From cda5b01368290f7df63e4fe86809fc0d8cbf29fd Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Thu, 17 Nov 2016 21:02:35 -0500 Subject: [PATCH 01/25] Add xltDevice moudle, setup SDK structure --- .idea/dictionaries/sunboss.xml | 7 + .../xlightcompanion/SDK/BLEBridge.java | 33 + .../xlightcompanion/SDK/BaseBridge.java | 37 ++ .../xlightcompanion/SDK/CloudAccount.java | 14 + .../{particle => SDK}/ParticleBridge.java | 72 +-- .../xlightcompanion/SDK/lanBridge.java | 22 + .../xlightcompanion/SDK/xltDataAccess.java | 10 + .../xlightcompanion/SDK/xltDevice.java | 608 ++++++++++++++++++ .../control/ControlFragment.java | 80 ++- .../control/DevicesListAdapter.java | 12 +- .../glance/GlanceFragment.java | 4 +- .../xlightcompanion/main/MainActivity.java | 20 +- .../scenario/AddScenarioActivity.java | 5 +- .../schedule/AddScheduleActivity.java | 10 +- .../settings/SettingsFragment.java | 29 - build.gradle | 2 +- 16 files changed, 835 insertions(+), 130 deletions(-) create mode 100644 .idea/dictionaries/sunboss.xml create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java rename app/src/main/java/com/umarbhutta/xlightcompanion/{particle => SDK}/ParticleBridge.java (87%) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDataAccess.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java diff --git a/.idea/dictionaries/sunboss.xml b/.idea/dictionaries/sunboss.xml new file mode 100644 index 0000000..c281e18 --- /dev/null +++ b/.idea/dictionaries/sunboss.xml @@ -0,0 +1,7 @@ + + + + rgbw + + + \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java new file mode 100644 index 0000000..04d98f4 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java @@ -0,0 +1,33 @@ +package com.umarbhutta.xlightcompanion.SDK; + +/** + * Created by sunboss on 2016-11-16. + */ + +@SuppressWarnings({"UnusedDeclaration"}) +public class BLEBridge extends BaseBridge { + // misc + private static final String TAG = BLEBridge.class.getSimpleName(); + private boolean m_bPaired = false; + + public BLEBridge() { + super(); + setName(TAG); + } + + public boolean PairDevice(final String key) { + // ToDo: pair with SmartController + m_bPaired = true; + return m_bPaired; + } + + public boolean isPaired() { + return m_bPaired; + } + + public boolean connectController(final String key) { + // ToDo: connect SmartController via BLE + setConnect(true); + return isConnected(); + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java new file mode 100644 index 0000000..efbf09e --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java @@ -0,0 +1,37 @@ +package com.umarbhutta.xlightcompanion.SDK; + +/** + * Created by sunboss on 2016-11-17. + */ + +@SuppressWarnings({"UnusedDeclaration"}) +// Base Class for Bridges +public class BaseBridge { + private boolean m_bConnected = false; + private String m_Name = "Unknown bridge"; + private int m_priority = 5; + + public boolean isConnected() { + return m_bConnected; + } + + public void setConnect(final boolean connected) { + m_bConnected = connected; + } + + public String getName() { + return m_Name; + } + + public void setName(final String name) { + m_Name = name; + } + + public int getPriority() { + return m_priority; + } + + public void setPriority(final int priority) { + m_priority = priority; + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java new file mode 100644 index 0000000..7ba1a13 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java @@ -0,0 +1,14 @@ +package com.umarbhutta.xlightcompanion.SDK; + +/** + * Created by sunboss on 2016-10-06. + */ +public class CloudAccount { + //Login details + //Context context = getApplicationContext(); + //InputStream acct = context.getAssets().open("account.xml"); + public static final String EMAIL = "sunbaoshi1975@gmail.com"; + public static final String PASSWORD = "1qazxsw2"; + //public static final String DEVICE_ID = "2d0027001647343432313031"; // The Redboard + public static final String DEVICE_ID = "250040001947343433313339"; // EF001 - prototype +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/particle/ParticleBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java similarity index 87% rename from app/src/main/java/com/umarbhutta/xlightcompanion/particle/ParticleBridge.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java index a943bbc..abaa424 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/particle/ParticleBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java @@ -1,4 +1,4 @@ -package com.umarbhutta.xlightcompanion.particle; +package com.umarbhutta.xlightcompanion.SDK; import android.content.Context; import android.os.Bundle; @@ -10,7 +10,6 @@ import com.umarbhutta.xlightcompanion.main.MainActivity; import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; import com.umarbhutta.xlightcompanion.schedule.ScheduleFragment; -import com.umarbhutta.xlightcompanion.particle.CloudAccount; import java.io.IOException; import java.util.ArrayList; @@ -28,6 +27,7 @@ /** * Created by Umar Bhutta. */ +@SuppressWarnings({"UnusedDeclaration"}) public class ParticleBridge { //misc private static final String TAG = ParticleBridge.class.getSimpleName(); @@ -41,29 +41,6 @@ public class ParticleBridge { private static int resultCode; private static long subscriptionId = 0; - //CLOUD FUNCTION CONSTS - //cmd types - public static final int VALUE_POWER = 1; - public static final int VALUE_COLOR = 2; - public static final int VALUE_BRIGHTNESS = 3; - public static final int VALUE_SCENARIO = 4; - public static final int VALUE_CCT = 5; - public static final int VALUE_QUERY = 6; - //device id - public static final int DEFAULT_DEVICE_ID = 1; - //ring values - public static final int RING_ALL = 0; - public static final int RING_1 = 1; - - public static final int RING_2 = 2; - public static final int RING_3 = 3; - //ring text - public static final String DEFAULT_LAMP_TEXT = "LIVING ROOM"; - public static final String RINGALL_TEXT = "ALL RINGS"; - public static final String RING1_TEXT = "RING 1"; - public static final String RING2_TEXT = "RING 2"; - public static final String RING3_TEXT = "RING 3"; - //on/off values public static final int STATE_OFF = 0; public static final int STATE_ON = 1; @@ -75,15 +52,6 @@ public class ParticleBridge { public static final String eventDeviceStatus = "xlc-status-device"; public static final String eventSensorData = "xlc-data-sensor"; - //constants for testing lists - public static final String[] deviceNames = {"Living Room", "Bedroom", "Basement Kitchen"}; - public static final String[] scheduleTimes = {"10:30 AM", "12:45 PM", "02:00 PM", "06:45 PM", "08:00 PM", "11:30 PM"}; - public static final String[] scheduleDays = {"Mo Tu We Th Fr", "Every day", "Mo We Th Sa Su", "Tomorrow", "We", "Mo Tu Fr Sa Su"}; - public static final String[] scenarioNames = {"Brunching", "Guests", "Naptime", "Dinner", "Sunset", "Bedtime"}; - public static final String[] scenarioDescriptions = {"A red color at 52% brightness", "A blue-green color at 100% brightness", "An amber color at 50% brightness", "Turn off", "A warm-white color at 100% brightness", "A green color at 52% brightness"}; - public static final String[] filterNames = {"Breathe", "Music Match", "Flash"}; - - //Particle functions public static void authenticate(Context context) { ParticleDeviceSetupLibrary.init(context, MainActivity.class); @@ -120,7 +88,7 @@ public void run() { int power = state ? 1 : 0; // Make the Particle call here - String json = "{\"cmd\":" + VALUE_POWER + ",\"node_id\":" + nodeId + ",\"state\":" + power + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_POWER + ",\"node_id\":" + nodeId + ",\"state\":" + power + "}"; //String json = "{'cmd':" + VALUE_POWER + ",'node_id':" + nodeId + ",'state':" + power + "}"; ArrayList message = new ArrayList<>(); message.add(json); @@ -141,7 +109,7 @@ public static int JSONCommandBrightness(final int nodeId, final int value) { @Override public void run() { // Make the Particle call here - String json = "{\"cmd\":" + VALUE_BRIGHTNESS + ",\"node_id\":" + nodeId + ",\"value\":" + value + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_BRIGHTNESS + ",\"node_id\":" + nodeId + ",\"value\":" + value + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -161,7 +129,7 @@ public static int JSONCommandCCT(final int nodeId, final int value) { @Override public void run() { // Make the Particle call here - String json = "{\"cmd\":" + VALUE_CCT + ",\"node_id\":" + nodeId + ",\"value\":" + value + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_CCT + ",\"node_id\":" + nodeId + ",\"value\":" + value + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -176,14 +144,14 @@ public void run() { return resultCode; } - public static int JSONCommandColor(final int nodeId, final int ring, final boolean state, final int cw, final int ww, final int r, final int g, final int b) { + public static int JSONCommandColor(final int nodeId, final int ring, final boolean state, final int br, final int ww, final int r, final int g, final int b) { new Thread() { @Override public void run() { // Make the Particle call here int power = state ? 1 : 0; - String json = "{\"cmd\":" + VALUE_COLOR + ",\"node_id\":" + nodeId + ",\"ring\":" + ring + ",\"color\":[" + power + "," + cw + "," + ww + "," + r + "," + g + "," + b + "]}"; + String json = "{\"cmd\":" + xltDevice.CMD_COLOR + ",\"node_id\":" + nodeId + ",\"ring\":[" + ring + "," + power + "," + br + "," + ww + "," + r + "," + g + "," + b + "]}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -199,7 +167,7 @@ public void run() { } - public static int JSONCommandScenario(final int nodeId, final int position) { + public static int JSONCommandScenario(final int nodeId, final int scenario) { new Thread() { @Override public void run() { @@ -207,7 +175,7 @@ public void run() { //hence the parameter of position is good to go in this function as is - doesn't need to be incremented by 1 for the uid for scenario // Make the Particle call here - String json = "{\"cmd\":" + VALUE_SCENARIO + ",\"node_id\":" + nodeId + ",\"SNT_id\":" + position + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_SCENARIO + ",\"node_id\":" + nodeId + ",\"SNT_id\":" + scenario + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -227,7 +195,7 @@ public static int JSONCommandQueryDevice(final int nodeId) { @Override public void run() { // Make the Particle call here - String json = "{\"cmd\":" + VALUE_QUERY + ",\"node_id\":" + nodeId + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_QUERY + ",\"node_id\":" + nodeId + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -482,7 +450,7 @@ public void onEvent(String eventName, ParticleEvent event) { //if (eventName.equalsIgnoreCase(eventDeviceStatus)) { if (jObject.has("nd")) { int nodeId = jObject.getInt("nd"); - if (nodeId == 1) { + if (nodeId == MainActivity.m_mainDevice.getDeviceID()) { Message msgControlObj = null; Bundle bdlControl = null; @@ -492,28 +460,28 @@ public void onEvent(String eventName, ParticleEvent event) { } if (jObject.has("State")) { - MainActivity.mainDevice_st = jObject.getInt("State"); + MainActivity.m_mainDevice.setState(jObject.getInt("State")); if( MainActivity.handlerDeviceList != null ) { Message msgObj = MainActivity.handlerDeviceList.obtainMessage(); Bundle b = new Bundle(); - b.putInt("State", MainActivity.mainDevice_st); + b.putInt("State", MainActivity.m_mainDevice.getState()); msgObj.setData(b); MainActivity.handlerDeviceList.sendMessage(msgObj); } if( MainActivity.handlerControl != null ) { - bdlControl.putInt("State", MainActivity.mainDevice_st); + bdlControl.putInt("State", MainActivity.m_mainDevice.getState()); } } if (jObject.has("BR")) { - MainActivity.mainDevice_br = jObject.getInt("BR"); + MainActivity.m_mainDevice.setBrightness(jObject.getInt("BR")); if( MainActivity.handlerControl != null ) { - bdlControl.putInt("BR", MainActivity.mainDevice_br); + bdlControl.putInt("BR", MainActivity.m_mainDevice.getBrightness()); } } if (jObject.has("CCT")) { - MainActivity.mainDevice_cct = jObject.getInt("CCT"); + MainActivity.m_mainDevice.setCCT(jObject.getInt("CCT")); if( MainActivity.handlerControl != null ) { - bdlControl.putInt("CCT", MainActivity.mainDevice_cct); + bdlControl.putInt("CCT", MainActivity.m_mainDevice.getCCT()); } } @@ -525,11 +493,11 @@ public void onEvent(String eventName, ParticleEvent event) { } //} else if (eventName.equalsIgnoreCase(eventSensorData)) { if (jObject.has("DHTt")) { - MainActivity.mainRoomTemp = jObject.getInt("DHTt"); + MainActivity.m_mainDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); if( MainActivity.handlerGlance != null ) { Message msgObj = MainActivity.handlerGlance.obtainMessage(); Bundle b = new Bundle(); - b.putInt("DHTt", MainActivity.mainRoomTemp); + b.putInt("DHTt", (int)MainActivity.m_mainDevice.m_Data.m_RoomTemp); msgObj.setData(b); MainActivity.handlerGlance.sendMessage(msgObj); } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java new file mode 100644 index 0000000..6cb7ea7 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java @@ -0,0 +1,22 @@ +package com.umarbhutta.xlightcompanion.SDK; + +/** + * Created by sunboss on 2016-11-16. + */ + +@SuppressWarnings({"UnusedDeclaration"}) +public class LANBridge extends BaseBridge { + // misc + private static final String TAG = LANBridge.class.getSimpleName(); + + public LANBridge() { + super(); + setName(TAG); + } + + public boolean connectController(final String address, final int port) { + // ToDo: connect to SmartController HTTP + setConnect(true); + return isConnected(); + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDataAccess.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDataAccess.java new file mode 100644 index 0000000..e73c791 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDataAccess.java @@ -0,0 +1,10 @@ +package com.umarbhutta.xlightcompanion.SDK; + +/** + * Created by sunboss on 2016-11-16. + */ + +// Data Manipulate Interface (DMI) +public class xltDataAccess { + private static final String TAG = xltDataAccess.class.getSimpleName(); +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java new file mode 100644 index 0000000..5ba4103 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -0,0 +1,608 @@ +package com.umarbhutta.xlightcompanion.SDK; + +import android.content.Context; + +/** + * Created by sunboss on 2016-11-15. + * + * Version: 0.1 + * + * Please report bug at bs.sun@datatellit.com, + * or send pull request to sunbaoshi1975/Android-XlightSmartController + * + */ + +@SuppressWarnings({"UnusedDeclaration"}) +// Smart Device +public class xltDevice { + + //------------------------------------------------------------------------- + // misc + //------------------------------------------------------------------------- + private static final String TAG = xltDevice.class.getSimpleName(); + public static final int DEFAULT_DEVICE_ID = 1; + + //------------------------------------------------------------------------- + // Constants + //------------------------------------------------------------------------- + public static final int MAX_RING_NUM = 3; + public static final int RING_ID_ALL = 0; + public static final int RING_ID_1 = 1; + public static final int RING_ID_2 = 2; + public static final int RING_ID_3 = 3; + + public static final int BR_MIN_VALUE = 1; + public static final int CT_MIN_VALUE = 2700; + public static final int CT_MAX_VALUE = 6500; + public static final int CT_SCOPE = 38; + public static final int CT_STEP = ((CT_MAX_VALUE-CT_MIN_VALUE)/10); + + // Command values for JSONCommand Interface + public static final int CMD_SERIAL = 0; + public static final int CMD_POWER = 1; + public static final int CMD_COLOR = 2; + public static final int CMD_BRIGHTNESS = 3; + public static final int CMD_SCENARIO = 4; + public static final int CMD_CCT = 5; + public static final int CMD_QUERY = 6; + + // Device (lamp) type + public static final int devtypUnknown = 0; + public static final int devtypCRing3 = 1; + public static final int devtypCRing2 = 2; + public static final int devtypCRing1 = 3; + public static final int devtypWRing3 = 4; + public static final int devtypWRing2 = 5; + public static final int devtypWRing1 = 6; + public static final int devtypMRing3 = 8; + public static final int devtypMRing2 = 9; + public static final int devtypMRing1 = 10; + public static final int devtypDummy = 255; + + public enum BridgeType { + NONE, + Cloud, + BLE, + LAN + } + + //------------------------------------------------------------------------- + // Ring of Smart Fixture + //------------------------------------------------------------------------- + public class xltRing { + // Lights Status + public int m_State = 0; + public int m_Brightness = 50; + public int m_CCT = CT_MIN_VALUE; + public int m_R = 128; + public int m_G = 128; + public int m_B = 0; + + public boolean isSameColor(final xltRing that) { + if( this.m_State != that.m_State ) return false; + if( this.m_CCT != that.m_CCT ) return false; + if( this.m_R != that.m_R ) return false; + if( this.m_G != that.m_G ) return false; + if( this.m_B != that.m_B ) return false; + return true; + } + + public boolean isSameBright(final xltRing that) { + if( this.m_State != that.m_State ) return false; + if( this.m_CCT != that.m_CCT ) return false; + if( this.m_Brightness != that.m_Brightness ) return false; + return true; + } + } + + //------------------------------------------------------------------------- + // Sensor Data + //------------------------------------------------------------------------- + public class SensorData { + public float m_RoomTemp = 24; // Room temperature + public int m_RoomHumidity = 40; // Room humidity + + public float m_OutsideTemp = 23; // Local outside temperature + public int m_OutsideHumidity = 30; // Local outside humidity + } + + //------------------------------------------------------------------------- + // Variables + //------------------------------------------------------------------------- + // Profile + private int m_DevID = DEFAULT_DEVICE_ID; + private String m_DevName = "Main xlight"; + private int m_DevType = devtypWRing3; + + // Bridge Objects + private BLEBridge bleBridge; + private LANBridge lanBridge; + + // Bridge Selection + private BridgeType m_currentBridge = BridgeType.Cloud; + private boolean m_autoBridge = true; + + // Rings + private xltRing[] m_Ring = new xltRing[MAX_RING_NUM]; + + // Sensor Data + public SensorData m_Data; + + //------------------------------------------------------------------------- + // Regular Interfaces + //------------------------------------------------------------------------- + public xltDevice() { + super(); + + // Create member objects + m_Data= new SensorData(); + for(int i = 0; i < MAX_RING_NUM; i++) { + m_Ring[i] = new xltRing(); + } + + bleBridge = new BLEBridge(); + lanBridge = new LANBridge(); + } + + // Initialize object and connect to message bridges + public boolean Init(Context context) { + // ToDo: get login credential or access token from DMI + + // login to IoT cloud + ParticleBridge.authenticate(context); + + // Open BLE + bleBridge.connectController("8888"); + + // Open LAN + // ToDo: get IP & Port from Cloud or BLE (SmartController told it) + lanBridge.connectController("192.168.0.114", 5555); + + return true; + } + + public boolean isSunny(final int DevType) { + return(DevType >= devtypWRing3 && DevType <= devtypWRing1); + } + + public boolean isRainbow(final int DevType) { + return(DevType >= devtypCRing3 && DevType <= devtypCRing1); + } + + public boolean isMirage(final int DevType) { + return(DevType >= devtypMRing3 && DevType <= devtypMRing1); + } + + private int getRingIndex(final int ringID) { + return((ringID >= RING_ID_1 && ringID <= RING_ID_3) ? ringID - 1 : 0); + } + + //------------------------------------------------------------------------- + // Property Access Interfaces + //------------------------------------------------------------------------- + public int getDeviceID() { + return m_DevID; + } + + public void setDeviceID(final int devID) { + m_DevID = devID; + } + + public int getDeviceType() { + return m_DevType; + } + + public void setDeviceType(final int devType) { + m_DevType = devType; + } + + public String getDeviceName() { + return m_DevName; + } + + public void setDeviceName(final String devName) { + m_DevName = devName; + } + + public int getState() { + return(getState(RING_ID_ALL)); + } + + public int getState(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_State); + } + + public void setState(final int state) { + setState(RING_ID_ALL, state); + } + + public void setState(final int ringID, final int state) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_State = state; + m_Ring[1].m_State = state; + m_Ring[2].m_State = state; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_State = state; + } + } + + public int getBrightness() { + return(getBrightness(RING_ID_ALL)); + } + + public int getBrightness(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_Brightness); + } + + public void setBrightness(final int brightness) { + setBrightness(RING_ID_ALL, brightness); + } + + public void setBrightness(final int ringID, final int brightness) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_Brightness = brightness; + m_Ring[1].m_Brightness = brightness; + m_Ring[2].m_Brightness = brightness; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_Brightness = brightness; + } + } + + public int getCCT() { + return(getCCT(RING_ID_ALL)); + } + + public int getCCT(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_CCT); + } + + public void setCCT(final int cct) { + setCCT(RING_ID_ALL, cct); + } + + public void setCCT(final int ringID, final int cct) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_CCT = cct; + m_Ring[1].m_CCT = cct; + m_Ring[2].m_CCT = cct; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_CCT = cct; + } + } + + public int getWhite() { + return(getWhite(RING_ID_ALL)); + } + + public int getWhite(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_CCT % 256); + } + + public void setWhite(final int white) { + setWhite(RING_ID_ALL, white); + } + + public void setWhite(final int ringID, final int white) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_CCT = white; + m_Ring[1].m_CCT = white; + m_Ring[2].m_CCT = white; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_CCT = white; + } + } + + public int getRed() { + return(getRed(RING_ID_ALL)); + } + + public int getRed(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_R); + } + + public void setRed(final int red) { + setRed(RING_ID_ALL, red); + } + + public void setRed(final int ringID, final int red) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_R = red; + m_Ring[1].m_R = red; + m_Ring[2].m_R = red; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_R = red; + } + } + + public int getGreen() { + return(getGreen(RING_ID_ALL)); + } + + public int getGreen(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_G); + } + + public void setGreen(final int green) { + setGreen(RING_ID_ALL, green); + } + + public void setGreen(final int ringID, final int green) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_G = green; + m_Ring[1].m_G = green; + m_Ring[2].m_G = green; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_G = green; + } + } + + public int getBlue() { + return(getBlue(RING_ID_ALL)); + } + + public int getBlue(final int ringID) { + int index = getRingIndex(ringID); + return(m_Ring[index].m_B); + } + + public void setBlue(final int blue) { + setBlue(RING_ID_ALL, blue); + } + + public void setBlue(final int ringID, final int blue) { + if( ringID == RING_ID_ALL ) { + m_Ring[0].m_B = blue; + m_Ring[1].m_B = blue; + m_Ring[2].m_B = blue; + } else { + int index = getRingIndex(ringID); + m_Ring[index].m_B = blue; + } + } + + //------------------------------------------------------------------------- + // Bridge Selection Interfaces + //------------------------------------------------------------------------- + public String getBridgeInfo(final BridgeType bridge) { + String desc; + switch(bridge) { + case Cloud: + desc = "Cloud bridge " + (isCloudOK() ? "connected" : "not connected"); + break; + case BLE: + desc = bleBridge.getName() + " " + (isBLEOK() ? "connected" : "not connected"); + //desc += " Peer: "; + break; + case LAN: + desc = lanBridge.getName() + " " + (isLANOK() ? "connected" : "not connected"); + //desc += " IP: " + " Port: "; + break; + default: + desc = "No bridge available"; + } + return desc; + } + + public boolean isCloudOK() { + return(ParticleBridge.currDevice.isConnected()); + } + + public boolean isBLEOK() { + return(bleBridge.isConnected()); + } + + public boolean isLANOK() { + return(lanBridge.isConnected()); + } + + public boolean isBridgeOK(final BridgeType bridge) { + switch(bridge) { + case Cloud: + return isCloudOK(); + case BLE: + return isBLEOK(); + case LAN: + return isLANOK(); + } + return false; + } + + public boolean getAutoBridge() { + return m_autoBridge; + } + + public void setAutoBridge(final boolean auto) { + m_autoBridge = auto; + } + + public void setBridgePriority(final BridgeType bridge, final int priority) { + + } + + private BridgeType selectBridge() { + // ToDo: develop an algorithm to select proper bridge + /// Use current bridge as long as available + if( isBridgeOK(m_currentBridge) ) return m_currentBridge; + + if( getAutoBridge() ) { + if (isCloudOK()) { + m_currentBridge = BridgeType.Cloud; + } else if (isBLEOK()) { + m_currentBridge = BridgeType.BLE; + } else if (isLANOK()) { + m_currentBridge = BridgeType.LAN; + } else { + m_currentBridge = BridgeType.NONE; + } + } + + return m_currentBridge; + } + + // Manually set bridge + public boolean useBridge(final BridgeType bridge) { + if( bridge == BridgeType.Cloud && !isCloudOK() ) { + return false; + } + if( bridge == BridgeType.BLE && !isLANOK() ) { + return false; + } + if( bridge == BridgeType.LAN && !isBLEOK() ) { + return false; + } + + m_currentBridge = bridge; + return true; + } + + public BridgeType getCurrentBridge() { + return m_currentBridge; + } + + //------------------------------------------------------------------------- + // Device Control Interfaces (DCI) + //------------------------------------------------------------------------- + // Query Status + public int QueryStatus() { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = ParticleBridge.JSONCommandQueryDevice(m_DevID); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } + + // Turn On / Off + public int PowerSwitch(final boolean state) { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = ParticleBridge.FastCallPowerSwitch(m_DevID, state); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } + + // Change Brightness + public int ChangeBrightness(final int value) { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = ParticleBridge.JSONCommandBrightness(m_DevID, value); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } + + // Change CCT + public int ChangeCCT(final int value) { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = ParticleBridge.JSONCommandCCT(m_DevID, value); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } + + // Change Color (RGBW) + public int ChangeColor(final int ring, final boolean state, final int br, final int ww, final int r, final int g, final int b) { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = ParticleBridge.JSONCommandColor(m_DevID, ring, state, br, ww, r, g, b); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } + + // Change Scenario + public int ChangeScenario(final int scenario) { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = ParticleBridge.JSONCommandScenario(m_DevID, scenario); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index 4731740..736e9cc 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -23,8 +23,8 @@ import android.widget.ToggleButton; import com.umarbhutta.xlightcompanion.R; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; import com.umarbhutta.xlightcompanion.main.MainActivity; -import com.umarbhutta.xlightcompanion.particle.ParticleBridge; import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; import java.util.ArrayList; @@ -38,6 +38,13 @@ */ public class ControlFragment extends Fragment { private static final String TAG = ControlFragment.class.getSimpleName(); + + private static final String DEFAULT_LAMP_TEXT = "LIVING ROOM"; + private static final String RINGALL_TEXT = "ALL RINGS"; + private static final String RING1_TEXT = "RING 1"; + private static final String RING2_TEXT = "RING 2"; + private static final String RING3_TEXT = "RING 3"; + private Switch powerSwitch; private SeekBar brightnessSeekBar; private SeekBar cctSeekBar; @@ -87,9 +94,12 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa // Apply the scenarioAdapter to the spinner scenarioSpinner.setAdapter(scenarioAdapter); - powerSwitch.setChecked(MainActivity.mainDevice_st > 0); - brightnessSeekBar.setProgress(MainActivity.mainDevice_br); - cctSeekBar.setProgress(MainActivity.mainDevice_cct - 2700); + // Just for demo. In real world, should get from DMI + MainActivity.m_mainDevice.setDeviceName(DEFAULT_LAMP_TEXT); + + powerSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); + brightnessSeekBar.setProgress(MainActivity.m_mainDevice.getBrightness()); + cctSeekBar.setProgress(MainActivity.m_mainDevice.getCCT() - 2700); MainActivity.handlerControl = new Handler() { public void handleMessage(Message msg) { @@ -116,7 +126,8 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { //check if on or off state = isChecked; //ParticleBridge.JSONCommandPower(ParticleBridge.DEFAULT_DEVICE_ID, state); - ParticleBridge.FastCallPowerSwitch(ParticleBridge.DEFAULT_DEVICE_ID, state); + //ParticleBridge.FastCallPowerSwitch(ParticleBridge.DEFAULT_DEVICE_ID, state); + MainActivity.m_mainDevice.PowerSwitch(state); } }); @@ -133,7 +144,7 @@ public void onColorSelected(int color) { colorHex = String.format("%06X", (0xFFFFFF & color)); Log.e(TAG, "HEX: #" + colorHex); - int cw = 0; + int br = 65; int ww = 0; int c = (int) Long.parseLong(colorHex, 16); int r = (c >> 16) & 0xFF; @@ -147,22 +158,32 @@ public void onColorSelected(int color) { //send message to Particle based on which rings have been selected if ((ring1 && ring2 && ring3) || (!ring1 && !ring2 && !ring3)) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_ALL, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_ALL, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, state, br, ww, r, g, b); } else if (ring1 && ring2) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, cw, ww, r, g, b); - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, br, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); } else if (ring2 && ring3) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, cw, ww, r, g, b); - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, br, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); } else if (ring1 && ring3) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, cw, ww, r, g, b); - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, br, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); } else if (ring1) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); } else if (ring2) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); } else if (ring3) { - ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, cw, ww, r, g, b); + //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, br, ww, r, g, b); + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); } else { //do nothing } @@ -185,7 +206,8 @@ public void onStartTrackingTouch(SeekBar seekBar) { @Override public void onStopTrackingTouch(SeekBar seekBar) { Log.e(TAG, "The brightness value is " + seekBar.getProgress()); - ParticleBridge.JSONCommandBrightness(ParticleBridge.DEFAULT_DEVICE_ID, seekBar.getProgress()); + //ParticleBridge.JSONCommandBrightness(ParticleBridge.DEFAULT_DEVICE_ID, seekBar.getProgress()); + MainActivity.m_mainDevice.ChangeBrightness(seekBar.getProgress()); } }); @@ -201,7 +223,8 @@ public void onStartTrackingTouch(SeekBar seekBar) { @Override public void onStopTrackingTouch(SeekBar seekBar) { Log.d(TAG, "The CCT value is " + seekBar.getProgress()+2700); - ParticleBridge.JSONCommandCCT(ParticleBridge.DEFAULT_DEVICE_ID, seekBar.getProgress()+2700); + //ParticleBridge.JSONCommandCCT(ParticleBridge.DEFAULT_DEVICE_ID, seekBar.getProgress()+2700); + MainActivity.m_mainDevice.ChangeCCT(seekBar.getProgress()+2700); } }); @@ -220,8 +243,9 @@ public void onItemSelected(AdapterView parent, View view, int position, long //disable all views below spinner disableEnableControls(false); - ParticleBridge.JSONCommandScenario(ParticleBridge.DEFAULT_DEVICE_ID, position); + //ParticleBridge.JSONCommandScenario(ParticleBridge.DEFAULT_DEVICE_ID, position); //position passed into above function corresponds to the scenarioId i.e. s1, s2, s3 to trigger + MainActivity.m_mainDevice.ChangeScenario(position); } } @@ -278,31 +302,31 @@ private void disableEnableControls(boolean isEnabled) { } private void updateDeviceRingLabel() { - String label = ParticleBridge.DEFAULT_LAMP_TEXT; + String label = MainActivity.m_mainDevice.getDeviceName(); if (ring1 && ring2 && ring3) { - label += ": " + ParticleBridge.RINGALL_TEXT; + label += ": " + RINGALL_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring123); } else if (!ring1 && !ring2 && !ring3) { - label += ": " + ParticleBridge.RINGALL_TEXT; + label += ": " + RINGALL_TEXT; lightImageView.setImageResource(R.drawable.aquabg_noring); } else if (ring1 && ring2) { - label += ": " + ParticleBridge.RING1_TEXT + " & " + ParticleBridge.RING2_TEXT; + label += ": " + RING1_TEXT + " & " + RING2_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring12); } else if (ring2 && ring3) { - label += ": " + ParticleBridge.RING2_TEXT + " & " + ParticleBridge.RING3_TEXT; + label += ": " + RING2_TEXT + " & " + RING3_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring23); } else if (ring1 && ring3) { - label += ": " + ParticleBridge.RING1_TEXT + " & " + ParticleBridge.RING3_TEXT; + label += ": " + RING1_TEXT + " & " + RING3_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring13); } else if (ring1) { - label += ": " + ParticleBridge.RING1_TEXT; + label += ": " + RING1_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring1); } else if (ring2) { - label += ": " + ParticleBridge.RING2_TEXT; + label += ": " + RING2_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring2); } else if (ring3) { - label += ": " + ParticleBridge.RING3_TEXT; + label += ": " + RING3_TEXT; lightImageView.setImageResource(R.drawable.aquabg_ring3); } else { label += ""; diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java index bc6578c..d1a9850 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java @@ -11,13 +11,14 @@ import android.widget.TextView; import com.umarbhutta.xlightcompanion.main.MainActivity; -import com.umarbhutta.xlightcompanion.particle.ParticleBridge; +import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.R; /** * Created by Umar Bhutta. */ public class DevicesListAdapter extends RecyclerView.Adapter { + @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.devices_list_item, parent, false); @@ -47,21 +48,22 @@ public DevicesListViewHolder(View itemView) { mDeviceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener(){ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - ParticleBridge.FastCallPowerSwitch(ParticleBridge.DEFAULT_DEVICE_ID, isChecked); + //ParticleBridge.FastCallPowerSwitch(ParticleBridge.DEFAULT_DEVICE_ID, isChecked); + MainActivity.m_mainDevice.PowerSwitch(isChecked); } }); } public void bindView (int position) { - mDeviceName.setText(ParticleBridge.deviceNames[position]); + mDeviceName.setText(MainActivity.deviceNames[position]); if (position == 0) { // Main device - mDeviceSwitch.setChecked(MainActivity.mainDevice_st > 0); + mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); MainActivity.handlerDeviceList = new Handler() { public void handleMessage(Message msg) { int intValue = msg.getData().getInt("State", -255); if( intValue != -255 ) { - mDeviceSwitch.setChecked(MainActivity.mainDevice_st > 0); + mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); } } }; diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java index 849de59..26400d8 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java @@ -51,7 +51,7 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, outsideTemp = (TextView) view.findViewById(R.id.outsideTemp); degreeSymbol = (TextView) view.findViewById(R.id.degreeSymbol); roomTemp = (TextView) view.findViewById(R.id.valRoomTemp); - roomTemp.setText(MainActivity.mainRoomTemp + "\u00B0"); + roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); MainActivity.handlerGlance = new Handler() { public void handleMessage(Message msg) { @@ -126,7 +126,7 @@ public void run() { private void updateDisplay() { outsideTemp.setText(" " + mWeatherDetails.getTemp("celsius")); degreeSymbol.setText("\u00B0"); - roomTemp.setText(MainActivity.mainRoomTemp + "\u00B0"); + roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); } private WeatherDetails getWeatherDetails(String jsonData) throws JSONException { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 98e255b..614c88e 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -16,17 +16,22 @@ import com.umarbhutta.xlightcompanion.R; import com.umarbhutta.xlightcompanion.control.ControlFragment; import com.umarbhutta.xlightcompanion.glance.GlanceFragment; -import com.umarbhutta.xlightcompanion.particle.ParticleBridge; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; import com.umarbhutta.xlightcompanion.schedule.ScheduleFragment; public class MainActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener { - public static int mainDevice_st = 0; - public static int mainDevice_br = 50; - public static int mainDevice_cct = 2700; - public static int mainRoomTemp = 24; + //constants for testing lists + public static final String[] deviceNames = {"Living Room", "Bedroom", "Basement Kitchen"}; + public static final String[] scheduleTimes = {"10:30 AM", "12:45 PM", "02:00 PM", "06:45 PM", "08:00 PM", "11:30 PM"}; + public static final String[] scheduleDays = {"Mo Tu We Th Fr", "Every day", "Mo We Th Sa Su", "Tomorrow", "We", "Mo Tu Fr Sa Su"}; + public static final String[] scenarioNames = {"Brunching", "Guests", "Naptime", "Dinner", "Sunset", "Bedtime"}; + public static final String[] scenarioDescriptions = {"A red color at 52% brightness", "A blue-green color at 100% brightness", "An amber color at 50% brightness", "Turn off", "A warm-white color at 100% brightness", "A green color at 52% brightness"}; + public static final String[] filterNames = {"Breathe", "Music Match", "Flash"}; + + public static xltDevice m_mainDevice; public static Handler handlerGlance = null; public static Handler handlerDeviceList = null; @@ -39,8 +44,9 @@ protected void onCreate(Bundle savedInstanceState) { Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); - //login to Particle cloud - ParticleBridge.authenticate(this); + // Initialize SmartDevice SDK + m_mainDevice = new xltDevice(); + m_mainDevice.Init(this); //setup drawer layout DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java index 9789e7d..aa6974f 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java @@ -18,8 +18,9 @@ import android.widget.Spinner; import android.widget.TextView; -import com.umarbhutta.xlightcompanion.particle.ParticleBridge; +import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.R; +import com.umarbhutta.xlightcompanion.main.MainActivity; import me.priyesh.chroma.ChromaDialog; import me.priyesh.chroma.ColorMode; @@ -64,7 +65,7 @@ protected void onCreate(Bundle savedInstanceState) { filterSpinner = (Spinner) findViewById(R.id.filterSpinner); // Create an ArrayAdapter using the string array and a default spinner layout - ArrayAdapter filterAdapter = new ArrayAdapter<>(this, R.layout.control_scenario_spinner_item, ParticleBridge.filterNames); + ArrayAdapter filterAdapter = new ArrayAdapter<>(this, R.layout.control_scenario_spinner_item, MainActivity.filterNames); // Specify the layout to use when the list of choices appears filterAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); // Apply the scenarioAdapter to the spinner diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java index 9445d51..52ae6be 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java @@ -15,8 +15,9 @@ import android.widget.Spinner; import android.widget.TimePicker; -import com.umarbhutta.xlightcompanion.particle.ParticleBridge; import com.umarbhutta.xlightcompanion.R; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; +import com.umarbhutta.xlightcompanion.main.MainActivity; import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; import java.util.Calendar; @@ -31,7 +32,7 @@ public class AddScheduleActivity extends AppCompatActivity { private Button addButton; private ImageView backImageView; - private int defeaultNodeId = ParticleBridge.DEFAULT_DEVICE_ID; + private int defeaultNodeId = xltDevice.DEFAULT_DEVICE_ID; private boolean isRepeat = false; private int hour, minute, nodeId; private String am_pm, weekdays, outgoingWeekdays, scenarioName; @@ -83,7 +84,7 @@ protected void onCreate(Bundle savedInstanceState) { //initialize device spinner deviceSpinner = (Spinner) findViewById(R.id.deviceSpinner); // Create an ArrayAdapter using the string array and a default spinner layout - ArrayAdapter deviceAdapter = new ArrayAdapter<>(this, R.layout.add_schedule_spinner_item, ParticleBridge.deviceNames); + ArrayAdapter deviceAdapter = new ArrayAdapter<>(this, R.layout.add_schedule_spinner_item, MainActivity.deviceNames); // Specify the layout to use when the list of choices appears deviceAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); // Apply the scenarioAdapter to the spinner @@ -245,7 +246,8 @@ public void onClick(View v) { } //call JSONConfigAlarm to send a schedule row - ParticleBridge.JSONConfigAlarm(defeaultNodeId, isRepeat, weekdays, hour, minute, scenarioName); + // DMI + //ParticleBridge.JSONConfigAlarm(defeaultNodeId, isRepeat, weekdays, hour, minute, scenarioName); //send data to update the list Intent returnIntent = getIntent(); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/settings/SettingsFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/settings/SettingsFragment.java index 4d2827a..3ee159d 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/settings/SettingsFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/settings/SettingsFragment.java @@ -1,37 +1,8 @@ package com.umarbhutta.xlightcompanion.settings; -import android.content.Context; import android.content.SharedPreferences; -import android.graphics.Color; -import android.os.Bundle; import android.preference.PreferenceFragment; import android.preference.PreferenceManager; -import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; -import android.support.v4.content.ContextCompat; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.CompoundButton; -import android.widget.LinearLayout; -import android.widget.SeekBar; -import android.widget.Spinner; -import android.widget.Switch; -import android.widget.TextView; -import android.widget.ToggleButton; - -import com.umarbhutta.xlightcompanion.particle.ParticleBridge; -import com.umarbhutta.xlightcompanion.R; -import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; - -import java.util.ArrayList; - -import me.priyesh.chroma.ChromaDialog; -import me.priyesh.chroma.ColorMode; -import me.priyesh.chroma.ColorSelectListener; /** * Created by Umar Bhutta. diff --git a/build.gradle b/build.gradle index 53f4fad..c20bca1 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.2.1' + classpath 'com.android.tools.build:gradle:2.2.2' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files From 99d739fab44ed6c120c4641fd34b111f1ba2b98b Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Thu, 17 Nov 2016 21:16:16 -0500 Subject: [PATCH 02/25] clean --- .gitignore | 1 + .../xlightcompanion/SDK/CloudAccount.java | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java diff --git a/.gitignore b/.gitignore index c6cbe56..20970f7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .DS_Store /build /captures +CloudAccount.java diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java deleted file mode 100644 index 7ba1a13..0000000 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.umarbhutta.xlightcompanion.SDK; - -/** - * Created by sunboss on 2016-10-06. - */ -public class CloudAccount { - //Login details - //Context context = getApplicationContext(); - //InputStream acct = context.getAssets().open("account.xml"); - public static final String EMAIL = "sunbaoshi1975@gmail.com"; - public static final String PASSWORD = "1qazxsw2"; - //public static final String DEVICE_ID = "2d0027001647343432313031"; // The Redboard - public static final String DEVICE_ID = "250040001947343433313339"; // EF001 - prototype -} From 1edd84d45a4afd07d5d4773b11f378ac4c5906d0 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Thu, 24 Nov 2016 14:28:18 -0500 Subject: [PATCH 03/25] support multiple cloud device instances --- .gitignore | 8 +- .idea/misc.xml | 2 +- app/.gitignore | 2 +- app/src/main/AndroidManifest.xml | 13 +- .../xlightcompanion/SDK/BaseBridge.java | 16 + .../xlightcompanion/SDK/CloudBridge.java | 571 ++++++++++++++++++ .../xlightcompanion/SDK/ParticleBridge.java | 531 ++-------------- .../xlightcompanion/SDK/xltDevice.java | 129 +++- .../xlightcompanion/main/MainActivity.java | 3 + .../scenario/AddScenarioActivity.java | 2 +- screenshot/control.png | Bin 0 -> 225210 bytes screenshot/control1.png | Bin 0 -> 225210 bytes screenshot/glance.png | Bin 0 -> 92720 bytes screenshot/glance1.png | Bin 0 -> 92169 bytes screenshot/shcedule.png | Bin 0 -> 68380 bytes 15 files changed, 770 insertions(+), 507 deletions(-) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java create mode 100644 screenshot/control.png create mode 100644 screenshot/control1.png create mode 100644 screenshot/glance.png create mode 100644 screenshot/glance1.png create mode 100644 screenshot/shcedule.png diff --git a/.gitignore b/.gitignore index 20970f7..a71d409 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,10 @@ .DS_Store /build /captures -CloudAccount.java +<<<<<<< HEAD +<<<<<<< HEAD +app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java +======= +>>>>>>> parent of 99d739f... clean +======= +>>>>>>> parent of 99d739f... clean diff --git a/.idea/misc.xml b/.idea/misc.xml index 5d19981..fbb6828 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -37,7 +37,7 @@ - + diff --git a/app/.gitignore b/app/.gitignore index 612e9ba..94a15fa 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1,2 @@ /build -/src/main/java/com/umarbhutta/xlightcompanion/particle/CloudAccount.java +/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudAccount.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 101e6c2..8ec8835 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,10 +24,19 @@ + android:screenOrientation="portrait" /> + android:screenOrientation="portrait" /> + + + \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java index efbf09e..014afa0 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java @@ -4,12 +4,16 @@ * Created by sunboss on 2016-11-17. */ +import android.content.Context; + @SuppressWarnings({"UnusedDeclaration"}) // Base Class for Bridges public class BaseBridge { private boolean m_bConnected = false; + private int m_nodeID; private String m_Name = "Unknown bridge"; private int m_priority = 5; + protected Context m_parentContext = null; public boolean isConnected() { return m_bConnected; @@ -19,6 +23,14 @@ public void setConnect(final boolean connected) { m_bConnected = connected; } + public void setNodeID(final int nodeID) { + m_nodeID = nodeID; + } + + public int getNodeID() { + return m_nodeID; + } + public String getName() { return m_Name; } @@ -34,4 +46,8 @@ public int getPriority() { public void setPriority(final int priority) { m_priority = priority; } + + public void setParentContext(Context context) { + m_parentContext = context; + } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java new file mode 100644 index 0000000..ea00517 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java @@ -0,0 +1,571 @@ +package com.umarbhutta.xlightcompanion.SDK; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import com.umarbhutta.xlightcompanion.main.MainActivity; +import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; +import com.umarbhutta.xlightcompanion.schedule.ScheduleFragment; + +import java.io.IOException; +import java.util.ArrayList; + +import org.json.JSONException; +import org.json.JSONObject; + +import io.particle.android.sdk.cloud.ParticleCloudException; +import io.particle.android.sdk.cloud.ParticleCloudSDK; +import io.particle.android.sdk.cloud.ParticleDevice; +import io.particle.android.sdk.cloud.ParticleEvent; +import io.particle.android.sdk.cloud.ParticleEventHandler; + +/** + * Created by sunboss on 2016-11-23. + */ +@SuppressWarnings({"UnusedDeclaration"}) +public class CloudBridge extends BaseBridge { + // misc + private static final String TAG = CloudBridge.class.getSimpleName(); + + private ParticleDevice currDevice; + private static int resultCode; + private static long subscriptionId = 0; + + public CloudBridge() { + super(); + setName(TAG); + } + + public boolean connectCloud(final String devID) { + new Thread(new Runnable() { + @Override + public void run() { + try { + currDevice = ParticleCloudSDK.getCloud().getDevice(devID); + SubscribeDeviceEvents(); + setConnect(true); + + // Delay 2 seconds, then Query Main Device + Handler myHandler = new Handler(Looper.getMainLooper()); + myHandler.postDelayed(new Runnable() { + @Override + public void run() + { + JSONCommandQueryDevice(); + } + }, 2000); + + } catch (ParticleCloudException e) { + e.printStackTrace(); + } + } + }).start(); + + return true; + } + + public int JSONCommandPower(final boolean state) { + new Thread() { + @Override + public void run() { + int power = state ? xltDevice.STATE_ON : xltDevice.STATE_OFF; + + // Make the Particle call here + String json = "{\"cmd\":" + xltDevice.CMD_POWER + ",\"node_id\":" + getNodeID() + ",\"state\":" + power + "}"; + //String json = "{'cmd':" + VALUE_POWER + ",'node_id':" + nodeId + ",'state':" + power + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.e(TAG, "JSONCommandPower" + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + public int JSONCommandBrightness(final int value) { + new Thread() { + @Override + public void run() { + // Make the Particle call here + String json = "{\"cmd\":" + xltDevice.CMD_BRIGHTNESS + ",\"node_id\":" + getNodeID() + ",\"value\":" + value + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.e(TAG, "JSONCommandBrightness" + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + public int JSONCommandCCT(final int value) { + new Thread() { + @Override + public void run() { + // Make the Particle call here + String json = "{\"cmd\":" + xltDevice.CMD_CCT + ",\"node_id\":" + getNodeID() + ",\"value\":" + value + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.d(TAG, "JSONCommandCCT" + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + public int JSONCommandColor(final int ring, final boolean state, final int br, final int ww, final int r, final int g, final int b) { + new Thread() { + @Override + public void run() { + // Make the Particle call here + int power = state ? 1 : 0; + + String json = "{\"cmd\":" + xltDevice.CMD_COLOR + ",\"node_id\":" + getNodeID() + ",\"ring\":[" + ring + "," + power + "," + br + "," + ww + "," + r + "," + g + "," + b + "]}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.e(TAG, "JSONCommandColor " + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + + public int JSONCommandScenario(final int scenario) { + new Thread() { + @Override + public void run() { + //position corresponds to the spinner in Control. position of 1 corresponds to s1, 2 to s2. The 0th index in the spinner is the "None" item, + //hence the parameter of position is good to go in this function as is - doesn't need to be incremented by 1 for the uid for scenario + + // Make the Particle call here + String json = "{\"cmd\":" + xltDevice.CMD_SCENARIO + ",\"node_id\":" + getNodeID() + ",\"SNT_id\":" + scenario + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.e(TAG, "JSONCommandScenario " + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + public int JSONCommandQueryDevice() { + new Thread() { + @Override + public void run() { + // Make the Particle call here + String json = "{\"cmd\":" + xltDevice.CMD_QUERY + ",\"node_id\":" + getNodeID() + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.i(TAG, "JSONCommandQueryDevice" + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + public int JSONConfigScenario(final int brightness, final int cw, final int ww, final int r, final int g, final int b, final String filter) { + new Thread() { + @Override + public void run() { + int scenarioId = ScenarioFragment.name.size(); + boolean x[] = {false, false, false}; + + //construct first part of string input, and store it in arraylist (of size 1) + String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"s" + scenarioId + "\",\"ring1\":" + " '}"; + ArrayList message = new ArrayList<>(); + message.add(json); + //send in first part of string + try { + Log.e(TAG, "JSONConfigScenario " + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + x[0] = true; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + + if (x[0]) { + //construct second part of string input, store in arraylist + json = "{'x1': '[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"ring2\":[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "], '}"; + message.add(json); + //send in second part of string + try { + Log.e(TAG, "JSONConfigScenario " + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + x[1] = true; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + + if (x[1]) { + //construct last part of string input, store in arraylist + //json = "\"ring3\":[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + DEFAULT_FILTER_ID + "}"; + json = "\"ring3\":[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + xltDevice.DEFAULT_FILTER_ID + "}"; + message.add(json); + //send in last part of string + try { + Log.e(TAG, "JSONConfigScenario " + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + } + }.start(); + return resultCode; + } + + public int JSONConfigAlarm(final boolean isRepeat, final String weekdays, final int hour, final int minute, final String scenarioName) { + final int[] doneSending = {0}; + new Thread() { + @Override + public void run() { + boolean x[] = {false, false, false, false}; + + //SCHEDULE + int scheduleId = ScheduleFragment.name.size(); + int repeat = isRepeat ? 1 : 0; + + //construct first part of string input, and store it in arraylist (of size 1) + String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"a" + scheduleId + "\",\"isRepeat\":" + "1" + ", '}"; + ArrayList message = new ArrayList<>(); + message.add(json); + //send in first part of string + try { + Log.e(TAG, "JSONConfigSchedule " + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + x[0] = true; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + + if (x[0]) { + //construct second part of string input, store in arraylist + json = "\"weekdays\":" + "0" + ",\"hour\":" + hour + ",\"min\":" + minute + ",\"alarm_id\":" + xltDevice.DEFAULT_ALARM_ID + "}"; + message.add(json); + //send in second part of string + try { + Log.e(TAG, "JSONConfigSchedule " + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + x[1] = true; + doneSending[0] = 5; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + + //RULE + int rule_schedule_notif_Id = ScheduleFragment.name.size() - 1; + int scenarioId = 1; + for (int i = 0; i < ScenarioFragment.name.size(); i++) { + if (scenarioName == ScenarioFragment.name.get(i)) { + scenarioId = i; + } + } + + //construct first part of string input, and store it in arraylist (of size 1) + String json2 = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + rule_schedule_notif_Id + "\",\"node_id\":" + getNodeID() + ", '}"; + ArrayList message2 = new ArrayList<>(); + message2.add(json2); + //send in first part of string + try { + Log.e(TAG, "JSONConfigRule " + message2.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message2); + x[2] = true; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message2.clear(); + + if (x[2]) { + //construct second part of string input, store in arraylist + json2 = "\"SCT_uid\":" + rule_schedule_notif_Id + ",\"SNT_uid\":" + scenarioId + ",\"notif_uid\":" + rule_schedule_notif_Id + "}"; + message2.add(json2); + //send in second part of string + try { + Log.i(TAG, "JSONConfigRule " + message2.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message2); + x[3] = true; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message2.clear(); + } + } + } + }.start(); + return resultCode; + } + +// public int JSONConfigRule(final String scenarioName) { +// new Thread() { +// @Override +// public void run() { +// int rule_schedule_notif_Id = ScheduleFragment.name.size() + 1; +// int scenarioId = 1; +// for (int i = 0; i < ScenarioFragment.name.size(); i++) { +// if (scenarioName == ScenarioFragment.name.get(i)) { +// scenarioId = i + 1; +// } +// } +// boolean x[] = {false, false}; +// +// //construct first part of string input, and store it in arraylist (of size 1) +// String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + rule_schedule_notif_Id + "\",\"node_id\":" + nodeId + ", '}"; +// ArrayList message = new ArrayList<>(); +// message.add(json); +// //send in first part of string +// try { +// Log.e(TAG, "JSONConfigRule" + message.get(0)); +// resultCode = currDevice.callFunction("JSONConfig", message); +// x[0] = true; +// } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { +// e.printStackTrace(); +// } +// message.clear(); +// +// if (x[0]) { +// //construct second part of string input, store in arraylist +// json = "\"SCT_uid\":\"a" + rule_schedule_notif_Id + "\",\"SNT_uid\":\"s" + scenarioId + "\",\"notif_uid\":\"n" + rule_schedule_notif_Id + "\"}"; +// message.add(json); +// //send in second part of string +// try { +// Log.i(TAG, "JSONConfigRule" + message.get(0)); +// resultCode = currDevice.callFunction("JSONConfig", message); +// x[1] = true; +// } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { +// e.printStackTrace(); +// } +// message.clear(); +// } +// } +// }.start(); +// return resultCode; +// } + + public int JSONGetDeviceStatus() { + new Thread() { + @Override + public void run() { + //construct first part of string input, and store it in arraylist (of size 1) + String json = "{\"op\":0,\"fl\":1,\"run\":0,\"uid\":\"h" + getNodeID() + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + //send in first part of string + try { + Log.d(TAG, "JSONGetDeviceStatus " + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + + public int FastCallPowerSwitch(final boolean state) { + new Thread() { + @Override + public void run() { + // Make the Particle call here + String strParam = String.format("%d:%d", getNodeID(), state ? xltDevice.STATE_ON : xltDevice.STATE_OFF); + ArrayList message = new ArrayList<>(); + message.add(strParam); + try { + Log.d(TAG, "FastCallPowerSwitch: " + strParam); + resultCode = currDevice.callFunction("PowerSwitch", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + } + }.start(); + return resultCode; + } + + // Particle events publishing & subscribing + public long SubscribeDeviceEvents() { + new Thread() { + @Override + public void run() { + try { + subscriptionId = currDevice.subscribeToEvents(null, new ParticleEventHandler() { + public void onEvent(String eventName, ParticleEvent event) { + Log.i(TAG, "Received event: " + eventName + " with payload: " + event.dataPayload); + // Demo option: use handler & sendMessage to inform activities + InformActivities(eventName, event.dataPayload); + + // Demo Option: use broadcast & receivers to publish events + //BroadcastEvent(eventName, event.dataPayload); + } + + public void onEventError(Exception e) { + Log.e(TAG, "Event error: ", e); + } + }); + } catch (IOException e) { + e.printStackTrace(); + } + } + }.start(); + return subscriptionId; + } + + public void UnsubscribeDeviceEvents() { + new Thread() { + @Override + public void run() { + if( subscriptionId > 0 ) { + try { + currDevice.unsubscribeFromEvents(subscriptionId); + } catch (ParticleCloudException e) { + e.printStackTrace(); + } + } + } + }.start(); + } + + // Use handler & sendMessage to inform activities + private void InformActivities(final String eventName, final String dataPayload) { + try { + JSONObject jObject = new JSONObject(dataPayload); + //if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { + if (jObject.has("nd")) { + int nodeId = jObject.getInt("nd"); + if (nodeId == MainActivity.m_mainDevice.getDeviceID()) { + + Message msgControlObj = null; + Bundle bdlControl = null; + if( MainActivity.handlerControl != null ) { + msgControlObj = MainActivity.handlerControl.obtainMessage(); + bdlControl = new Bundle(); + } + + if (jObject.has("State")) { + MainActivity.m_mainDevice.setState(jObject.getInt("State")); + if( MainActivity.handlerDeviceList != null ) { + Message msgObj = MainActivity.handlerDeviceList.obtainMessage(); + Bundle b = new Bundle(); + b.putInt("State", MainActivity.m_mainDevice.getState()); + msgObj.setData(b); + MainActivity.handlerDeviceList.sendMessage(msgObj); + } + if( MainActivity.handlerControl != null ) { + bdlControl.putInt("State", MainActivity.m_mainDevice.getState()); + } + } + if (jObject.has("BR")) { + MainActivity.m_mainDevice.setBrightness(jObject.getInt("BR")); + if( MainActivity.handlerControl != null ) { + bdlControl.putInt("BR", MainActivity.m_mainDevice.getBrightness()); + } + } + if (jObject.has("CCT")) { + MainActivity.m_mainDevice.setCCT(jObject.getInt("CCT")); + if( MainActivity.handlerControl != null ) { + bdlControl.putInt("CCT", MainActivity.m_mainDevice.getCCT()); + } + } + + if( MainActivity.handlerControl != null && msgControlObj != null ) { + msgControlObj.setData(bdlControl); + MainActivity.handlerControl.sendMessage(msgControlObj); + } + } + } + //} else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { + if (jObject.has("DHTt")) { + MainActivity.m_mainDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); + if( MainActivity.handlerGlance != null ) { + Message msgObj = MainActivity.handlerGlance.obtainMessage(); + Bundle b = new Bundle(); + b.putInt("DHTt", (int)MainActivity.m_mainDevice.m_Data.m_RoomTemp); + msgObj.setData(b); + MainActivity.handlerGlance.sendMessage(msgObj); + } + } + if (jObject.has("DHTh")) { + MainActivity.m_mainDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); + } + //} + } catch (final JSONException e) { + Log.e(TAG, "Json parsing error: " + e.getMessage()); + } + } + + // Demo Option: use broadcast & receivers to publish events + private void BroadcastEvent(final String eventName, String dataPayload) { + try { + JSONObject jObject = new JSONObject(dataPayload); + if (jObject.has("nd")) { + int nodeId = jObject.getInt("nd"); + // ToDO: search device + if (nodeId == MainActivity.m_mainDevice.getDeviceID()) { + if (jObject.has("State")) { + MainActivity.m_mainDevice.setState(jObject.getInt("State")); + } + if (jObject.has("BR")) { + MainActivity.m_mainDevice.setBrightness(jObject.getInt("BR")); + } + if (jObject.has("CCT")) { + MainActivity.m_mainDevice.setCCT(jObject.getInt("CCT")); + } + } + } + if (jObject.has("DHTt")) { + MainActivity.m_mainDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); + } + if (jObject.has("DHTh")) { + MainActivity.m_mainDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); + } + //} + } catch (final JSONException e) { + Log.e(TAG, "Json parsing error: " + e.getMessage()); + } + + if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { + m_parentContext.sendBroadcast(new Intent(xltDevice.bciDeviceStatus)); + } else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { + m_parentContext.sendBroadcast(new Intent(xltDevice.bciSensorData)); + } + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java index abaa424..28efec8 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java @@ -1,79 +1,45 @@ package com.umarbhutta.xlightcompanion.SDK; import android.content.Context; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.util.Log; -import com.umarbhutta.xlightcompanion.main.MainActivity; -import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; -import com.umarbhutta.xlightcompanion.schedule.ScheduleFragment; - -import java.io.IOException; import java.util.ArrayList; - -import org.json.JSONException; -import org.json.JSONObject; +import java.util.List; import io.particle.android.sdk.cloud.ParticleCloudException; import io.particle.android.sdk.cloud.ParticleCloudSDK; import io.particle.android.sdk.cloud.ParticleDevice; -import io.particle.android.sdk.cloud.ParticleEvent; -import io.particle.android.sdk.cloud.ParticleEventHandler; -import io.particle.android.sdk.devicesetup.ParticleDeviceSetupLibrary; /** * Created by Umar Bhutta. */ @SuppressWarnings({"UnusedDeclaration"}) public class ParticleBridge { - //misc + // misc private static final String TAG = ParticleBridge.class.getSimpleName(); - //Max num constants - public static final int MAX_SCHEDULES = 6; - public static final int MAX_DEVICES = 6; - - //Particle vars - public static ParticleDevice currDevice; private static int resultCode; - private static long subscriptionId = 0; + private static boolean m_bLoggedIn = false; + private static List m_devices; + private static ArrayList m_deviceID2Name = new ArrayList<>(); - //on/off values - public static final int STATE_OFF = 0; - public static final int STATE_ON = 1; - //default alarm/filter id - public static final int DEFAULT_ALARM_ID = 255; - public static final int DEFAULT_FILTER_ID = 0; - - // Event names - public static final String eventDeviceStatus = "xlc-status-device"; - public static final String eventSensorData = "xlc-data-sensor"; + // Particle functions + public static void init(Context context) { + ParticleCloudSDK.init(context); + } - //Particle functions - public static void authenticate(Context context) { - ParticleDeviceSetupLibrary.init(context, MainActivity.class); + public static boolean isAuthenticated() { + //return (ParticleCloudSDK.getCloud().isLoggedIn()); + return m_bLoggedIn; + } + public static void authenticate() { new Thread(new Runnable() { @Override public void run() { try { ParticleCloudSDK.getCloud().logIn(CloudAccount.EMAIL, CloudAccount.PASSWORD); - currDevice = ParticleCloudSDK.getCloud().getDevice(CloudAccount.DEVICE_ID); - SubscribeDeviceEvents(); - - // Delay 2 seconds, then Query Main Device - Handler myHandler = new Handler(Looper.getMainLooper()); - myHandler.postDelayed(new Runnable() { - @Override - public void run() - { - JSONCommandQueryDevice(1); - } - }, 2000); - + queryDevices(); + m_bLoggedIn = true; } catch (ParticleCloudException e) { e.printStackTrace(); } @@ -81,457 +47,54 @@ public void run() }).start(); } - public static int JSONCommandPower(final int nodeId, final boolean state) { - new Thread() { - @Override - public void run() { - int power = state ? 1 : 0; - - // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_POWER + ",\"node_id\":" + nodeId + ",\"state\":" + power + "}"; - //String json = "{'cmd':" + VALUE_POWER + ",'node_id':" + nodeId + ",'state':" + power + "}"; - ArrayList message = new ArrayList<>(); - message.add(json); - try { - Log.e(TAG, "JSONCommandPower" + message.get(0)); - resultCode = currDevice.callFunction("JSONCommand", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; - } - - public static int JSONCommandBrightness(final int nodeId, final int value) { - new Thread() { - @Override - public void run() { - // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_BRIGHTNESS + ",\"node_id\":" + nodeId + ",\"value\":" + value + "}"; - ArrayList message = new ArrayList<>(); - message.add(json); - try { - Log.e(TAG, "JSONCommandBrightness" + message.get(0)); - resultCode = currDevice.callFunction("JSONCommand", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; - } - - public static int JSONCommandCCT(final int nodeId, final int value) { - new Thread() { - @Override - public void run() { - // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_CCT + ",\"node_id\":" + nodeId + ",\"value\":" + value + "}"; - ArrayList message = new ArrayList<>(); - message.add(json); - try { - Log.d(TAG, "JSONCommandCCT" + message.get(0)); - resultCode = currDevice.callFunction("JSONCommand", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; - } - - public static int JSONCommandColor(final int nodeId, final int ring, final boolean state, final int br, final int ww, final int r, final int g, final int b) { - new Thread() { - @Override - public void run() { - // Make the Particle call here - int power = state ? 1 : 0; - - String json = "{\"cmd\":" + xltDevice.CMD_COLOR + ",\"node_id\":" + nodeId + ",\"ring\":[" + ring + "," + power + "," + br + "," + ww + "," + r + "," + g + "," + b + "]}"; - ArrayList message = new ArrayList<>(); - message.add(json); - try { - Log.e(TAG, "JSONCommandColor " + message.get(0)); - resultCode = currDevice.callFunction("JSONCommand", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; - } - - - public static int JSONCommandScenario(final int nodeId, final int scenario) { - new Thread() { - @Override - public void run() { - //position corresponds to the spinner in Control. position of 1 corresponds to s1, 2 to s2. The 0th index in the spinner is the "None" item, - //hence the parameter of position is good to go in this function as is - doesn't need to be incremented by 1 for the uid for scenario - - // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_SCENARIO + ",\"node_id\":" + nodeId + ",\"SNT_id\":" + scenario + "}"; - ArrayList message = new ArrayList<>(); - message.add(json); - try { - Log.e(TAG, "JSONCommandScenario " + message.get(0)); - resultCode = currDevice.callFunction("JSONCommand", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; - } - - public static int JSONCommandQueryDevice(final int nodeId) { - new Thread() { - @Override - public void run() { - // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_QUERY + ",\"node_id\":" + nodeId + "}"; - ArrayList message = new ArrayList<>(); - message.add(json); - try { - Log.i(TAG, "JSONCommandQueryDevice" + message.get(0)); - resultCode = currDevice.callFunction("JSONCommand", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; - } - - public static int JSONConfigScenario(final int brightness, final int cw, final int ww, final int r, final int g, final int b, final String filter) { - new Thread() { - @Override - public void run() { - int scenarioId = ScenarioFragment.name.size(); - boolean x[] = {false, false, false}; - - //construct first part of string input, and store it in arraylist (of size 1) - String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"s" + scenarioId + "\",\"ring1\":" + " '}"; - ArrayList message = new ArrayList<>(); - message.add(json); - //send in first part of string - try { - Log.e(TAG, "JSONConfigScenario " + message.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message); - x[0] = true; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - - if (x[0]) { - //construct second part of string input, store in arraylist - json = "{'x1': '[" + STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"ring2\":[" + STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "], '}"; - message.add(json); - //send in second part of string - try { - Log.e(TAG, "JSONConfigScenario " + message.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message); - x[1] = true; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - - if (x[1]) { - //construct last part of string input, store in arraylist - //json = "\"ring3\":[" + STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + DEFAULT_FILTER_ID + "}"; - json = "\"ring3\":[" + STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + DEFAULT_FILTER_ID + "}"; - message.add(json); - //send in last part of string - try { - Log.e(TAG, "JSONConfigScenario " + message.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } + // Synchronous query + private static int queryDevices() { + // Make the Particle call here + try { + String sItem; + m_devices = ParticleCloudSDK.getCloud().getDevices(); + m_deviceID2Name.clear(); + for (ParticleDevice device : m_devices) { + sItem = device.getID() + ":" + device.getName(); + m_deviceID2Name.add(sItem); } - }.start(); + } catch (ParticleCloudException e) { + e.printStackTrace(); + resultCode = -1; + } return resultCode; } - public static int JSONConfigAlarm(final int nodeId, final boolean isRepeat, final String weekdays, final int hour, final int minute, final String scenarioName) { - final int[] doneSending = {0}; + // Asynchronous operation + public static int getDeviceList() { + resultCode = 0; new Thread() { @Override public void run() { - boolean x[] = {false, false, false, false}; - - //SCHEDULE - int scheduleId = ScheduleFragment.name.size(); - int repeat = isRepeat ? 1 : 0; - - //construct first part of string input, and store it in arraylist (of size 1) - String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"a" + scheduleId + "\",\"isRepeat\":" + "1" + ", '}"; - ArrayList message = new ArrayList<>(); - message.add(json); - //send in first part of string - try { - Log.e(TAG, "JSONConfigSchedule " + message.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message); - x[0] = true; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - - if (x[0]) { - //construct second part of string input, store in arraylist - json = "\"weekdays\":" + "0" + ",\"hour\":" + hour + ",\"min\":" + minute + ",\"alarm_id\":" + DEFAULT_ALARM_ID + "}"; - message.add(json); - //send in second part of string - try { - Log.e(TAG, "JSONConfigSchedule " + message.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message); - x[1] = true; - doneSending[0] = 5; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - - - - //RULE - int rule_schedule_notif_Id = ScheduleFragment.name.size() - 1; - int scenarioId = 1; - for (int i = 0; i < ScenarioFragment.name.size(); i++) { - if (scenarioName == ScenarioFragment.name.get(i)) { - scenarioId = i; - } - } - - //construct first part of string input, and store it in arraylist (of size 1) - String json2 = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + rule_schedule_notif_Id + "\",\"node_id\":" + nodeId + ", '}"; - ArrayList message2 = new ArrayList<>(); - message2.add(json2); - //send in first part of string - try { - Log.e(TAG, "JSONConfigRule " + message2.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message2); - x[2] = true; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message2.clear(); - - if (x[2]) { - //construct second part of string input, store in arraylist - json2 = "\"SCT_uid\":" + rule_schedule_notif_Id + ",\"SNT_uid\":" + scenarioId + ",\"notif_uid\":" + rule_schedule_notif_Id + "}"; - message2.add(json2); - //send in second part of string - try { - Log.i(TAG, "JSONConfigRule " + message2.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message2); - x[3] = true; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message2.clear(); - } - } + queryDevices(); } }.start(); return resultCode; } -// public static int JSONConfigRule(final int nodeId, final String scenarioName) { -// new Thread() { -// @Override -// public void run() { -// int rule_schedule_notif_Id = ScheduleFragment.name.size() + 1; -// int scenarioId = 1; -// for (int i = 0; i < ScenarioFragment.name.size(); i++) { -// if (scenarioName == ScenarioFragment.name.get(i)) { -// scenarioId = i + 1; -// } -// } -// boolean x[] = {false, false}; -// -// //construct first part of string input, and store it in arraylist (of size 1) -// String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + rule_schedule_notif_Id + "\",\"node_id\":" + nodeId + ", '}"; -// ArrayList message = new ArrayList<>(); -// message.add(json); -// //send in first part of string -// try { -// Log.e(TAG, "JSONConfigRule" + message.get(0)); -// resultCode = currDevice.callFunction("JSONConfig", message); -// x[0] = true; -// } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { -// e.printStackTrace(); -// } -// message.clear(); -// -// if (x[0]) { -// //construct second part of string input, store in arraylist -// json = "\"SCT_uid\":\"a" + rule_schedule_notif_Id + "\",\"SNT_uid\":\"s" + scenarioId + "\",\"notif_uid\":\"n" + rule_schedule_notif_Id + "\"}"; -// message.add(json); -// //send in second part of string -// try { -// Log.i(TAG, "JSONConfigRule" + message.get(0)); -// resultCode = currDevice.callFunction("JSONConfig", message); -// x[1] = true; -// } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { -// e.printStackTrace(); -// } -// message.clear(); -// } -// } -// }.start(); -// return resultCode; -// } - - public static int JSONGetDeviceStatus(final int nodeId) { - new Thread() { - @Override - public void run() { - //construct first part of string input, and store it in arraylist (of size 1) - String json = "{\"op\":0,\"fl\":1,\"run\":0,\"uid\":\"h" + nodeId + "}"; - ArrayList message = new ArrayList<>(); - message.add(json); - //send in first part of string - try { - Log.d(TAG, "JSONGetDeviceStatus " + message.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message.clear(); - } - }.start(); - return resultCode; + public static int getDeviceCount() { + return m_devices.size(); } - public static int FastCallPowerSwitch(final int nodeId, final boolean state) { - new Thread() { - @Override - public void run() { - // Make the Particle call here - String strParam = String.format("%d:%d", nodeId, state ? 1 : 0); - ArrayList message = new ArrayList<>(); - message.add(strParam); - try { - Log.d(TAG, "FastCallPowerSwitch: " + strParam); - resultCode = currDevice.callFunction("PowerSwitch", message); - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - } - }.start(); - return resultCode; + // ID:Name + public static ArrayList getDeviceNames() { + return m_deviceID2Name; } - // Particle events publishing & subscribing - public static long SubscribeDeviceEvents() { - new Thread() { - @Override - public void run() { - try { - subscriptionId = currDevice.subscribeToEvents(null, new ParticleEventHandler() { - public void onEvent(String eventName, ParticleEvent event) { - Log.i(TAG, "Received event: " + eventName + " with payload: " + event.dataPayload); - try { - JSONObject jObject = new JSONObject(event.dataPayload); - //if (eventName.equalsIgnoreCase(eventDeviceStatus)) { - if (jObject.has("nd")) { - int nodeId = jObject.getInt("nd"); - if (nodeId == MainActivity.m_mainDevice.getDeviceID()) { - - Message msgControlObj = null; - Bundle bdlControl = null; - if( MainActivity.handlerControl != null ) { - msgControlObj = MainActivity.handlerControl.obtainMessage(); - bdlControl = new Bundle(); - } - - if (jObject.has("State")) { - MainActivity.m_mainDevice.setState(jObject.getInt("State")); - if( MainActivity.handlerDeviceList != null ) { - Message msgObj = MainActivity.handlerDeviceList.obtainMessage(); - Bundle b = new Bundle(); - b.putInt("State", MainActivity.m_mainDevice.getState()); - msgObj.setData(b); - MainActivity.handlerDeviceList.sendMessage(msgObj); - } - if( MainActivity.handlerControl != null ) { - bdlControl.putInt("State", MainActivity.m_mainDevice.getState()); - } - } - if (jObject.has("BR")) { - MainActivity.m_mainDevice.setBrightness(jObject.getInt("BR")); - if( MainActivity.handlerControl != null ) { - bdlControl.putInt("BR", MainActivity.m_mainDevice.getBrightness()); - } - } - if (jObject.has("CCT")) { - MainActivity.m_mainDevice.setCCT(jObject.getInt("CCT")); - if( MainActivity.handlerControl != null ) { - bdlControl.putInt("CCT", MainActivity.m_mainDevice.getCCT()); - } - } - - if( MainActivity.handlerControl != null && msgControlObj != null ) { - msgControlObj.setData(bdlControl); - MainActivity.handlerControl.sendMessage(msgControlObj); - } - } - } - //} else if (eventName.equalsIgnoreCase(eventSensorData)) { - if (jObject.has("DHTt")) { - MainActivity.m_mainDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); - if( MainActivity.handlerGlance != null ) { - Message msgObj = MainActivity.handlerGlance.obtainMessage(); - Bundle b = new Bundle(); - b.putInt("DHTt", (int)MainActivity.m_mainDevice.m_Data.m_RoomTemp); - msgObj.setData(b); - MainActivity.handlerGlance.sendMessage(msgObj); - } - } - //} - } catch (final JSONException e) { - Log.e(TAG, "Json parsing error: " + e.getMessage()); - } - } - - public void onEventError(Exception e) { - Log.e(TAG, "Event error: ", e); - } - }); - } catch (IOException e) { - e.printStackTrace(); + public static boolean checkDeviceID(final String devID) { + if( isAuthenticated() ) { + for (ParticleDevice device : m_devices) { + if (devID.equalsIgnoreCase(device.getID())) { + return true; } - } - }.start(); - return subscriptionId; - } - public static void UnsubscribeDeviceEvents() { - new Thread() { - @Override - public void run() { - if( subscriptionId > 0 ) { - try { - currDevice.unsubscribeFromEvents(subscriptionId); - } catch (ParticleCloudException e) { - e.printStackTrace(); - } - } } - }.start(); + } + return false; } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index 5ba4103..9e20c15 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -1,6 +1,7 @@ package com.umarbhutta.xlightcompanion.SDK; import android.content.Context; +import android.os.SystemClock; /** * Created by sunboss on 2016-11-15. @@ -22,6 +23,25 @@ public class xltDevice { private static final String TAG = xltDevice.class.getSimpleName(); public static final int DEFAULT_DEVICE_ID = 1; + // on/off values + public static final int STATE_OFF = 0; + public static final int STATE_ON = 1; + + // default alarm/filter id + public static final int DEFAULT_ALARM_ID = 255; + public static final int DEFAULT_FILTER_ID = 0; + + // Event Names + public static final String eventDeviceStatus = "xlc-status-device"; + public static final String eventSensorData = "xlc-data-sensor"; + + // Broadcast Intent + public static final String bciDeviceStatus = "ca.xlight.SDK." + eventDeviceStatus; + public static final String bciSensorData = "ca.xlight.SDK." + eventSensorData; + + // Timeout constants + private static final int TIMEOUT_CLOUD_LOGIN = 15; + //------------------------------------------------------------------------- // Constants //------------------------------------------------------------------------- @@ -110,11 +130,14 @@ public class SensorData { // Variables //------------------------------------------------------------------------- // Profile + private static boolean m_bInitialized = false; + private String m_ControllerID; private int m_DevID = DEFAULT_DEVICE_ID; private String m_DevName = "Main xlight"; private int m_DevType = devtypWRing3; // Bridge Objects + private CloudBridge cldBridge; private BLEBridge bleBridge; private LANBridge lanBridge; @@ -140,27 +163,73 @@ public xltDevice() { m_Ring[i] = new xltRing(); } + cldBridge = new CloudBridge(); bleBridge = new BLEBridge(); lanBridge = new LANBridge(); } - // Initialize object and connect to message bridges - public boolean Init(Context context) { - // ToDo: get login credential or access token from DMI + // Initialize objects + public void Init(Context context) { + // Ensure we do it only once + if( !m_bInitialized ) { + ParticleBridge.init(context); + // ToDo: get login credential or access token from DMI + // make sure we logged onto IoT cloud + ParticleBridge.authenticate(); + m_bInitialized = true; + } + } - // login to IoT cloud - ParticleBridge.authenticate(context); + // Connect to message bridges + public boolean Connect(final String controllerID) { + // ToDo: get devID & devName by controllerID from DMI + m_ControllerID = controllerID; + setDeviceID(DEFAULT_DEVICE_ID); + //setDeviceName(); - // Open BLE - bleBridge.connectController("8888"); + // Connect to Cloud + ConnectCloud(); - // Open LAN - // ToDo: get IP & Port from Cloud or BLE (SmartController told it) - lanBridge.connectController("192.168.0.114", 5555); + // Connect to BLE + ConnectBLE(); + + // Connect to LAN + ConnectLAN(); return true; } + public boolean ConnectCloud() { + if( !m_bInitialized ) return false; + + new Thread(new Runnable() { + @Override + public void run() { + // Check ControllerID + int timeout = TIMEOUT_CLOUD_LOGIN; + while( !ParticleBridge.isAuthenticated() && timeout-- > 0 ) { + SystemClock.sleep(1000); + } + if( ParticleBridge.isAuthenticated() ) { + if (ParticleBridge.checkDeviceID(m_ControllerID)) { + // Connect Cloud Instance + cldBridge.connectCloud(m_ControllerID); + } + } + } + }).start(); + return true; + } + + public boolean ConnectBLE() { + return(bleBridge.connectController("8888")); + } + + public boolean ConnectLAN() { + // ToDo: get IP & Port from Cloud or BLE (SmartController told it) + return(lanBridge.connectController("192.168.0.114", 5555)); + } + public boolean isSunny(final int DevType) { return(DevType >= devtypWRing3 && DevType <= devtypWRing1); } @@ -180,12 +249,25 @@ private int getRingIndex(final int ringID) { //------------------------------------------------------------------------- // Property Access Interfaces //------------------------------------------------------------------------- + public String getControllerID() { + return m_ControllerID; + } + public int getDeviceID() { return m_DevID; } public void setDeviceID(final int devID) { m_DevID = devID; + cldBridge.setNodeID(devID); + bleBridge.setNodeID(devID); + lanBridge.setNodeID(devID); + } + + private void setParentContext(Context context) { + cldBridge.setParentContext(context); + bleBridge.setParentContext(context); + lanBridge.setParentContext(context); } public int getDeviceType() { @@ -396,7 +478,7 @@ public String getBridgeInfo(final BridgeType bridge) { } public boolean isCloudOK() { - return(ParticleBridge.currDevice.isConnected()); + return(cldBridge.isConnected()); } public boolean isBLEOK() { @@ -483,7 +565,7 @@ public int QueryStatus() { if( isBridgeOK(m_currentBridge) ) { switch(m_currentBridge) { case Cloud: - rc = ParticleBridge.JSONCommandQueryDevice(m_DevID); + rc = cldBridge.JSONCommandQueryDevice(); break; case BLE: // ToDo: call BLE API @@ -505,7 +587,7 @@ public int PowerSwitch(final boolean state) { if( isBridgeOK(m_currentBridge) ) { switch(m_currentBridge) { case Cloud: - rc = ParticleBridge.FastCallPowerSwitch(m_DevID, state); + rc = cldBridge.FastCallPowerSwitch(state); break; case BLE: // ToDo: call BLE API @@ -527,7 +609,7 @@ public int ChangeBrightness(final int value) { if( isBridgeOK(m_currentBridge) ) { switch(m_currentBridge) { case Cloud: - rc = ParticleBridge.JSONCommandBrightness(m_DevID, value); + rc = cldBridge.JSONCommandBrightness(value); break; case BLE: // ToDo: call BLE API @@ -549,7 +631,7 @@ public int ChangeCCT(final int value) { if( isBridgeOK(m_currentBridge) ) { switch(m_currentBridge) { case Cloud: - rc = ParticleBridge.JSONCommandCCT(m_DevID, value); + rc = cldBridge.JSONCommandCCT(value); break; case BLE: // ToDo: call BLE API @@ -571,7 +653,7 @@ public int ChangeColor(final int ring, final boolean state, final int br, final if( isBridgeOK(m_currentBridge) ) { switch(m_currentBridge) { case Cloud: - rc = ParticleBridge.JSONCommandColor(m_DevID, ring, state, br, ww, r, g, b); + rc = cldBridge.JSONCommandColor(ring, state, br, ww, r, g, b); break; case BLE: // ToDo: call BLE API @@ -593,7 +675,7 @@ public int ChangeScenario(final int scenario) { if( isBridgeOK(m_currentBridge) ) { switch(m_currentBridge) { case Cloud: - rc = ParticleBridge.JSONCommandScenario(m_DevID, scenario); + rc = cldBridge.JSONCommandScenario(scenario); break; case BLE: // ToDo: call BLE API @@ -605,4 +687,17 @@ public int ChangeScenario(final int scenario) { } return rc; } + + //------------------------------------------------------------------------- + // Device Manipulate Interfaces (DMI) + //------------------------------------------------------------------------- + public int sceAddScenario(final int br, final int cw, final int ww, final int r, final int g, final int b, final String filter) { + int rc = -1; + + // Can only use Cloud Bridge + if( isCloudOK() ) { + rc = cldBridge.JSONConfigScenario(br, cw, ww, r, g, b, filter); + } + return rc; + } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 614c88e..17501f6 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -14,6 +14,8 @@ import android.view.MenuItem; import com.umarbhutta.xlightcompanion.R; +import com.umarbhutta.xlightcompanion.SDK.CloudAccount; +import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.control.ControlFragment; import com.umarbhutta.xlightcompanion.glance.GlanceFragment; import com.umarbhutta.xlightcompanion.SDK.xltDevice; @@ -47,6 +49,7 @@ protected void onCreate(Bundle savedInstanceState) { // Initialize SmartDevice SDK m_mainDevice = new xltDevice(); m_mainDevice.Init(this); + m_mainDevice.Connect(CloudAccount.DEVICE_ID); //setup drawer layout DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java index aa6974f..9d2485a 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java @@ -125,7 +125,7 @@ public void onClick(View v) { scenarioInfo = "A " + colorHex + " color with " + scenarioBrightness + "% brightness"; //SEND TO PARTICLE CLOUD FOR ALL RINGS - ParticleBridge.JSONConfigScenario(scenarioBrightness, cw, ww, r, g, b, scenarioFilter); + MainActivity.m_mainDevice.sceAddScenario(scenarioBrightness, cw, ww, r, g, b, scenarioFilter); //send data to update the list Intent returnIntent = getIntent(); diff --git a/screenshot/control.png b/screenshot/control.png new file mode 100644 index 0000000000000000000000000000000000000000..eb58e8befe3128ab9df7089cae2edb9b4ed10eb7 GIT binary patch literal 225210 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%0DP2!)lmU+hKYh6^hB zx;Kd+`P$k1Eb0QIUz3H|e?5Q&sH7?oe~k|$iGHg3yR{;fHLiE=Nmn(xD|@H+GYuWL zp7&Oj`1To0v_&fV{W(hw0H1_M%0`I^-_=d;N*+2ISDcq-e|;Th%zJ(8GcXnWk#wv;Gu|v#)7Erf0sV*hr6XnpwN-1rOp8`>dNU1E1G+ zD!sRFnO~cb*d6Q5WV%6Fe5Y6Vhr&~LZR-o;d3hq4*t~J>?Lq10x4M^%VN?Vo64}it ze5_PZ_hEAX$Ycm2DD-zMHI!pHF14!!1OqyNY5-vSH^6p4{zE>OqhKD11dV_(OV2o2 zYmJwAwG7p*PmZ;%Qv9gmEyVpOF0nUzaP69Ndpj+e)g~y_g;llAyo;ncKj%$I2T=*8 zu=KR?7CzSgryhdZ*ux%S=`iP`RFp%YpUO2#=u*}VP{k~mA&6gA9>zq`J0T?`4vHCr zdHmY>?>Q0ZEef6&N9UN$5OySD>AMbq`M>no9Qv6{3wDFggDdZ<@DN82e{Gfq;l7Zk z68$?lU#^`r`ne!+bPErRSTN?btJX_y=DyVW!n7+^FOYS26{o$w5an$7!btLciKAs6 zQ7I@%o73-(ci=J#2j{Y$3Dy&%tyQA=cfPZj>`GBaM4YLiCL{#S20xX~G$X~gx^rBQ zQ_8;V#|`F9z2{ibe}dx^{xsU!z8~Pv?Iwsr31}X}6!7pl?yGuDXgK`)wP?f4sWHK0 z3qi9ygR5HUQ=2NGNKF!{hdvf1eBR8M$HGa5rbSZwre1F#)=31WIsOcg10q0s_4E)O zTZ@vY=kHMGY(RFmB4l{;i_i%hzt+U}Rz#3pnFIHgS*3aNN6keTF`-Q=1`p;jHw|p5UVtW8!a3jn?$d&k!v`S1CU^-U^ry>+QP3>T zJvbm_&>L3?ruy-HvtA5}MWqUJ4iQ1({Yugn>D)ws!Ja5hOUlB1!+_UL!C_Al-He?N zLi)E>meyBXZ-^q3I-N{nGcn{OW!gJIPF*S$FE!Y-7n=plZfQsqM^yv|67p!42C z^bo7$cx`ZM;xdR*(D$w0&F6s@l=u{(j8jopa9ydX<4Zo*n*jCQNJq&EG2cX!adkJ- zGQ;5W&iSxL&63+O)KCT@$kQkAXFLh{Fyay)--iTTYP|eKgLw0OlXnsQDZ9z#CMcff zDit^3c(~nTX*Iq|p4TF4C{*hBL#YI0OI));KRV{CK-nDpcAv()wN>C%DYO?vUlyj2 zU8LIjEwF_iVLZL;Fkw}!Nfq3Cs2xqW;{?`cGrtD zoR`547tD?X!U_(HfUQ9%a9-eVM1cNE1z3kaw^#-6zL^WoR%kDL9&Y zde8*S?l}-1BrXs~5Pz%;z@m(u=l4xDO*fErZ}_1M6i|{D>$6{#EpdgPSrp%R$ih2s zpf@S7OIby`#e%dMpBFyUzQR6WG!NuLGqn1FObTK5qbNn8CYz z=2b#eUW1nMYjQ*{Gy!JH1{Ss2{pl?cqnXh?>RwF&{{023vw0@rz%&2xZe{~2l|P%% zoygic5JBvk?=5;9clMAb8}ew{k&N9?J-MNE2Keg2KQ{km>8GM*3I8~J>02Af!9anb z%jCPYQJFxRY~ZwO?~!r4(CEW}x~bhosO26i6(EfHpJeta%unStSQ$qk{ka}OqV&M~ z2g`Etu!2ahX^9o|b{7v9eEhq@c%z%aONc6sXEpnT(QhF8Jtl@I3Xy$fI4LV9gPB$=`3F;*DZ%HND6qwI zE;nPf*4H5VuLo(aJ=ejX-zuB3GU!f&uy}QSz#Oh&!2>`{ z=*T6(0V6p8>eFKP1YT*Lg%Y^+>()i6D2V^FpuC-q0Sfk~Ey};MXF*uGW2D2Z$e|RN z=o}ai4^%X=zIcxxx>T*}pnt}8(i}!8$oDCsTu-)F1gIS!`5gG<5bkE^kfqHf{S{L5Q0G-@wQhugOg8;m& z&ZV4q^>z{s;@%6EFv%hN397^l7lnu4zrUv-icYHcQ#qT(=S%_#^P?y#C4n@j6|Tf+ zfYmN_4b_td8O4p>B7w9uBsU{LLe)J@sP6EfZbo3H^8j?Gj{3JCcbmhr-<$A2!N?+@1Ys2@reS=RDcCR z!$1mfAp(LH5Vg@u|AvT~pURgasIU@WDf-T3#J3^fETA~9z8xfuUIX?3E%f*^!>fp5 zLd^f)Gn5;{7>$CA0hI`}^7^386&PJhJ7S*#{W{)PkNDHnThU!WK zUT`9T%P`WGK9uo{KSgd(S7!v? z5hMEEPsJ-PqTA%V(xp;rD45r2bc5m`1444Sh&CD=+nf_qhV!%j1N`}WXhidTXk-AY zK9fTc2q^zXPy`VK2o_$1;F!OQwwrl+l^7zd2=qxE@K!ki?S^!S{%c@OcED2qdgzKQ zEWl{>GJzZdTQckY9|7U+4JZFC4^lW_aHo-l_J)7Y`-jXB$SvW5DxdG*|7Z zG@$h_>B@SL@`CQ@-fg3@#;G==jHgDPeNtA+7g?pAj;fGB!xpmA>aBek7@4%^f6FlL zp)?z4y)OKIz6fgK>+%gW%2*Tq24#G)sBayXaUGVCRZ0#8q{$0Dfzw#iihB|g?0D)G z;D38ifs0Kw?G&X$2V$vGuItN(9=HN1ao zKUvOvDl@zKW{U8Ae~N>NB`#7V-2I1oN`>K4k7LkRYO`}~zcJ3ziZiUnb{SK5; zqfxtW^12m`=(PN12qB&&d#-BsY5(Q>TD_1J;+%998%o2;Ef_lua26TC_Or#~X{vAy zG9V06VWbbmYQZOj8mfnQ9)gMMpvTgnGu6vml=63QpU)wjAlkuJ6w#rYDlDNBED51f zONOhrJP?2o&o(cl3GKbyq6obSx2A$?*`*hnsYLFbh3>;O8hdDD#@=mOOmKCm)(E2? z%H#NU?*dliUGjnEEO#xMZa%BSibp~9Swg2b7AuX|5$`F4pgqDFVj>>| zp%vGU>AdEDONJ<6&~`7_EoTKtzI>OX%D9a)%1Cw^vB%6Ed^K96GLybWYe7BUUyccH zQelyp|NWiI=^EnxP4smbJ!`GnWwcJU4;;MAOKaFnNLgrMaelPL zw-C?B5Wleg>Tn}Iz3VK4)+@|KBj3>lXBa!6wNjUWSw125EYE^Dz{fgYy2|r1unAxTvt4O{QN^v(iLrXxvpl!6>Uf%G~6^Tq7H? z{nB26NU>+lw_V&)B^#tDt!_K}xY)k6QDwQsk&^DHS{*o!F=*Qs9O~t!FY9}|XW3XE zbxi^B*4#T!_(!}GY)HC<$3v>#=zH=r^|$!5E^|*M>A5!tW~Glc566r*IF^(yPNYL=j;_(=+fhNeSyLSBOsG=iI9+HvoLg9xC}fw ziXNrE^qivN>Eq&d>VsmP4#PgtLqEKnEDxzbuEmMlK*tkYPY)btfEz}Pbqx1(7>spn z*00XR67x?VVT1!(ugkCqG7&@KBWhWNHvJ|zT*8%X{o4!Ut+c6?0>$4%&+Cse-==_m zq|d&JiKD`(`SqpWG|0v56^lPda_N%TjYh*i_0H%9B!ylq+3Ow=kiBLX zo^;{h{)YM%X0CG}Sh-%1X2n-FAo%!s4E-)o-m~9ImfM}hBIn~HHB7fyE%q4)k~v4= zgk#^=gxe(dwb=*NwNl zg&#{mgCRHHIZ>E_GN!~xqEN-s#ITI!3N`hmVgX4@jTG1H)*tj-I!biVUJx7dr2Ix= zXo^7B=(vlSO7M?d`!{y9x?PJ*f3P=ec=t9)AKO^udG-!(=EN=h2;D-^*`N;&Otmz( z3Mo@J&N+d7%cdXYXHaKsa4G9ecmpp()#8ifzUKW*}9)UnY0?P~0`f36xyvPZVWvelD=1 zpC)*r?$}fLb`CzaF_v-7?N^qOZTv?YdBbxq;i^ulmzhU+yT!2Ir0T`vmxkW`s!?E@ zg!$pYJ3)P_Co!btTDoiPd#sYBq?OJ`o*Ol>@l&Vg{tA7DJcP0?LwUYeQ(13`#sCmkIJL1@fYw`f)fh5EHD1mN5~iT4=R>qbOOG^)RjRAsY|vK&7?)d)%v% zh=f)d#cmSG=wbNSJ}DdEf_uKC9iE0ChW%t#xNdD^NfaA8EENG^>b7 zjK;ov0_FJDDkS<33UyM=ZY;pFI-XRM`931-_ud;4l5{3vFe8+ zJ4b6e?fQ$WoH?yKlMsqQ3eq(zIzx=5AUP$&VV|+pMwHE~t7Bc-za5CgP z=U4IqI~)$j{WmNT!beoooRYyBQe5VjpYq_*O5;SVr{*_{S$lWQN$2(93OcUsaGV{W z>ySD6)=y?7g{|uh9QR;h@(r}9g-KAOVDqRk^-7iU)0=TJlx@^K`Q($4cdBKyeY@f= z1LS!lhXl8fumk8zYF^>H4hc-A8oBcV8EW*lk>D8=CW zzg<=Ch*_d-inO>RzTPXA>NArdmxz8NJMcU7ke)vR*~gS~Ehp5gI<&m}ML{nDM$5WL zPnQ0h@u)F~FFiCpZWEHjA#ZKe4^x_pMXx;{_ajd&<2vGW<9y$(mnL(Cu$4M=yzsHX)K4~zHQ1j29s9TXII zpJNZqR9+##MbnfD;t6MkE1GkF_<>j1f#Y}C)n9$=9c-gV%cW&F{Z8rEY7c_eLc-gd zEGGmkG8=JSG~0)nKhRZaN%GvzMy+St%xJt`@UIU4p@pV+0&6~$lWpafM!u$@mC*;B^Ba7~~sLZp;OT%Q3k9_yve0n4t zx9Em@!)keBLWe{s9{R^4vS^xw?z;}0t=7WO8S!#vJ1wv`dHP>-+PzE?l}_W8E}_kt z#KQ&7-7rn*FnJ#Le{k@c#YN{ za>7h`K6c$)b+cJg(4&nrJRDU4IQ6+hE<|m8Zq5dUpxGW@q&b%=@%FYAD@xDBHWkaw zz&i^D7CE4VtbyVUs-P)BEJxiV@cp@!0c45I=P3oHj+wS78p%LZDQkXFQTET4cX<8* z&go@$ebIex3$1!SqAtL!CT7e3ima8iqhFE+J+$LzIpCvzk%x6K+0 zkN3n*e|(q<5HyB5SL3ycOyRRzw8~OS7xo+o6r|;PxSO;%v*a?mzA0ujD%a%?I0%>7 zLvk)Qn2aNxlAM-KFAAs^X7ABH}j`ug8q_$Q}5QJti>tyqZzQz{OYg3@+0e^M}P*rh&n{(=p)Tg78!$&>kk9Pj*> zx|X(?_CK*Lp&r=9Sz?9SMUw^HguL*T3lnf|H7e;yV?)+gC_93vZZZ9ViOrYD-BE0XHH<&^Qj+x1@DtapxG z!6yvN(X@g#&n)7&%?CEPH3}m$#N6*9cL}XVlBLBm_?#~bwuM@qK8-cDe3lcPjB&s~+Fu-Za)snE_&TFFy^*wNhu(U0%j^<@jj3GID`&X)oFw z(AzK7q)fZydwKocwGh|ngYOsjhXg%$tz6u-K)%$7?9@T+QKMG(9n&e~4|r4(fq+3v z{f+Fut+d^hWkn-TQHKNQ?O;V7*YDKojYj`f_s%=J9|F{B&0QIWsBO z&`>h;9YsiO;zz?LD<7n~V3IcyZy?kwH~@lng@UQq5lEnYf$b$(olMT_+=oTq0L;4E zXWN4ccQC%?nF1f5fB=6>G3lxN^ActOe!k{XCm+L;?ZJEU>0BwD&F{nWD~Dnhd-s5z z&7(9A?`MD5>49^@%|IPhIpFMORjId_vByNR?j@7y?_Xx~YNnqVgi2C@5 z)nqJBmqL-=%%%O%tj46e$}X9!zv(sMOV26UBp)$r>k|*|+Wbb#pJY2oTOC&9el(>R z+6^n3kjqe^3IN%jt`2J81&_=^Ot#8hdF!)Mt2X=2aeNE?Tg?8=m+`hG^Ex0C`X`wb z(?u*hy2c5aBGcdbU(Z%a$wgC$_-*`J%?*tq6E*GXGhvX!rsgy4>%$M~XW?;qNiJ&3 zLe6Kp(}xc>zCK^XN(QCX}C5b(`#r|XwI@tQ9#q=Q+iq&dZ?X@(YPuZ&5>V02K zou+j(SGVtxox;N{#aG<>hv zXr1!fgq+Zl2H%#3z?6n;$WVHK>}i5+4GHGLNPS}B(O_>|ZI`@=!|Dp2ee}~y57ULB z^8RH|X+&y|*YYQc&BE+#kNkP6zGb~TKf8|GLg?A@GOd+r%14QAy{3I!G^qNCQ?MhG zqDs1yME5uWcvd(JQZ?aRU=jHrCHM&&B+u6(HGAG=#xT1etB$?Vo;OC3VC(yBAlq2C z`y=i4e&NLh&IOpQ+^O8jzTC_KG>lVm?R;?&h2uWVwMVfR;qOzo=RZBBiZQLD0||Nz zNgAml1vEAxmr0p|*XDG#KfYJl zMAu@d#%7%bK9nx#j4Wl%@)x?O9xWKo5MGbqvK&bhuv@*GQy>}9ZQXFi1R0g-^eAcCsGR|R15A88-~*<&r{FBq^5d1YYRC;(_0Ae z-R^lvy+z;tvt;@Cn;M@Ma$~qyJJqF6esyGC&|_ZMO^yImp1vMZ^5v`?GpX$gq$3-; zlT=7>3Ez_S;84xRqJSmyPW5_q^`Yf<*RJc~j%)e$MzDpsje0fL;;0~G)Q`U3?@c>G zbQwX{(+C;Q4S&utK!4i#bT_!?ee4X{jKx;HF=xvM< zZ&)BsOGPB<`7{w2J%kkXHT2_{w>0?R`F&9Ptvw z)9n(Xr6T8ErqdHmA0A09V0srEO9cMJEMYN{zU#WJTv&>*e%Q~C&{5T`wa*;OK|Ji`uaq0r?Jf{6 z)RoTFuBY8M3B1Mx^|HXJ0Pgo7q%(W*=gXzt!R&27jJ&7!EwRWujMkUN%Xxbcb2Esyxaj;T*ML#N|t<-!D(JvrLt zkceDCXQD%$MH;&xdc?BvBqdeP{ukHcr9|u6-eiaRs6+)+^Iz=Vkf}utn~*Sd`Fds| z2&9+$$m4-|<_sVUV=$}7DWyJNd@2h zx)uF3suh&ah?TK!g;ZE5Ta~C!3&s36Rwk)!&i8#9D9;6`n*>5&Yo8VEze_-W;vU^$ zCtBu%3IkM;ja?IKS@0gk&mp#h7b9pQIu5^g1@$dM{*B-Syl)xsKkZEoo{-Q3D7gK? zYScgMQZ>dOQC0T0EG{PxLGrYh@zuKRQG9R0o`k1{bqj4XB3hf#&5^K`jDD2NS<7^vxM(te`8jhFjnKLlA=phl( z3SouA5%SaZj!~HM>edIC?gBON)t$o z__vd}l1fy!(I*HktMBLUSn93!pV!pIyhlpt?a8$xB^uK|&rjVn25-(U4D!ur+c=i4 zm6wCV0(`#zXRPz=#-mXCIPC(&=0t=L0PQnsQeH!M#{tC@ z0E>kfG8J3CIEH+KOxcsYZ3zTW{-q*5&9-K1bJmdFc}*7h`zgNaR5#wV-wi1_ina$_ zgQnqtFyCKKpBAOAS}iC(r(%3S^9k4dHTc72kQ;2^Zz1o^&TOL#_?C*qb~#N;?Pf1R zmM*{YsigZIl~L()8UuS6Y~uR#s30^wtc{~VV=+n0*uJ4ztsPiVP}S+&gz@a?FE)sn z2&|J^2ng3+YiLSKu6a2WxAl~RH6mk|FO^wSMc&*S1xp14>m^Kde9HvsocBbiO>i0TE_NIgm zgXP_mv4lo4#GM~Hs;WtGvqU|Qhvx2kNB2ejEJw%VeetpueRsMJJLNp7g&H77tsKHv zj*C^d3l73FP78JZXIt)j$O^-oX+(ZW61!15U|a7t0(jJ!37q}Oxsfd*Owgp2Vycis zM?%p$vl6**He*R7xqu5;K^0#QJdZ{kipTsMjYvd?-T4(rIYB*C?TfQ45#ZuWS9;H& zBTsk!RoYq^AMh=nF!ZXB$>>^4yycgbu~tsuHhTu3C5XJenKeN9NAGrTDq*p7I!vi; zztrr*j0UA90`t=>Hk}+l3^U_8i2K@Kg=hf6+D(+qbQxPJe6O9mzGKZ%ZZRK5;L6B% zVu@;5NRgc3vQqtRCBTFQq&iA4uEeHn_YHWOlYT(Fw1|5qY%nx5I&6B1I6LJzJFCY0 zEa9P+T~*ViXSl_}HOR&_%*DmV!ZpfO)W=aa#8$ys3}@x6VC5|11QmhUD?sd=gQIM0 zH3`ssD7a@!^9w%_h6NFIb5a}keBF8CK{aLc38_Pn`mLs=BB_(1WEg*v>f zZG@&zC4Ll?YybY4U4~`ECht*8$A1SJP^EkTM3gl^%{F1T&wIYNGuY-)YS6u!J4j7s zDwtgXx$cc9*a{6TXzW1;8t)U(MO4&(fbVSbt74IvX=d=f5HpYXM>{J!?Bb{aW8~A) zrQE)bTRf0G0RdF4KLzq$i`Jkut{NAQ|EO8p5D3r$qBr@;@~c*fGuAN*kArsSu>Qje zcYm*ki6`}vu)XgAoM-)OFz#=>*UsqtnKVY<4FT;+V)*=}1ml#RF9V;0i!~F(`uPX> z=81(?)`!dtJuXCv{W%T8c|)uHCJsp*kXiJg2Z3p=h)X}J{qWD@G?S5DOO5cHF^F6s z^0$)PnT{vzl;X1El1pzPa}i?SPiH!}|Js+L&~C->zA%WdkZ(RdeT%G@TCFH1{gu$y ze~SYa@0Ee2=y1WNTT zrsFJ1NVxP{RbPUzNc=h70Kq)@kD!0F?@M|CV8qdYlk_>A-rI;W>K|5r}CbVd0VnJMHgfhYuCNc&t#QK#dl6 zLgt{A?r8&m7)B^jCOR$D*lr^$F88lP0|!=H(xMMnfB+EO85qGp1`OsRcn-7`hhOlr zVFa}9+;k{F{pFa?yfUKrb1O)`nOitEN-Y~^>WogDZIG|&SP;*^EG|&^PM~QvRyRFO zQ~Hz#>x>ukY%&6qCn7>|(*jwHDW~b}sG0IdO*hs^S65X-^Bh)iZKAZBt5;B0&`?m2 zm(!S+hsc8$H^2~Z7~)+;&L8#EEDaqmu8NJ+-y4cP?a?o8=dBm+`kGbBH$&!&5ww2d zn{i6n?&s6vv12{7*f*LIA!tGw2cW|mNaxkt7f?e$2V|5gJ`mN{gYt zGpFKkHLmC9z7@N=_2#Ve=+URWk%12>z&}}ZqS-m9t#ncPjKfXU*8Y0k!T@~YnPtEQ zOS`rG*&RcRpqDxlhrd8XbE5~$x7ninYh%VNog%p^#UAb%(`*ljBbZ8M@?)K}O_&Eb z-0kDm8YgH277}!b{s?`oUattOzL}WeP09sZ+*WJm=i|Lt28Z6Ax)eN||dAp6H7of!28;+5Vjtx>*Lj^+{;+b9R& z*5$^F5}cKCowMmRC>trKLET4e5JyW}x@H9(rQPHYd^nonrP= zX#((<^)TbHzy3Kfq3_l-f#0S5atB_Em5jAXE!MylX?Wd17R1Ply>)Uzv3sw9eU*Zd z8@KuG>cRj)&z?IjF~ceUccN)&UUSGOHb7#Tsnm;SFYKL+MqIU*1ei?JcYfR9OPEGr z0^l+0XAXn5^q^fp$XSs7HjPejbSSilXqGHdlcnmzM7WM87b?kt)vdo#|*)5IF8(`vnq~S0mufY&6TS1NP81NU+i=NV2^-d!2w{c$RhA4)kpn< z)r*HQi5*PNqEQ$NcW!{C*ukW(Q2ar+*N48v*3q^={{6jpT4cDpK%uR-*)vexn3S`m{&|ebmRl)I_#}=^he*PHEL; zAkfUT*G3&O=SK3o)$awor4$95uI!DQd)anuzB@0#8F z%&i@l%gHDcBGW6~wSpVnLXA&JP6H9$7#SX0m~pEwx2-VPDmQHX%C~k0Jw7At90?7{ zzcN6fRmJq8F089>BL?qyXI&^3i zA>c2{mXX;#TqgKNV8QHw6aZ2g(Jsq72do80cc7-Op;Y5`C8#6DBb{l zQaEU!w8&LF3D1{}9E+ zaT5bM%@c9qr-2g)Kc#SsCF=E3w$PL8Q0MH>s_cUC>@9q{Ejpn(WZ_;T=Uuw{OVEF+ z;$@=1aE#TVr=a4iRKHo7x>OQP$bC)E6Fz;fUx0=e$$u~34!t|__c>{9{T-dfS6}qA zOwVW(#l**_p=e`YY-jEDa5fmKdONv`Z*N@I_9X_rg$m44RW<$O<$?7jq<^GR6YjT@ zGt#)DY~nJ_6@V0|v^T0^|d``ZW)%UdgXX=G2D z?t){!eM?5&BXWuI^Q)pO$9%>w3d)Ts$zFo2tMe@ld#uh$1i1{R>*Y+#<1#C_44#fJ zamAEoEKljnDnSv|VEPm-d1? zmuNNJ>zkyrZNG=j!vBtaf{Iu+j6PB=<%0!i57pfRuWUch?Lm+bXYY~jMKHb~_K(Dr z!Foywn;-y_eOATCp;~nM`?pon8o%SGohtqhK(?V^0{CB+jn;v3FWxSg3LY-5&tNYTAV{0_}*@sjI8Y*~JZ7L5HLUenrC= zSlyJ9qc#ybKR9>_B0~JsL| z!|(=ZrxL|{bS{r2Y#bc8?{9x$Rz{m_#@MmZ%z#D8IST`5t4PQ^O5~;)`G~&DyJAIP z@jCpb>n~_=-ZH4#%Bb3M;NFVzWVdr9+mWdP5t{h z_OBhDylG5}DuY)u+6%MCp2RKQ?2wxCe)9t-f1zrwG^Ir#6>ogO1vbL1YDZIAje}EL z-WuC-rL}bkFEto{wY|1>Ui4F!b$kw-q!g9SYybD+UU;{^UW3pn|LtOPz?H1a-9_GP z$y+*b69qK`l&GO(qGsP-}x|u3P>$yZfV75G0SRMUNujk||Wm>H;8)sss!c;z&wym;DC( z1i~M`)UQq6O2$+?HQ%Xx;N*p<{Uc*BC#FkznIbLnIR3X4Xi)RNhcw7i(3QW80_Eka zh19~2JHert5;A?jXGUi5xZl<6681-u$BShOUR0|Tj;8Rgp8n@6j_}i*f$+8B0RlA(3uoYs%Tgf|I%?L@RW~ru(pTuu%c(0! z4-C_A=ne{l0-m22`}A1vpagf9r}OiO)Z(>&;T_=<&1=u>49i(NO`Dwf8B?%dq0$@d z*K6I@Z<=hHa$W0|$19o~zW6CUewH5D__}`mbrAmU?3eN6#|%Ez!JhhK@xt-#kdwlV z(XA>z1TMOLEp6h${SELePw1 zy9$%RU=|icF^3pvQe3P7pcNj2Jm@KtI{Y^ZAnnD;LJ{;IoaUj;dm$=D z>!j0uGJ?P8-!PK!_v=kYFb6z9M(*6e$)c(jyMUeWB{kVRpoZhVl8`aG98Uqy$+VAG znbqvJw$I7eVa83XK({NZ&Is$&-(;gmAaPOOL86e@+bTgrj{Crod@N%i~d;ji~_Di@#w z^A$LlzdyLlSg~KK@>oam?eUlDolPS&*^(M@3m#qk6;xA1&j`2a0ie-h{yhnJ7P~vX zCo=1u{LWWKb7B&(EWl-?=W1O0N$%z-$&_U(onrQpR}pAQhD@?=7qDTKBfRh<%;AskBbg> zBd+BJ#}r`eYUTDquKhrTLt)GzL16gS-Ky&02TE8629#2JI#!^@lU{0vuaVOCXW&3f zu$@hRJ@1!PjUkkI2{+$NdZttVYdvuI+%+LYx3#L~4_M2bldTS#G|T!nA!%D%i$qJa z>W8|4CTNW-5j>;Hv^t%DJl^rsbhqK#JMZ@Keq|$#{nk{~dyD`)|7EXP=i%?u&2)84 zkme>(f4tAlSMa#-24{AwM!h1E2}3v+^s$=uvqy#+?pC!CO;%Xl+h>(a@PRQ z|NGQ=$EKSVY^97fNKR@=Lt#%2uezL0y{kbF8TgZ`1nzl)%Cpvqu%JZ_bri&1O(T#B z;cV<@^|xLg!FT`@{R|EIx>W`cV`Bh@Ltu%<$urv^`aZa-?j8KJ(_djiLn|dfSbTkz zmTPYIFzG_c{VupW0sL<-l`^ZU=Uc%RIzKY!x}G#_f-NjeRc2|5IXRjP^-;+B#oS%P zl~M2EH0%_QIF0tRM3Z{HcIB}g?Q{M^s}2j_hWn-!BgBXSPHCaG)H)mgyO5(#y&Xmn z*UrHnR903Cw!28?*WZ7P;tP%MN$gue5nR1-czz#%bzlB^@l1suHk21Xa7twlzXH!e z0pUh!6u(*gVO9B!ti`WoBQwmuiV!}J5Y7st9}YS{ZgO9*%h4e<>lJSut!SgikHVF; zne%pdHK=txIYQsMDw{fM<#01|^9ql|ISVi|^vBUS_5COWRvm*s&9{7_D``N7HthUp z*f~yKF!57;)zTdp9!gt~)9S|!td|1_kqyO9$2I_x<7S8r^1Xo}&c?D20P0|=DS+2b zZSMXc+F-Bl;Lc|fS-}GT)aYOP3gkNf0$Mc5q@2l<>7+&um^I~D&3bSP;qPpxD)AX~ zv~0+8=}R+sRIx#l`tr;Y-GZ|Do2GISGLdXREBO>8f(h!>kx%&$T6wWs7@5ZJ@N6ZW zM^{HK1P{c!1$j6L2)Cqe=qz#87{P7GJ~)$}%WN0T8!g4Hz))HR0De@!71u!18JWTlD3zi=TqYKbMa zvR|M65Z72h#MahV@{=q-2mSfLg-jIpQ@#9Vs-mez+?|J53|E))eq}+d@mWIov%iO$ zqh%`xz^1*Q<53DFY7t}P73LGX_g6e^>C5vKAUF!?Da>rjaNCgWIMlzIS^1>@l{G_h zuJHy=7X!0#Od7MfN!6E+pS{3?7;eL;H? zy)I1Q2=I>sp>ke8(obfLp)erIc->X}dEZKR{*(TPGlVcBk~v_8j}O^mYj5nTQH3Ph zUIW=jgOFiG<=L&rz2bF?6=YQQ7vrbTS*B#`Uc7CCZQM?mNOXr)U*xtBRqQBp z25Bkmvmo8g3`hhsr>l{@a^tRlCSOsXOWU9H#F93r(MGEgWgg1-Qq&7MOVa|napNEL zR$UwBJ*hl;LE*nNb-sN5sob``f4@0@!6t?>Rmhe1no`09wF{KnR2Yp{!U*t*@OuKo z-%J5^+o0(G-U0u}fhq%_!TR84z3~0H{^j99jnz?)0Xy8U+C5A`Rrj}fcX;=ju@4=5 z=w^*@rrc<?k5IJXsx}u{mBqvq!i=_N=IwWC3gtZS5Ce!2O0N0>mcN3Wj7p z5sj)^4~?P~_gf1dk7vnO+?er8SCp_=i`kV3=gehQwTF*rxP{M_Apyn#1mMc{ykZQ6flznwar2oIw9`c5U7_M0o5o&1_NO z(ai2ttszzt4TNr%eP3%5G&4CfH90fxS2|UI0G*J~!${fnPUUQJb?msDV-IyhX63H> zeB@XF9YRO z{&OD#G{TKemWNO$*R=$OgE+EmsXY<89=Uf2-F&z%%^c3vUzzGF0QpZZ9)}MrclRKx zL%;Wsj#3&Xx8B~Sbs=JEN}DQTkQ=$&tTi6}x#z9R)QY29s=OdR5^>aEE#Qhgn|-%H zE_IOGHusT8-m9ibr$P`J5X}K(u}rJ|wevce8MHbf7KivS#5hv+&0W);VL#Zmv?bz{ zC5mBs)#3(*17Qd@2sTO6IN6d3V!am0`0ZSijHBm#NPoS-Kd<+Gz1yq&Iyj~}c{sZd zsVca4Mk!oPVmhQS*7>||SS{96yom&mLJZ5<_$^l@#Yp6 zGhPtcoY!Drd^T+ge+_<#2K9BG`D8i-y(R;ikYA|2W8wM^dZ%NFO~lqb;|25wOktIj zzGK9l2CSSh>$*03m@5ZlGoe8tMBvNH|44%z*eQ;9vj6lzei@b+=iObzP4x|o$L=yc z#-IldSag9gok)*YFHnB(ybDP86LDj)Al^?MO5^&-c^EEMj@Djo2cx0|)^ni|-1m ztnRNsenVgRBj1(M&PXB46poMa4b0ou@$Ux%F|$OwS`7j7-+Z{-##6R81oC2VR&P}U zu!g3NMv96`E+Roa9GXBpH*h+<>0N;MV)j$Y$bOKM$BH<4@+`6l*DSpIqb?(>FdH|+ znWK;1m-+jhDc|}d=H*Y}Z8W65>&in>J8-rcdMBzDiWUxfpULi7ne12VQ#Q0V?6fvR z-`RiZ_|?`0RAyi9|6zN@T)xdnWdEa(7Y-yCG#xPj-s1nQ(iM1Zm6VVTT`iLfhZ0)K z*g;vH7dzPc>YxFC{~l(-I#UQ?nr8i~rkQBdP$QR`Mbn`-#9*Y;NV_-Ta#|1KRwt&% zrq9)|^!x&0hy(gAA&Xp!y5Ed`0=1C*iX0y*22ibLd&lxZ^WH$rwkE)zIecy;Fg@!o^UqS-FSChh`a8+ zJAeMjdmTIK*Y=bo_Bwu@UT;RcKJdE+_$kM7)XpY;u~lKs=S@uDvGe&USk{-IW#2y} zu*8K};&`{>ZqB%2#k{%wRuJ3s%td`)x4)pYscy*J4rx%_O)yhRj}EPQi@a4JEBFuo z-_ur5T@5P&PVqmPX=ucV3CM)bN)m``vYzS%7O3vV1BAm0WPfL*GpwFgq{031VHhAB z7F&E7(4Y=}5d#8yTBUj|=9`$?1Ls(z5bl8XFF?V5SOn_pdvA6$Xw|x|YxuWkLBFW< zf-pz<;5EY_&YQe$V;~iL`z8@apYDkRl5`FKNcMTOzN8X1Q6OmX@5K!4bk1thay0Yu zW)awCqx(K9s?Z1!sI|&iJj@tzEFNDFV1j^4!2RDpnH`=kxmnaNB^saf2;(`9akDmDL=zUZHWg`m_SGK(Z$hlW@DbBh@&g55SJcly*Z%@ri4h@^C{NOf`<&Eu2Y zxa%yQ9yjPVcEzUFNYGh68Y{d3a=-*ldAuPZp-My6cw>4!A;NBn^Z9DHFQ7F4ORJr+ z0-WtgUjefvBuUZUnHbq)dNMQXSBq@VE(qG4o^DfxHl56|@#Ch~Vji!z>vm#_|I~Et zj7Z`#$^$>1b|2(?9Ld?hcF{pYXa`}gRL}L%a#J(7Z$VfYL&P6tY@#m-+srW!*b>!s z)~DFAKZm$y^NQU-ycLVo_l{>aCrK&W%)`55j36oMX0#?0O4r5>IG|D^CaZ~^i1l+p zta3dj1Ab-fY8C9sqo0Fw)K)^T(}?38jHR&lce^js3G`)SMavcw4Q&TmY6_BgY-Er% z1RC-AwlNC%6a-|o0opRzXw#eTh+tlL zjWp2=tiv^09Dn5HHX{dU%>y-4E7;9f;>H12%mIiAKyHSFAsU8(>(csQ`;-=(&afK- zbJ_oc)R4Qj*wyd-59ZbGfEsGYJ6DcIIlo&|pFKwWktE&X}DzY`PlZQxNBIG#3h>iALX@j7oSOipt}6n^($ zGVK{tOd}3A4br615`~xL{prLY9$?N_p-Fso&9|>WgCv^KA_Z$t5cZW78S4TALgEC0 znRj*-yNXdV0DmSK{Ox0}v)s_W#SQ4WZCb436gmU5(N^9{$nt<_Xgu$r6rGRn`O)6Pyf1qkB+we z#@<$=%-bA(V6N;iOgma+YcQ(!LMQ$sVcLDk_k0o|zG*eBo04+6#JT z_4rqWF|DB=K$UQKQN>XYeoi+0Xbyl-XS9=qH@lOb@_OXK;w(^uiThE?>@c_Xrs}B) zzY(fgtzqV$)fXNed74P<`l^t6LA!5A3K)MhCV!~Kydtu+@-~#WKXmd{BCM;_R8Fsm zp+!WXgk7Netw8Qjtgh)Gdv0l5nue^^jwy|SB3o5H$*e~B@MlJ3!WX&5&cryoAN7>$ z2Z>RTBoywe#I7jwlc}JN&I|L2bq@&_a*hvTgCB zK`R#obR!0a*;4l;BjlyW7)_Gpe^meXCYS{60%+*h%b^F2LV&)jx}B}ckc(DTokIjX z7PR;Z94@vPS_29LMCLH6)_l5HW>A}5r_pa2ko>UgoZmE~0STqRI+3h2ueAglLMA}% z<4lm2*|0;3@h8=LShe4St_T z@Ud+Lv{o>ZugG}(D_f1IJ<$56JEc&SRt4mfTdrRefE*Ut-3B7j-Hy7r@ zb$NQAoD(SF$>oK5WMT#DFZH84=s(2GLXWLY4TYbOWZ>U{N_D;vp zYMrpkP=I&ta_85=_(Je(F>Q;lVd^RRo7f;^IK8Vyc9F>FN0o*jv(@1oDc(rMFu^4g z#cE3RQo;Vjp|@r}fnppW;R_Gc{Q|&W6~2{Sj%jliznov%p{!EVHUlH5xp&0iN7aRzP);&ruvipuZz5Rt0Xaz z#uITt;ai7VM!5Ab;Kd(9-z4PczyY8HG$?Z$J=}|mQM|4r2}g^&YI^m$Thnjaga4N3 zmV0JxP*>K-?L`W$2c{OAeK3vVVD3-NFz#=(8;KOK z=pyGaz1Zb0YiR>ofO;HIaNxkwY=mE~{A@N1zI==v&xa6zv)cXx^N8DlK=5o;ynic!O|6wyHp2g_Ro;Dl>Fs-)gC9|nrHnJ z#73NksGANM*X_})`|hNyp_D=O@Jjak^OX3)k>n2+sNl~U3+y?P*9kxr=BqL)NqM>i z&g;l=rmP?R(rdXABl$EL-RKvTBJh=8?d&B0wfI0h5($Ixm{uB}!?Vo*2ZONX^>q~k zQzR{4O4;Qn35)ZZA~P#xN^!fME^=Nbzq^x-xh$ny(~)y7{T?{G?(v+9mFmqYU^zoV zYIhc#fP!oiIncgD3CNuM+#X4+RGm2$0@xMVa6l)Lsz00J;=V?qL2t)<6cg2d4FB4H zm;YhN=0=NulXUua5G%4&TQj>Vr%RGGAF*g?kfN`jq4Isz#9=iTLnelXNF&_(6)|yO zVGJn1Ye*pN<;_NuUPl()v^c~_YG(*frQ|w5mp9@Y0oGEYwzB6ZpyNwEjLInN&A|Yo zxc5*p68RkxqhV-JNmuXhKc5ln?=uP%_QiH1$Lq~Es$g-T7sRewadh{N$`p2ZM9igx z%Qc?46w*RM$-mnuHs*lbOpMxR;NjtVaNYZQ zJ>#77e4bOLF@}~OD|2l?o&9eCDKfDB#d1G!e249tIBErK6V8?1u!i zIYbj$nv?f6E;9lKs_lGP_NQ}^5YW5cuJz>?eihBL| z@ZTB3w`PuO3<0B#Z`1Kvh;uiluzW*GU2LrHgpxDc3I#(`Yp2|BJ>3+Oxr?SfyXMmk zt%cGSYKI*^SVBIr{3M-&%RZ5v%7jBC^9@oQl#2z!LI6YxkVjjS>L|8PQbOPToLY?? zQf}pbPHLg-FS}&JfH;xclXJ$A+01%{AOIvKxZpCJNS+)qU@l4HSmKjw69E+O<;orv zwDIsi_1&GbK1jyqcF3Cx!Ijx&Zs7pAbp+O#6<1f939w)2 zIXEzKpa4u>E;fFcIXX1cfryN>2As-lei20Mgd~8t!TP;pQ;&(QmB1=M?E@PSc=y^# z3&L+Ltn_;^{+a{v@7-_EyjV;Fuu;l_58`o_NC<@k>p)kAA{%VCpX5G5 z-S~OC7Cq91eNk{(>puiTxLIS;ct(!$XPoh^vMMbr5IxV!qb>9$paxbNN z?Fy*@8x~Ey5}5X(F9d9;q^PLabEfBB3u!^}r#SyeagEoQ=+4bw*vYvK6-sRlkbz$- zve*?O)dtLp{!qbd?@HAlMeoi8Z|zTI&Yt_vTTl7T@y^zybV3%q>{0v?JLOQT65e-# z4X{0lVEf@O=0A?!7cz)RurjS!s&c;hMz+Xq@{MHiXme2qM6=6sb)fC5_sPk}qVpck zNE8}Fp&#S8OQIhJ7@loq>+G22YZbASGlLBKb5smj5^7`S3sPWyCH@eVZ}8rqQ+046 zVp1{>&YRg6ADQW(m1a;AZ2VT5fHlh<5fIqoC}gOm?jxWhWm?3eSps0txuZ)l&+m(! z8`<1|I=UKOYk!Fb0;{F_*F<5!M9#MYuWreK9oQpVfLi4CbefV*GaoMC{pTiH=cmS( z1LMP*eHjIMz}GQK#)eOUWM!g}BOD+-w3s5XIBb^Y2{#7fbA*FE?h4WbY=*w<0_z*= z{2lZMj(@43?Be3yeSfsWit$0Dt5R!izFP8Y z08X3^bZE4(>;>r+>s%Xq_a(Z(_PfYssdZ{yS{Q_KlX59DvvkQhDR7#q?V{=*iz?eR%!kKr@EG%l~ix93XfCnG@$3Jcwk6M|*$XT(>O8~gjqy_@09Tdl{m9}P&uS>WT1m3@1uACO{6Mz;E-5$3f$ zEUBy)@aee&ijCGR@dyGUc2`zQY8;#=s^3F7U_9;Ine4IS=_70F8O*=n)S_>mF9=O+ z*Ees|S8m|<^<+@vptQM~{=HOR&_0`{{WWjn&%N3_dk*jD#}{C(a)T-MJc#fwp&}9k z?ZxLXUm);^1vQKzJ@Q_coows^;?a&~&S?IObt7P__h8bZkVjM1u9L|m1kyr9=+5#G zGJycz*4*OP2;l()u(DZaT-nss-6ntHqX<&864pEYjPk)kPlWM zaGhl4Pe5h~7qGcQ9|EVcm)9TOZD#5A!5M#&GWY@~?q-fVpPA`f8pE>)f{cpZ|B563 z#VryBggS+k{cq;k9DwQl1cdF_yvzNm(!6s+pt|)2HvGr1DpZFfykijY`|42NmR{~1o-jeJP72IEifw!;KFLEz*6hHKm?1W@$GFSAQL@- zu+ygjo(t@G6aKYVly;hYl8BR+-8qK+uMt>=;UOUEVgLgHSKDr7Pq_hg*O$7KI7)2b zRkxjW!UpU2!(H$7=12D#=BjGpuD6UvS8Q?4cT;-+L=1=b=1TZ&9n|0ef_bU8&eK0i z=Ch>45@`Q)UQeXwVtQ6c`Zyj%pVVvk+jaKe5N8FQB86K`-df=g675fN^2fk-?uGgHbYrS?&+^Roqtrad zZf7z7c{zKSc4|Tg;C7d9u0JZyMR6y~69q zcq2V!hI}m12=yX|zp0KUn^*R!>zXZ?&O&qlDV=Nc}vnAh}5{Qhq*u4q{nHi*+xrra0z|yO*XV}j%Tm@52vquZ{^Sy z!`;z6a)#Yc+UmwouN&`SkO6n*-jFlZ9$0v!Q5}!>dVGB z_8MV4r(im>()_KHj8Go7HEglX8}YRePE;T^EZU9Vm|{>;Q0Xkh%9;_u-xbs6j)8X3bfrABh^2 zuU(@EpvD6TAdvq_s}U?txc_{I4g%--0Yn&A&UG}Ph2XwFD~E9*9B=+CrhdAN)> z4d7L`MBgS~_=b}K%5XK`Gwngc03S;-sHF*+mWoRA*K~KCJSt9iByg6hf5s4%J3!WS zK-6&AwYA+1ZPQEuU@+V)DQS>OdBYK29d$OGn5I6TNyhcxGs6Oh72pL%_I8uTaFUWl zvXi&GDFe`1Bi))hE#Sd^KL|&CjRm-cl>xCt9s*Jju!E?O&4CZO5XQJAz&_WKDU7_| z?=@YP_-7V=l#J&J2VVWXox0b^6L(*~c`Q`&q?KwvKS_S6gzvc0-jh%h7f1u)M4EsPtv-lGLQvZLgy}@Aa{|!={I@ELy7XxI;lW$3WB1gI z(-Mqk$VMI`bR5`QX6mF>^FZB27L(E$A94sdaP+CQ)00_xn`(H*!}WbXRt%`bIApuR+g-fVv_Co2LU^YL#57S??)XLrT(E ziiy!~`LZHc==dl>o!_=kOu*97u%_isK8B!QLI9r}gOz}V#vwqS0o>deL>W1*TwNb@YxeblLJSvTaqfSL z^ScfeSf5LH=MQMkkFQyt!+sxsO^cP;j4a_o@U1pArk`vZs_rr)ux=`}<-B@*l0}rN zp#j$hf9{6?B}V&e1f;_92h_FBurP+Z99U_KKLmMQx<=A+_zAmeROcw-}Ik>fjgQ^ztL^&R?K8{l?B!`OnbsFC{0IaK+`fYB56Aeeb3UM>0>n4Y2`D zfRcvnSB`w?yc}l@9=>8&_A%+ZBT}*X?i4g7pBkp`;zET>$vWw30>ft}Un=!BECvYCVK1aW@c{L213e&v{$tM{08DfcL^%{w zL{O(lZAf%0DUFA&5~5NaI9CWFAD;G)kmNWmLe@v3)&OA61|SLiHmIOWV|NN1Ky~{1 zqv-xMpmb0pg7&2WEE+=*u7jAO0`pHI{v72-p#56HfUWs;@$w_$C?!S~as)n&FlWF8 ztoE7>1z7{`cA<@264dIeRM2#uoDSDr^74#vfT6-IE`is6oGLR}oIP4>090x>sNe_X zyU}9q5c~+<*(!m#u$^Xc@$E$ECT4w776jp<3e9w z(Hnpn8|66ER;9!sjUhvep_GjU(P} zz4AC1L{gF5|72C#M+F=e9dFaE&>f#hF-U~N$mk3EJ2fFXen)kskim&a6J!`rQ z^Z5a{Fx9u6ReHxiGVM%8<2jIPO|9hd@g5{s224;L;w~rKmy4nGMJ=8ArANQ&?gWe3 zHe`3VZ2x!#0G4o%RANTLsaBz{cdfRDB|q3I5Sa8~R*+AwY_Y$}y452rr~A>>A_7Y7 zZOf@Fy6$*eYMFceu=@hee^WDPuk9KU+_P0wnzY#KgmRwpXwU5k%6F^lV0f$5qyIh2{|mb@N}pJZS`!0rFH0mVWFyC$m@Nl zVLsUBSwt_s&aZ!=s%nXW3)*c5tWA^^Bxrj>EU-yM{$2OhzRcs z@HRe1uGr_tk>IJ4>#yQ1_kGdJ1Br*?exDb6o@HMjuh#pHN948{Ko(j(4kMiP1AxWz zqrIe@Tf8Qc&ZjB7H-=qXQFQ?g6@>yHz!)$9`WLf9rU0E`VPp&kP?aWCeoMPr#KhlP z)V(RD0FJ3~XiVKIIY44zgHshXGlAB02>|$QM{^!_*>t4t-T&l_hb3Ro@dA!V7^D_M zydbcH{wQ!!0uI;T?q_`5E_wLV<%?YFpiZP)^4Vgi21Io}d0XPyXPjwnCh4ZbdcM{& zo9i9T{;d8=(mL~CQuFKlGpK&!{GjKLGf<^FC%UWtaP zw~yGs&_zj_y(FSqS0}8~l-q!7mmX1l4*f#cAp2A+C?#H$rmb+pa9N?Sxm&2+sTKbA zNnt$fD?T0cK)0wd#!}5Y-_73#1nzHH07?crD9ul3_G2f@JuMFS0}0d;7=hFJ5S`57 z<;*%@vK>0a!jD*LE>1;S}nU-X7Ek@L`q4iuM&xok~8rm|h-2zTD^OqwX+ zAO8vbybZ|G*%1c|*N0;g`(B5}O&-^C*-eh~?VH)oLIHJ7KeC(~{qGkZaSF%!E_L_s zcSauF#plkV733nFva8YyVte$h&McIO9O%1}GKm96Ec`P+_bR*(#ekvd>Ya`ZzXA08 zk?%9$mZi5~z=cL&CkU_hZ*2ldcLuN;iB8LD!GEc|P?kH3@+N6f{gR(01LO?b0t@r;y8MMBWiNw0kgkb! z7zpk>6St-BkI1Ulh~fzHdd>y3OWA2(-Emv6ZK2Ds4;@K<3qS-eF4%rkrcb7$Bx#Y*_CT(Q?VqCA^h)b;bD7w~5z8=e=wfhyt zHamju>lq@Km*k+#?EUi_nwtO)QF^_l+Pi4((6m2-!!(AQEzFAxuDwEEp=4BLF>zsI z)lt3Lst)cJyWzZm8v;{NOD2hi0ffJfj1*XNUZ<)Csj*{wSC_M~8_h4FH?FCz?jgyK z_{O|sF1Kf8sHYOu>32O=bc2YkNnZ1EicmC}Q!trRG%bSv7EmuLQYi9R2kwELAwT$h@mX)i}NW zgm>nKgXe+?iB6DKWoVU%uq~Ef5+C};%Rx^BRdrS!IwR3ln3q=wD9L2MRR~gj`-Hs9 zKT;Hao%$o!rE`nb_wNPwC<+31FI`E0M&}D^gFxa@)a7RGi_MkvyJrZR3JDR{k|g-q znMeo-GNjK-7vPZwcm1cukQ_P!d+G)&O5QFujm!L`Q|Td2=n6v^#~nu;^FR=le~ysLt%s{X=OMdj)vI;^6%H}2uV9R-%{ zUAiEYDp*G>SWEO5^yXYxzM0r-d&Ud3d}{ z`=`g{1-8K3)0xEM)kiFYvpuAyl;z~B3iT6W*I_Z?0;%@Ez>|z)r!gAS?=yK3nHA4} z8q7X&Wj-T{MDeH94j!w%81BpRBV{ z1h))q46G_AOd&fJ_U`rXE-xcm9vUlGd@s#kCtOdDT+NK)HH^kx@~GpzFS&QG3sr&( zoVmdTfEV9i{jDLhGzQ1!vq;DCIJj&rDCU8bFRo|cOb}jM)Gzd82r<+FjG1Xj9~qx@ zYXpHZi_x;E_GsuFrLe)*_P(P3Og?H0Hib<1k78F5cLG$fdVbK`keD`{ z5YVm`e~9Y5M-_sPj`3(;G(^?j+DWmZCRCM$@cNPvL3574b$^X}`KqThlVUGO5CdK- zyo?Vo$if6R$l!s+sE6CK%P@_yl*IlF_;StzqcL}DHLrXMmHJV@1GPE?~|T;pn6j@gtv6VBgY| zQDA-F*v(;sHz(Ee-G7y(=IYh$oi@VHqR+L9iqt={YUKte%QxCzW*Xg8>=q+5|gwA6`G)r6bOz{VY5Mlt#&;sl!@sUO0t-Va2iPzb~EG13@EOK zc0FF+dwt<}IwGCG{xajd+Tk7AnbzdY&%4R}Wzp|EzgwH$F_v%=Rp<}}Hf?)}CE17w#(2Y!Kyl@bbJ!Tx75tjI2+pFV^NcYx?g zRV{jm$VE#L-AlzIo#=0BgPZn^makU+w_oYvdu*vxcW&8#_QkS%+o%V8_LY37e)>2J z`0n%7c~>|Dlo^S+nF)EJgJYS>hnW;13s6l0xIjZ;-tx@&OSck~BCcebv(IYbqOv3t zsU|O4EV@Z$pVJL%%?@EgCPz`wDCN3v;j*?f&&p{Wv@}CvVsMd+(il_FYGwZC$Y%(3 z++1{6rx^+?BCG8(<~P;u$6G=KG<{n)jRBFiG{hI88sPMsjTR=20;}d*O)}5xMsX&B zii@K{(Z?#k6rQ0%o9U#(zoq42kfyLh2E@VejaO&;j56VA4VN}Xe~A~%-@G}A&_BSE z7ookjK_~s4E6WJtK+R(zb2aW4M}yv&9?i@5k1P2}1fi2^3S>$$u(GpAGBb%hY{gSB zA)ppZ`aH^}D)mKd;$WA0v1}HTk7tkf9xkF$tl*D8-l!v9j-B#X6GSRP805Md^~seX z-Z!XF$+Bw&?V)X6+yxFZVi_|j93VOH4@I#*-DQyf{78Te9suqVMGhGMFR>IV>@^V- z@9F~Is{utH1-KMlt@Yo{_^$k>c{TUaS)p!)C}6dzP#ko`NW$d<7@>@tZx*(%ncoZ? zo9~K{Qt{{DCMO#*ayt^T6?$nF;P{JhvT-nRJ}TN8`zRXQi#D>qRZmb?j*E-MPgKE* z*8oMww9pc69>1jQQQ@`TRHlh}H#VF;%JL2;n)W3}3JXs7UL1`MvC5iosN9Pobg&e! zm4H9BC+rPy1D;yc;b-?5;g*+zyC8~p@BM$hiff$N8s+UoqQ3nQlycmIU+XfVH5DllkgB~#(?OxZz z0De`V-_sT5fp+2(ak7~dYF&G8H(y+dZ{5|URDlxym_;f3A>~3DrRTFgOH26%%a?B) z`QMs9$rA>f3gf&Rv8##&2(oA}G8y_zl zTW7#U8ZHJLHnv4Nt)aEAvxDGo2`-yVdZ-W?NmR(>_+jOU|3B!Yd8s84R4@G}3N+z2 zlmMD;hgl!%)nCnGgLhZ6)NlZ>{$o$LE=Te87*BfC}ZG8QzqOR zW`+uTVk}P@XEw9>=<#cI3&c_D55fjBm;2G&DXdkOiu2?SSOwkqp^z3E40;lu2Wdyq zkOo&}46e>$OS3RfJd^9{^&gz5N()PhON&~Hn=i;#0rYi4DUL+Ygr*|r?@ADcdj!E| zH1)vqcdU_%b_2(}>X$U$P`-;{EO49rQU)s2GdjgAF9@WQFfDZ*R7fV( zjixyG`|n0um0>~zTqitAD@~7FU*Ga#Zn>?+>*ya1ChQ@ke#s9A^A$N^aa{1U`B#7U zE+~3Si4;Oq-DTfHja{Jb%GQqVE`A8Jutc-!Dy7d{VjGnoRaP-Ff6CVSH3H56>d}r_rwaZ_z(XzCgG1x9i`_-0wW@?>w{H8O#^lsB% zEyD(8*@&@0kCM$+2Mr|d_dnO||k_glz?X2&nwBv@v*k;Vq%Bwii zzr-vMcx$FtbXFEuwA9ph6t$Pj&H9`1`sWL4jGN33L`^&QB7i)r#W$G0XL>ZbV5#^Qr%ULJB9Du8bgo43)rT_nN(c zq>i3%?_bo;{Qg?f=XR!Q}IW~nDDKyf*W|lwA0Gae2a{ zDghmKWvk*~rAw@(l#LFf5($aLnV;voK=h$Uu%L%W_=bnygg?K%#kdvjFM_z0@vIznuxl9w<#I366DU|&5c~oc ze|jW$$Ayl>!CfoFYp|i}q8!A4qSr&EHsD>cfX@4uRC;PN<9SanbJ>B)g_ZmsC@qk8 z608XN@9qA`J1NsHTAwq0nd>V<#`s>E4t^AHRvjo;MC(r<&B5NPbXD&v8Uit{7phP$ zRt|Z>p?R4(o3CNieDnEwwetz+0j}jmLXLy)qvGzarFwokpuoJGYaDxer@Fkoob#$o z1v0>&w9Lwyl7ga`99nxGqid7wt8ZEFborj+I!2vq&)Ad>X>|T8*ujA{Fc0q))z*@! z7%F}I;Gp)w!N^dvt$sUTbOL!d`L#Iu>sHOtpXAD{$)ncN;H`z^tNv)78TC9{CuuP$ zb9C5?nfc=94Mi_kCpD?rZ9d{%nEMBhL`xlMFvx_ckTJ4Es6ht)M1ilIZEffS;v_2KSf>|6~Avq99zb>xdRiDgZLbR4Xq5!44 zp0ZOiJmfo6a#V{Ubyv`8YN`+wnxeyKxgm!F?froyfL784$26t3m2_{)Ck(Lx^-&p0 zQ4-v8q(mqL#6hH`{6H@x4OA4jea#j&{>gr%YpJ(?)kM}b_(NiP3VU75YiOE{m6(dK z!qtQ+c8>kh;OlChzUm41l3xp5#iSbQcNA~Ga~13XoYz8T zr>QC00A+%=S6jhT!1tO&AV;_nngW5@OAP=%O$Hn^D~4vL4#%a0271(FAF&FcZUB3blw7o#=Uo;0N{9X z+U0+}aj);Z`hWtl?zN7Oy|u#_w9?RNod*P!dqz zWC3prs4n?rS?wQiutF3xUx^OgF?SAQ%H z%nUF7`Y}9_xq=1$S!TwJKq;a}Oh^UA@Zas*8HWE$-H3AC-1FA{De;e3`D+h3(Gf|C zwlWeR$#(G6pNFH6?j`evV-tZ4c{FHHNEu}m{>lZZLQp(?nb$iXEE%JLaR+Us&CN!* zv-y}kgM^_!zmM+DLf697iIVjUeVfru)x3fEYYB62}*&aq&EM^ zzo8Xbud0iq!O;Ipz+4X==-^MM7W_3mYo`qSlXE{Tn7HVf1x|dLogoNKB&2KkA}4?*?}=^n`{`KM+0=VgmB9pb!Rlv)JIN%;Gtu)5^JCwxw}|U zx(~cf^g1@}@V?%l=1W`c>Jc!b$d`ZR)#vd<*A;mFWBt4f{o**2b9S`HF%_lYZ%bqM zWQRh32rSp}KAj>Lg`|450u*RA z^P-mWu8;q#^~G+sBd?{98<*u{$gb!`JrwkD_StG$9vZzk$zGv>^qemrt57Gzne=m$ zpNTquXb+Q~JZxRLu`D-Ph|;rEr74peHdR#jHk zna$1FW@uzG0r77$)O)Az+tktdFF?*))6K&lw^ zwZFy1@5Zr`ff*Wr)BT^s6v=5}cnzx5HjZQ?;;cyb^v|CJvljbOe$R=oF zldh)cv?hv{@?qK6gEIW&CH&n-2f{g(n6(h1Vp{wpZQ(s#j?<6=uy*9!r5+(>?gIi-l4cM;U@?Z zeSH!r%<5vU1fBx>c!Ky=tjJm--^YT2xKRqb3y-)#f9`#I*XDcQ>f9&wDC<^CiN{iZSRmk!G}kY&h*4+)SynX&$=s7Kc7jLX(*g@+#u6Nnrb$Q$j$Bz$c?cQYN8KwR+Od}5nsP-gh8JRnV^Zuyi+9&?lU*?8=shm z4tbf*F$PqpH5ppM{1q+20PLEc55EXAcPAHwEL{aHYunkA5O>4r++TGq?iR?(bM z5D%CA0ItJwds7uC$cO!l?dcH!c&k6rXel=`2n{-c73V;Wy56@tvE=xjpKG4Z7PN$5 z1M_b;PbTlIy2ViBDnTEP=cY#K!`ULpIdWQw!|A+7cfj^F> z%ogu$MGXA}($(I3;_xmoPIcnYa(kPXtD4iZa-b)An9f#$%T}xQ#*Z{_(88L-PK4tg zLD6wIG11rwaUj4U4L=qO3;P-e?-Uyk8y9qn{lfG5#l;!!$p(w{ADcOY<(HjNMS>P2 zy_P#Aqm7L-ffAF3{}2Ym7O zD-b4}jVRzO`nfF2!{#CzDe=#}F`F9#cc}#{Y$|8qc45> z9Kvh0>(&(5Cts*^#bcrW<8|CkNU8=OwmF#6AodUxDEM+F5GUVEiV1f;WzQhK^)9~Z z@or=0*N2D}Pj2|{H7xMx>-xDh?Gra8dJwLo&ef`nz*M~&`ogGcizc+i_Mbcap z1CC!tn6uo7E;(9$ql2F$RAAiYsNS85#RWZG6;PX5TgT zr6fkcUYDf7GL}$r?rl8`X)_0mc{x8Mt@f}Sw#Fnx#N3b0NPIjC_H966D_oAZl6d>W zoY!+E{_>5+i~6zn6{D;jEse`(i1#SabQGwfo^GMTUX91-O?pDbwG9d^a$jC|J-#Tk zFr7p30fp7Owgm0vQF)%^gt#vvTxq%!z&I<;YIJNUZ>>p*FJyu^zvx!wEl!Rey|-(6 zH6_hKUH0qJhD$pxmyTS~9q6)(xHR!;jx_^mpb2+VLbw;xmq4xht#l9-`iFY5@VBv1X`kS0hlK<3@0Yq9z3)P`XhnWS@P8|Cylq zNW(EpqT~RQ{3ERW{loKxq(pbB!E}W>rOCquT;DgT1jAo{xc?R45N8b^cmG?%9(I7? zsKk)XYOaOhNJaNX_!Vjf-c8T82vI>V(|EOij%Gsi2G)9LbAq_6@n`Iep-o{|5fXIj zb!@cCLW0tWHD+_+*e{ig>1|&QIc$BS7EE~@&Ah&LW$+CSa4>hXfTuZmdU-iS*;!a` z6lD!O?V&PyekwBdHY(}u1#lVd-=;<@%UmxHD)K1aXJ_x|jpBbtu+BD6U^jLZ1+;hF zU6`LQq<*&>Z!{HOykEclQB`el@;E$xZQ8yBSai_#;D=S;ZlDqk&qb~V+#gg0`hPM8 z%5n|>U%se(P{*B*b?)-Hx+D)`*NkU0*!(+8jt|I?p4MHiZ`ZEs2C^eD_rDZdMm_$P z=mYhG2||YcF#0{cMJ`2ndg@9P>1!mB#;8CT$^%dlarho_xb-hCU*i+pUYNYZ0>4B7 za}(9de)^#$DG>x($VnS1;pH7|GfMH@)*Ji2NU6~Or7$HU!XZsiLax+k$G6xSRgx zGlwPwgFK6cfS_RDA{NN1lMCZA3i2*C5aXW9CjMzKKT&yi4wMo8_g5wQ$JO0)npLY^ z&xHZ;$)V;K?Wc|GCaK4ht>vHkPqb3EH}>%>vIPSA?={tC_yUMN-LpyU2ob4q!=2^N0#xASNxHQ`mbAgDZU$s-c_L8 zfBLHXeoSR1o#?;-KfOLYERLA(QvcR@Cl0D)9*u_wXK{HmS@C@PO(`e=OeWP`_+7-T zuqmZ%N0+#>D7U6)rYYuZ*x5!GwxvAe%F=}bzRoIZHL^kGzGjoQ0W8rEv7mR;Zb)WC z1$$!XkXA?4j3E)UdVX$8;6K2973J2_Q$5=laaIZ%_>Ln<))2?x%?J&AP!RA0r|XQNW|GZR8VFOJ!Y+ z^mMt(!6{H6@h8Jw&#|Dtqso9Eixv@y?gsDfPI7Q0pJH1iYoX@P`G=rMAd$=de#Vm- zj9V=+!j^$VlGHn`$Scou6l{!g@81kbpU35436 z9NDo}>Nnz0a(447)I2*W2@{H9EW>aT1V)o3m3&Li^9uD@Cy{cLuS@6>*(pVy>R>*O2rfeg!#{o*G|AM&Ufxh_Zszh6=>*xz!YDb? z+LsvJ%IZqAsXwiL2PfRz&|uP1KT4A%ejLDH&|CPyl=qgfdQB59hGXcYA+E{4I;XpH z%m6y|h{n58jRGmZjTvkw%*ehcShbQ-#?7NH6%J(I@_Dd zy{k(CU8}MG1^v2^mqeO%iar1gZ6|VT!7NxvI?(%32%gp@{)FQ)tcAfxq@gUyFRH7o z$6)nS8Gd=^04Rj4B>bB&=dRPC7c8urZj(b`b9}W?lTk@x!a^q4&b<2`8Q0E zOv*zlx;ut4h`;<=6=Ut8)$KA9q$M1#ZSdu5US3vq#u+xEkGVvVja8}t zS@{DEBB(O*j;@3x$t)-%FE67lk(^gJBn#@O>wesB93F2|G(7)Fp^OEsLVs*<&Lv04 zP+uU|ODG6Lc*inDga1EPN{Sc2aw@~NGS$7`( z@H?BM{sgct(fQs&KbfUAE&rW@ui0Yo4FImn8C+BPKJ5o zq=P{5Fw3tzl$RY-6o#%sb%^R4PYo3nPaBnlZx^<&gFwlB4{Efq4YP} zVCEvn_ceU{5TJd%cW?uE6S=PNIE!y2+R{wpwC`0UFbvFUo-{sQlmU ztZ=1)N>ki5yi%+Mp@Kup7VH89nW zUXkko+4XfrtsO089i^>BooIM?hF_Z_u#k)eT`^^xIAvfE7UWl}u6rRpFAE!|B$I?F zV5lI%&-I3tU3^JMc%GBKdvrkQU~A;lC(GbI+198-dh2=7I+`5w(0zLPcCrX@4_Y}C zMX4{3VatKm$hvSpGB_GRI98vP@TXmR!VDgCsPq**13Jnx0H+TbFGLLpiqfw^_Y*x> zJKqtPjOvlg3CcZryrH=vJ>4AySL>kA-IX@qENcu(a~N+-QJk=K`+{0tg`z}4f)u-{ z!>||`MQggr&7*G|3Grz&HNtTJ_LZF7LeY}mqw)CoN^{|bKTCR!`04t$7RB~J7}w~{ z6s^^7lz@3WMxGgkZWk}h`__|C@thF<-*ACB{^bqJvqHbJhS?4CCTQ zqdRhH$dt7zHTZP+hf3-JxICQ!Kprd?G6(|sigor2K%EJord@%^$C>>m%|~P;b)svlrooQju&1VTe0k_hd-Rd!pz`>*?6|{haVEccj;C#ob^m>Gy^z|UAYH05 zDu*F^8h&w$o31eiG5+-)bG?OOQTHoIw18KB*bHWyk z#ur2`U;N^x1)}hucgR$~-F8FJ^X+S$L_F6Dzaf&FQbd#b;`f;jFICA8{fL?B8$kl$ z$QR7yMf7cm6d@5|kI3^tJS>B7{9{E>7xk?WiwcV~5&$Dog|28&KVb00A8Fj)jxlD+s9_NHQFg86_i1bjiyKEA)PZmEooX82 zT$aB;8giF;>i+6z8i6l~_rGM>#r0pZwB|6YNpdo^J9k;uq zzy*CgAy%YC${P$juRh*9BJrJ9I{a_1^=(>t9p4F!t(zp~-@63xOXXBNXe8k0`As;> zbm>BpcgHQ-q`$XVZl)P~b=tC6o7+7u02p(Ox{mkt7BX8OaM3t!SSRU>fX70rq2R^su6~7p^+vE!lF8$cL|7fJ07p|7so@LX`-8yAZF72yToUcqK zUu1$BZgc9LDRe#s0!^ubv}0p+hGKMppz^8YReAe`t<5cFA}9#BPXLVjIF{9OKgfoT zcopaQ5)qXtJylMMpB@$OTLb7cNJ~6E*;%;Gd@ebl zPy*B85TQ@qe|h9)F~bMkf|Ml5OQoYcMHW>C32{U;WGm}ZyLGwT-3VLSM7>)@e7&1L z`?h-fwt3%sdt+jJj)-VekdG}~57)xxnN+FdR~f7Mpiz_A+e~9>Ov_do1cXMh6e~YT z7M?^%r@voMiEGsG6ZGmd%zQ(+I+ZG0DSotQV2gj_4468o2=nu_?4)PVr^#SR=wL|D z@JLX1k5_kzjXKB0KEM3oBFxKcr^#Cr896u_f4n}#v^b1~?ud$SfY(R%$&g}CF|6Zs zAxhMkC%nJNMWj(YAvb87{%^hxh6zu%cu*8tne<3HdvgZe#-HA6bOEB^dkI7~cF-v>!Pvs1Q}SMJC(@uQWza(r&JY>AkM2 zsYORQKU;VPC#kr*9IbQi3b=bAs*Bgfa6w8iJp2p|nxt(Cz?GVG79ZEy5@+coUIh*H zBy6GZ?7r-SAx8^ekuD!(*#h`(>-UrD^QeP1Y&vOpsrkUB1wp{2N8WhycP5V4>7nRA}wGll4!cd^4?IrODC1 z)=pI{K4H^IW4NP16Cwj{pMSo&iIqSu`*oOfNaRYiFNp&j7muI4e3psKK+B#DMd66G zsM~E>co?F@)s_7|H5zfJ>&B&oaY&?-5Tnw?_`i#uKM@k`z_a+b!P)(&{bm4P>hXf= zV9jeDjN!1+%X;O;97EIdYh&y5b2eQ7-a_2ya_BX zxo@HZO71NEeO_gKF6*pu5`(F3)yf1)Z}`Il-BUvRYg8@MK+WfCYa2%NO48&g15M5= zj)6$IrJLYTzugM!+c?V?igq|HXBBAYy>E%<1J%d`NJYu`pW_b87!1yATMz5i+SuME zX$E*W+WP3}8TX8a6}12BeMmF9Emct1oxw2NY^vpSw$oo}F?@7`|JHLxAr(*ps0PBK zwo@y}u5s1@qs*rK&Rnrm3Lr|9`?!7^`T`Bwn3m`{lX(3~Gf10uM_I!Ey)+?HUJf85 zNnq?zj>K)BdAwVH)WTFDg5uJW9&%A$Uw#ni$U-4yd0$NT26`TBgzCKfRN8+6=R6goRzq5L+%Im1Dh^{MXP7!F@Xt1(bB zT{7u0Q6s@<4}X7S(DV_h|LitryNEQOCt8#ETip})9A`0kpnf~w-*MPx5t{-}mQ}aU zCdyl#4RzRX@XBS&w@u{sbL;J$k2mI>Z$wygq*AkjLabHl=TpP>Fq!~M9x0Bz4C8J! z@Ly(9{!}$X4zGm|n@iNNhd8iBx_lawTPueb7~$1A!_&MtP)cQ`YWKd@JIgPNC%bsa zjw8m~sK21WayLuX+c3X{eJ;6`e$F)M!`QD^>3u|F<5;#sn&`wOTItPU`BJ=7Ka5P$ z%6W%hpFI8_N9X*IXWPc{Y+?5bFR1$aSvR> zpyO9oC9CFS+gQd~fSdhn!Ajar^A){vA^QrFvJ=PC9Rn+vImsLH6Fd18$y1Lk53tS} zS??F=z$fr+zbqMDO5O9tV=t~C0e)HX066>PBEZ@NM0S|(&s3GPvPkr~p%QNZdg^$Q z{TmK=m_6~&&ry>cr!@qSn6rw>R5l8Oie$BnogFJA=thzU1OdX7rrSZJGr9>ks&O0! z<)D)$UgvCALtp^{XkZT3_W7sn)19+Jv|GoUYaAZ$Fz!hx3B#v``O!PG_`{UiKwQch zybn|MSLczq{M+0eN`Ittb|9Y9RU9iKZUIc#_3Vd82RtD^7i#pno)4O~e*DF&-oHo( z#Av^d$D-^_tgotZT88-5qpw|^UURJv)cM~5-+Jz{1`A8ZhGrm7)uPavt>lGaX6Ggz z{YCzm3klTh%e0UxTc32RmNQuA#Bk(P;r7+)#qc2A@81)g!bq9MRn5?$;kTy}0Xgs#!2(b*I? zBTWIWX7&X>J}-OYV$|V+j+#7ur?T38b6;~&FHsL4Q^VrywkE!#p+%LOtixY45Dk$HU?520)O^Fm{2bze%6S~uJVA-&$7trxuGRC z?s;ndQ94$2oJ_{Bk^KQPXY4zkl7aJitjTC6e^kdxQQhg!?F*Ci7dv2-0C~m4$<^WH z?>I6xA*X4&&f=AwRC5`$dGFP?L`a;1$0$gDv0cj?J07FtVtY|D?xLosJcB&oeF&ei zRG8ICw}UT57za--$33pv5!Tu}0gzumhgnPM|-s7}MkerO>%+hKrv!>)P4k zaq$M>|Dr;Rc11_~d;jQ)i5$WY_wt83cr3(f%4jvf-xR|fMS&S!FxeP}fZ`GjRwGck zJDWd=1PWyXj&oa5gElEP6a#`r@Wo3(LiD79IHX|T*L!1LGuGPC$L9OV%=_q0esr>; zd#6Vyd@NIzosdAkT;dSAI@#qZscjj{v5j-;xbg~(h`5ksAW~}nEkHg)Ym5anjW{R*_ahjE&?fC?RkL_fiCk z_6qqUg~8wo32C7Fxi3MWa~afx(r#_Y?w^D3T9G`Vcn20Pw0k#z?8P#asQKw5vODTw zaU3Zw7C3AgP^41s$NsaHB^~&J9M9$(7u#npC{Jr{Pd9xI0DE#t7Wr%`x(v!H|#9%5S04&{t#7T3}Eum^(kk^cC;ARwrVT9F?Ir zqrt!VeXEDXs%N4qkhjYp6tvN?u9wj)91A5FJqGDdJ{@8=mb>D3;()4zV3a|?jOdT# z5XqKJE?AmM17!-ER=8GEyeJoe8)-aHpbh;PCN3w$R1|iWMHikc-a@x<0DDnUdlN8O zGD4bZZA-+sL9#(!{qjBu3PQ5 zU9-DClYI!=8wjBr7-)Fh-@E?3b$h(WY4Hf_uN3`7lzr8|!{bPzyIv`AWWYcY(gIY9 z2vZm)Wi%9j@whs12L;RSJQdy!CCOdQxhLf3P4}Wm>HpL0{A33o4kt{FBz)k$BneB0 ztoN<|#}D@3+rv46(aNI)kpYEtjtnZPzXI!GDU4H$j{Dq!85W`)U42c6ejv_#eu&Kd z{4F*(rUchG2m04m@IQ4SS|3Cf36d4Re$Gzx7G2TC&uqzt76Jt}yVOr0iqD-8 zo$F6@)kYrlKG=PR??PNG;U+*@<87=5t0xcA- zcIj*c5m=3qn^NlEDu(4ScW4r(8$gmL%{IxkZGoPW3q;P!Lk;5K;QjiQhLVz#kB|Jw z-1u^$|KEDSZg2hC0GCVzn~c4gdXlL|;$}j^fiyi5P=5oieGZUJn)PMNde(}GNk0+4rO320E4vWvL zm@X^6@~``IX$Y?B3oZvnAB3jMdY3t;r%Au*p&5x4hwmp9_sFdQml8nG+^4iAJrCe1?-*l@ha z(29y&_qiB~bYmlYZ;$*D=^FO4W4Hn))xLvj1osK`d6Y_Z6l_x(OyV2Eb3%~u3{p^z zLY8X$`xcTtUdAv>sLmfArX{`7nU#4a5ZRSYt*>RlVr5x8Uy_6(y}y19jiJdpiXy>BkBkEmP0ke4iJw zyd4uCF9~AhEN&3y5?|gfcAv@KH{;#FSkW$jm4DwvMId{~-$fumA_yQz2+(_$;5+GO zCNeI7bj`(N!Qvm$Hawy?j9U|;K#Ue^H-WDoKH33hb<#!~FJUXw=AqttLfJS0InUPe zJHmZ52e!<%S?J{Yez7)ySE}*P7fI>Db0SIW!jgYGy(`}N;e2vc&L+Uaf*Fb2e+o^g z`K5Y3+!b5kb7+W|J>x=lPX=+2S*$FsnCA8;y7lU?@@_Ufu_BIMu|6}x-P`V67DFlm zbv1`qDM>PzH8Jr|qGBUtw}=G|7So|qu(@yWtUi9b^B8_m{E!ZWBS=|rDMr5i%sFfY zWB5&oP~>7NsH`bFN*MOY6uWLwWH){cyI*!h+aSUE{}LBrLI;4!LA)mfIFMDI73Obe z7Jfj$`IT(5{dwnNZAr{`ls<|PYVGOb?8?_i;O5@uDB#W6sUbwnOu8MZip5x%`zEW) z>@7?9z|u$S5Btd&n5px9DE4Y+z1>?LY}DuCb??;i>U%Nfhf4IroZ5i1OrN)+`_Djs zoLGTQ%4KUdw(fnAp$CNnn#}^8;ZsnJqN{dpO&rsTENw_ggUF5a4a-tIR$sgdCU|zv z1QN_zw_EGH>+Fn;eEScKvF^#} zf7KKp6AHqdH%$-&aJEW2CE;30MZKUK!eWjYc&@{}Q{r+ql5%0fq%5Z&u zdy9hR9ImiCWbTB8?8!9>85`G@s0O8twATnkS2q3_+bXj3f7URu?(eI8u-0L zR4HJGRAfLGX%2z~TS{%2IMZP=L%d|C=?F(VUAy92bOdAek&MK%nclxVIQ`0~bl=eQ z1g5A7hiIAWcGBI;okP$r;oF$^T*OGQqz>npNPliPH<)zkmm-b>cI}Do zHt`?mSo1!o=F$2#8OM6_Wt}>b?=Zw^n&XcTPVx~bBMPWY^erpJ&1*|qTzrca`UHAv z^3cT?dZ!jk^K7k!j=+l;yDlI1$8RG$lO;w#__+|}fz!mc~9=2p8AWeavo?ZembnwW)Ua@V-Fupg0j|7bs^Dl=%+OiW|nr_(-{&yUqfo z7ExX`2(a){&|6f_uB3lNQ#Rj!&gmqn!e=i0%Hi-ad^M@C!gR+I%*~YB`uYMk4p5Zi1m%zDqT`eyE&)tbok4Q) z5#3bxqGb5J=uo&K>en<>1|$RJdD~WodBZ!%*zcd7ZdE#X>@25b{H3pnune#m4Hp2( zyqq|sX!hyH@P!G(y&pqgcKH(o=-wRA{M^jKFMGtTUt5hI4wn2rE+5X8?6-X3!)_nn z%79K~$B(;1GW*T1oli?=eelUPE*51;Jt#o@eQn6$Aj6|?tJYUB2a2n@Mb{5-(RLjK#hA?gaf4R z#knpwCrjJo0Xe=Fs3s!WP?H77XharNedAnKGr2F0BRs46(27M`xxke5Jt=a#XY;0a zekJ%DVs#(O8K45r=_8p?{dR@(joR2SF*z3SPPM#mJRSy2Jj9eBe|1r_s?1lv{S*G! zof(ZB@|v!LzUX<}P4ZRY*y7?@cj{6DRe=~MW#ttk!q6UW?r>UpSTa1=NrOfU`K}Sd zmt8Cv*HZf&cbqa{fYJe+1H~a9ucMVFbMoGBpDqqOBZ;%}lic!@18$ALMg~8SLFqLS ztKsU_HY?mPGLwD^spuUgPqJN9@AQz4LH1?N>5hU`-2eap^)O2aEOmL>;*g3on-UKAwbeX83$&D$+J-86ZG%C!mf(mrLRpJ#r$$|5)=IFVF3=`h$@Q2yrj z=gKzGy^*4N9jsYHW-9>?Sui0&F&D)_O{r61@24mb!miRB0VLKF)DRB}YlKp9Ps z989oO5fD$%mr%O7b^|&eb!@8pplx2n6(7!ct1GFx&O?WXQk~_7wZ95u$%e#yp=Ds+ z@${E{l=lEwJX(LGpT0nCnPA87D8~uU!XcU`CYqaHTgLSWA>ar4pnwv*@E8wbOoW94 z9_6QR=;qJ?kj=G05<-~3AHr0_5M!U?MRfJj#wlwSO#Zf++W+ob4@p@a0*?J!AcXL^ zJ@hFEJR+s>4{8`B+5OEn2*Wmv8im3d*;h6X)8|{%7wEPSg7F)HomO!>@^*o18VJs! zgD*fqXbT1tfo}L3Z76p3BYd0kx(u|93>eeeZ+BhOz`(78ckPC%IbdyfW_xpEq@#@!333W^Q{y>&`hhBk0pG=i*VOCbOb4fjpK=8v5g_qLc`A25D z-ya8C3IPRXW0`wAr;BH8j*4={4E+=AWUNm_n5h_q$yk`lgpha=GBP-yUJ?^=lbzYf zvjML`_X)8$fruX@Oy@dl43T)aI{Qc&D|02SR05AlnwYe56EjfqI=Z4i0hL5cBYZHN zSqm)rUi0z3LB~0@B``9cwH3atWk1~Z6?%I6(I1YLc_7e41b&2R(VPB$8yg~kcpQkA z#{lp;IeyJ8?YWuyX_rLJ1{$<>; z+c*tAh&N9vW&q=J_9*uvc8(L(tU0TuxQCn6ulqm<=p*e?_GG%L(WI$i@aN{MY3ARS zU#VJ=XL0#LBJ}I}%QEq0hs3BW0@JY^)l-L}MXQ2cwyp`bx`o}{Ss<7(Schh^q#G<| zFSHmW(Lx7}R*_S2FxQmF^Sh~sj^;9&(V&vPMxIHTa90;MvcKAV{*#XyYKOj58r$}T zeVXzs-L85Ms+Acg#;~FN-LlfryyepT@sH{LVS#1na;TiypBugfe^+sG8vjr#^|0eo z7UxI;3owO9VKet0H}Lh1LnTw4%U7Ls=zx~M!U6-sun=tG8M!ZTEBoUsAq|f$VZ0pd z(&4p5_S+F>u_aR{O^e8JgqzWM%gRkhX8XKb+b-v@Se zX{$(1u(1SaU>>lw2Rl59$dHS`n&-t|J?ku9f$ovdA^ z`8}a2-JWc9wl(~l|6}!lbBJQ~wbZc$dWm}`1gYn6A3@`Oh0G)K^~z zJBPv~0)cLCc{N%mu*55&@E)|hYF`CRvC9t>Q2vmJnO`(aVhFSgz;?-lR3dias5-2^pzF?~S$0E~zZ#Pz~ial2D-b zGI*=`Q+5V1Dc1hM$;rvVzs+Kz1c`vWPrp=DAE({-;n~;wt3_+la6Zn- zMFNon_w0PAzsTc*3H(V}WDPEKxAH zh=E8Ah@0!cKB`8izT?$h0s{@@x?7pq14K(MAO8Dlu6~?tIUk~ZKU?~`gVn1AXGm~F zGfQq&E#RwR@%T3}KahSj2}@wO98Oc*S-ElF$=>~Gr%Ui<&7Jkad1w$7ClS^oKh;O zRXWktKdt|MbfBnQ-QocQN~2xVmir@QEvxvV2B87O|6Ug6ChYakA@bUNYM&i+Dl`;h zQC--V)ROSb{`7~!zP5O^T#M;~@NM~81rVZms!zpqw=t4ZreFjyku{H*ci3u@^KwnX zDU@)onB$#3l{BTUaAf=zG=HcMGTzA}KExH!%x-3|xZbc4 zDpSr>`63;elw##_h!v&46N2_F#l;9hyCI*skyR7XciGG>`~;00Al# zf(Fs@&c)~aj*H>}IU?o`gKCyj1u0mMRh7uU3`E}l*JU!s&($2qMo8f&4I0Ve8(zTY_Dif_0a72Cbg>JvU21)arBIO@-Y{M z6ga`7EDTQd3QbfWmFXIj?i!o;a%8G;<5ULw%&MH*TT(pute&}+3D;C>eiY#*qK01Q zl|qqOn>?{Xhj-2bX<{XU?|sh zn#Psg={b2B2})aH9ad&46iO!tL7fy6B#MqjLD_Kzb#kGQr%wEIbl4#)q1+KJD72Ow z2-LJ?OIj8yTAC`Ex=4X_rF3hx`VTbQeu zLRUxUOr#&%!ww?(j+*Em!KRAc2mPkIp2;IhnmASu{|)suuDS$*wws(PDQ6941cr#= zRx5_8kATdmD7`Im?ZLrUj8LF6E=hl2%pPa6!=667t6cLZIke4(5tw~B2So60q3XCNEY9|%u<2Xx-H?fu`6FbH2wocSq*89+{6pMhcbx@q|ecCw1NoSu8E znZepp3_6hKXS-9_8a5fC1u_Ka*PCcN&vYcrU3lNC`)v`~>Jo!N+$p21Mdik2E^VTw zxTzjurx9*~DL#gX1rZwJUC)@o!8UK#)P9934!y}W(=nm3j+7P7>tvUc#xR1Nmb@cG6jU4 z0U0k?bznM!RD_x?119m9048zx3v_WXh8l3W?jBtsvIa(kHbs^tvkkBGN$*o&a65X4 z!i6dp)qTN9BS|!u&0l4)EwQ3VQl)#iR_^m{RT=62dCR_zQ*N za%6fcySyrEMy9m8Gg6%n^?Rot+-IrNvCgAPp+t7)NiP5 zcbd2zRf{ZBKPoSu(pl53uIaEe@mrYe8Qq*2{B_xFIIBralRjpS1Z!J^J++{9e0d1W zExTuXmsdxg0^PI>+{_?;ZcY#bIVXskd zYmG>Ci)@LTV2K@TgB5p`eQ$}I)=m?P1LPSElIR%CRktcs(uZ6yj9iOEa)Hd`Gscn~ zuN0(95m6xSHo(*+?Wf9X>Q~3`dW-Bz6F%Ug=Vr1fz40+BehB##~Rb&m4FdGBx1A3zPSk0cW|`^MH;n7*!+x}uZmSuGfu zQ`>-maSNeSQk->us!v+(`{uDXW|3|{Tq&@swa9=fk5b<-uf7h;EscP9<9y>{9O+@0 z%Hu%5shiZ{|IKxmMfZtV15#rgo)xkt1J=bta0Bu6Qd!dl9J|`3E5WjQ1l?zce*Cd z)1Q`EebNs)9BI(Tt>Mk*|0XdqmT+V?c0?74e~D%g!&Dz3)r2J9h!|Gud{b@0%d1j3 zL&C^L{JmL@k9_Xu6vcGNx1$<5paH_i+(Hs>J&~)$cEL15%veLP$X}Ts%EmXpMNQ4f z&BRx7@0L{G+N|JMB%-WN+d)~ySX%x$4Zxs$Z28kNK~1Cf-xil8-MacXur7GD9Q84F@z3Bg1n=;#lJnl|sm@M8rde#zCsnEU-iH&a!b%RXk6u_Esw< z3(KIa>pmna6_JA_P#c?y3$*AQ60T3F8D$A-!x)gcb%!cv`w5bnuU6C?9{ z!IUKO69+Gf*3;11Eq~Krg9+%X{?iSKx&`mouNZK&jSu%tH@5>E)T*6dQ>s;+G_@*I zM3>k%{u@o-DD&97L3L2i*P4ZkJRLiJBu2&}l(rV?TZ)|>3Tg7BgG&ML^4i+!vTj{t ztFf8u$mwc`%@U(-r`P-J0dg`UQ2mB=dA~K26+NoNXmvYi$0c8GcEWYpIpvt=D2r5I zOs@D|xxAv+;NC2etI5N=I(_meM-wuTSGx>;5)SY`q1xq+^t7`RWkRosjg9Q;lDu}k zs%GvQ9`;{e-tOm1s>DFi3L_~aRgl$(` z5BCdy(bOIl)ln3ZQ702n#_>?c4oEZ z!LF}xSW_pcpiiL5l+QeMs(5MF{a&J!zGQgW;5iWyVm#q7kq+Ghx<6blCBDIZ#6X4A zFgo&FDSf=_QYKO#(fSENn(_0xG|svkg->1=?$ASfr$0ah#R6lir@$}fx?Rcv>2pXs zD4-24d;m*RMOzQseW zOH7uN&i3WSF2gMZq!?TvBqlZy)FX^0Zup3ibP48231-QOG8e0HZSAfT(8&Q% zXFUE$N-{j0KKziN3Rg4JzM)REk?tQW4vMI$nXId8%s`2nXZeMt`2H+8R2>ow&L=vp z`FgQmxd{RCwyAq1x{Ga83-#8vO}ce>a8T`N;O7xoU)j*qQ_e`lz$f0_JKgEWx;Dgf z?1sUD6ZiKK#dNe%fJ%xFyI?SFxlWzrZ;g}1b~uK@Q$x@cZNhWkztP&f-+WpFO|1SZx5z5D zxAD$)G^Pjr*a=vfSyn(i`#;2hLooR)SV{>I0Tr1&P{uGW*3Un=CN_dg`Du(qc%W}& zK#$7p5LuiUBX-KPmbm0t8B&XH*7kyg4qQ3`8`0qj?g5h4y@!h#62|j7feRtx%iH?z zL2z=TE}K8r8dfDuSv#m9rwlE33CUh;Ib7+P@+2R_ApT|xK}qHWxe{HimS{H4~)(Y4D!yzOIS(0|se3oF%KImF$r#NI2$&sI*@ zQdl@HIk{t5FyyM8E%=FP#4bSCv>!(qGP>d&N4Um^6JXU@~YO_s#b1a|7B_P z1+Go{7;wg=dLD1c!Uf2DVHG)%^ldEsCIXC6?a!ndLg-Ni5d_h`01%p=!rd6*n#L?k z6Y|>!JNrj_g!@;RUK{ij^R&FPOoE(WfYu-7*Hd$IpPq?*9oQ3h`#!N$rm9Xg1Lvla zn$iljMc1mzsv@i6uJ2CuHg2_kM&=%lZl;cQwvN6&o{lmt?DCf8j#lPwj^@^$Mk)qA(qHLubPrb^cYGRHbNk|$A()_C(> zCo1*b#dBLfYh+i==qy%Zq2#xHS!<`*;x{O2E7#QWy{RynnRGAT9Qp~f!uc-K|Yg7t9G!CmEka#Yw5l=*PH;B}8v=yhgopl@J@hp#h-Be$^xv$DpS?~+d;Mox z4qo=@zM*iY!|G*QNx?n^7@ywU8czns*o%IXw(FBgA4z;j)&3$xm>5yPBymxjC@UsI zp5_*wwotKJ$~od)q>~kuo=o*t(?OZaP)cX*Mh}@9AMIR0XzYsrz)+|_%CY)RCNWeM z-aw11+;C^!Ky#@-rN){e(NVv&1Ki;wUh7k%Vx`7v`pNmPjAfFVL!h2gaAxH4FRu`h zHi@ED=3{lAes84ozU?_H{_^tg17@o%sc0^(B&gUoRkOqZRUn1}a%yNzfP()sH66PU z*NuXs`bp+_o4d1JvMFTQ{vox+5tS(>BFL0gfLG1VaG@%AKMbHc{wGHtc(8UD_~-Jo z{Dw*J8;HCj4OR*6Y*w>1kGD?}k7cw>2A3IP#%u=oVC?Ewsh@7!Ldmzebj(Ha@|xY>ea_teoWBAmBy3etPa{%IU4Sv7tFY!I{uI-W|o~ zxl!0g!gU;&?13~jv9L!?am-MQL9X6UTM$>FDkRW?H7&s5^QQNPr#DQb1i0tSbk|zf z;j|(Hb0b?r4_^MX{xYU1_Hqe+qU#Uy``6?5DedR|dF}JR^P+C~xTtYMIA(tjuHG)v zjC8n(PrbeLbRGiH^_B3=!WHs|^*D1o%{t~hE#d`$vzn;NdywF3Wc=OA`kO#op~1SA z#)X}gds74Y-$k@XE_BjML9c(;Gx3t>q$KD39@Vw()y)rv)<4`0UCU(ad=={)6+2cc z*UNR*OHwvQNY{`~W+Qd#&yzTY{w&BotU7bNj6Har(KP5~`rceuaT0Kag&;{W5$i)E zU3_X;)y?CDpVvm6)#`o-nid87@Hir-n$CVu1H`f^Mq{{H&Ea{sgKz6`mKrQ;D|(+@ z_F&;WAc}(}VSrlmjs(C6jG7n;=K@g3J<>|Qz&ONbavzZ3OAa;HCYcxB0tnEzjfh{H zUKqb}X4wU1S^3~{W^34^0lZ;|;Du9QkF3Qk94OU*1N?$j{2#v-kv z4=(Q`O^sbhphFb~KU2$rps{^T$Hd~Og4QofS!KHI4i@K5647cNtrrhs5eCiBnS(Cv zqb~J_(1 zgfP&6FeJlOWI+TmR*Et!#tMhXq{ZU2LPuX79~&MV8_MUdc5gXQpB}c1YE`F9+{CD7 zCRu(hEK9O9{SZkG0Q78TMM6$0YbdarA1s*psgzJoF`8$F^p_7$dy>O2Mh=vuVyBaO zKtSY^>3yDI4aR+ZTqUkYxX*%Ir#YiRN5S;klI2MQ$oDPK=C0QLEYI)GR%*`FbWRi8 zrzG~nPxrfbqpq)pHbn_zG2;|{n~tW6wvr29+qK)^#BS7hdwo>KK7--L{c!($*l}}Q z^>z1I4O63uB?$2OPOFnx5mK}-3fKcBH|}J=pGIVWNMrz2n3`c3Xliouer6Hm7T{og zm9X`7+Xb$0KJibu?shWtNM{A^X*OePgk@JvBd?cB=ntHVXzr7WkEMq{>+6D6SI9Tk z$#is5dm--ifT18szF2fOGNE;^dk622nV^QySQ>SAz9dc}yr#msbaCvn znV_^{a8;x>Kz{xF3^=!Li)nP(Te~w zJ=PG>cTyHPt?11+efdBEw+g}mvzf+5zacat=nmVIERpm;4k)OkIe7ruOG@hgy>>A}UU?*L#mzRM-Bropc43LVpxQ=&Wxz~2-3@y_=6Bpx?br3j z9E%xpGrTLYyR;)+3fjQ7Lr}GMc0PMf%x4(rRco15DGj8{7`7dpO~NBs6Q182S`f*E zRklOhBa=&2pm~FX$$|RAS*+sPe7$ee<|UvW@CvI$Zs%4%CXMMjQhi3OdskE_YiOn| zqrp(-icggmnTV|31kXsjUV|iGhZDi`e9^ccUrXj8Y<4X^AYe{DZQ)VWaILba%fy;Ni#j-$wTrNfB4z>_oAjJL?- zb5mVcPDNXB;hvl7-NxYH?Vy)*hnR{EO;w8?JyYo~yy4Q@G7!?}7?P~aZG?=|Z3LV1 z?Od(%ZztBhI=gWXke^~O@WF5|a9*!ZEuOF3@j1Ixhv6B`P~_=jCfx(Vfk*>0 zi<>=zq#JaA^6pP<`aUvs3RH3_v;YE8Gso~T;HRWe6?5}BZ)}8YX6^3fVBuq@Bw#@0 z{C1{n{7iqNZl`T!B(adG>nU#3ajziz-BVK4_5a|{Hz@;vMK{_)K5-uBW~a%=m8V0X zr$uO>mp0E7b(ZvJiQ|sX<`iD%C8(oe2%QrRy)g@-kbl$n#T5R_!OG6%+_&ZBA2+5~ z&pn%WL)~A!Llxc7v(E(t*1c~{|IX}v7v+m3yWsb1cYm@(hcNg12r&1yFtV^P@^%Ne zeOzBV_T0;eZT)FBgPlC&37J&Mt?Neq=7%Lx z1_r0A;GVxjBrXCcFIJ?`sJ_b0C&$+bArl~;l8mu!tcHlZ%?_to%QZ3c^UN>KfVF)2 zD5)8lr-w$=`Z1h{VTj?$lY7lpZkOGR$>R{$QWf3JR0sIc6o-#)Q+ybsr_66OV?@yg z)W+}75Zq#0$mKvzqovr1sI{kH=3FeRGv-N9jhlMIB=H-qrZApK2}@%D`}wekjqz;~ zeR%CbHnC#7+zT!8l>sy1y;>=4q+(}nRp>>cf%X!A(8WI3!c2xD3SNg=6Dq!sqe`=9 z(@pb3+g&10h*FO)#4MrV2{zUSSu4F5Ry|OOteitv%eoaN}$oSvezQl~t*ob+K(ReN`EKMM(!`{8nia zo(32foN$nwxWCz%sH&OqD>*wMK0YNi)q|3fk>@Kv@1>WesjyO5m7jlTRcu{sQy9t{ z)PMg<0K{Fi3j$m&lI=e#xuahY<#{K$atBw0^o(_TKvhf}`R)Wk@j?Lh82WEoXAP8j z1e(xaM`4td!FZTCDl;=wCnv?9&h3aVB$ZOr&-Kzsz!R~>_!1nECar}xxCis2xAneO zPCqc9*02|ijUcXn?gSK+s~BU@tHwE(V^(u%5{xR9675k9C^siMdppg z=Y$*@2diVV4am0(bH5YWB3udB844nkZq4$#Uuq(UsN&zN0OY@)%1#t)T>{Qnx_%O4 z9oyw?KZPP!KzjP{O7zts#XagHGAc4R8VD$Lh6nQj3}YlE07zs8kvOZ93LLxd4=Hl7 zxdxfD0I{Tp@!3I<1oK4=ps?Ki6DzW=G^k@P&Nv~vvbQPuNpmm7#LK{dur;W?&?l16?jV&&mCR|@;?k)-F6^kZHACEDpd z^OblYLoa$X@C(>UyVFS51vS8K>=pcDe29BDjWJ2^Iqy|7w&3t3E+om3YIAq z8=Yi2t@SHlu^;P$hvSmynbaoqS~z^@)a8!)H8d&ud~ zjAac}A3=6wyoD{ZQ{llG2cag5V@7IjW+@mMNiopTDd+4bAVA;(`P>B2R#gNgL<->p z3n#G8yG;&MlvwV{>g>hE&fE!_9L>V!P6TzPCr{ZyyU#?Pwr50fzZY6&q2Kh{GXfYz zt*iQw1M>lRrDM?a#Q-XiO(nVoiV?lte4HB8tj!S43sZDZ#%!)1XJc#AP)?Is?K;q; znmk=Gdai+s{#7k@oINIHgNW#al<1V0^pt?mlI-*X0DBYKUJ==TBDMsGD`dx&S6v2c zb0x>t$?lC(tLdfMr(=X?qt<6*2d=ma5w$WS7ZIcXN^j3=XUBD@xsdD&k8r8Xx1waH z6y!$aIue<8sV zFFJ#vd=@lbhZb;2N1=(x`KN`B8@K(X*4E-Y6$V+_YB5z-f7}=G$j*<^NNxFfmwoRo z|E}~*-VL=ep+hS8t34X4u?|;@6(4z0BL4DHA})eg9Tx6O%|9a?#}?Z01~7Ekr?33N(nFo*~g9qhg#9@D~pT5 zO@)tP-DpwZC;6rPkXmhMZqu-T$Y9D2GP-9VG$MS8pBm*TG95F_hX^p#BSg^_&iqxe z9g9Sv6?dJTqe_II^U9uLyycSvOg6LAK)!Vtb)_N15r z>SyGI$fXEi%-n1Mj1$zD+~!wqZeFJ^>b9*M(Pl%YJl7?hlJcSF{%VHl;!P&AwvZaz zzsB5fB3C*)CwF!_9UYgj<9ktO3?v;5Bw|NzN(~S|WZvNQ4uB{H0UC650Tw#ZE@qk` zxaFcSvah|DssfNQhD=88yAVLU z&fEGB+J=eN2MgMn-}eZ0P3mbFf`~i}u(vA4FAP9LTGlDL$C;_bS*aN*pRzMQX{C_p zW?W{cUjmK-MwvS z@BzWZB;<~6zhsir%NmFjZ3pE3zxt2<;v48D>Jp_J!W{VWTTM#$`OE#;kuZRhgsNMb z3Xnm81f2|t6gE`W)@iM-Y4I)3v9nI|a<20LbCk%#Y?uO1XI`4_lAOJz`QrSXO9oaPjGQ*^|7R|uuL`Jv(U?LRWIuRE$I$EqSG>w1EW+IHBbN;2?lWAbEm3EdAvnn zcacBt3@ukg1l#93`#Yn~w7s1h>(2qqy}xq}SeEilf`t5KWK3mBSyDGK{)z+5X*qis z+;VwcEz5HGf4Ms*-_$hf>Z|nNfRaoF!9`U%v0VzOHDN20&Hs2h%djfDE?R?hcXz|4 zySqbj)7{b%(%qqSceCk6LZrL9QMy6t_B`)*opZSS%@2Imnrn_Z=C~6H2=-~^B3}Hu zP-Kk|*aqZ!JwNYqA_^3RMNfK{C(hu;X8Mx@rQo26(H|@7;8Fzx*;>AW0~^S_U1!st z>T5^BCtNk>SV?X)Luz2)Kvo&iH>F1zfaDtcJ|IL52S-*D^|aameYuU^zPySF%o$9NwqeAk zYANErAp3-V#BL}QPM@R8T2QAmpz(~_H3b;MgTM)~%UxbwayNsOP79>XN<0Abm139K?yp4Kf(-g?z;c7p9N`bAePnd>glA>0Z-z&nkH0-9e=CiT`VNoUTqbr} zS!wWgMw1kLZQ-i|T87rgM6_>xJ#c49uZS(gsNw+zKao zo4XqwaI%6=PyBA~$<0kT&Q^mAWpYD9kuu>=QLs<2aK0}ex~L%n9qq_a`kVj^+sfTe zg)bEO4+t{?W{B`XcKHz)PdY`v2u(A{k;&s~j);Qp?{(18m*eC4z*Vey&NF7}99|Pp zAi#0d@9MSZ=NJ1LU=;eg1z0AbCQ4vZaHQs;!OnxYHVo^3r@}FFW15r0>iOfpyHgKRfUe zL(TFV|Ghg!x8xCh;3cmA#MvU4iuv8b0k-r%2MbXUTZBJ*%CpS~?7y?3qS;JIFN3~3 zLfsCfbk7v@kyGY&6C`l%ypO%#?@W&me2eJ*xHR4AYxupPHb;L&^^`}8cxmvq|7%Uq zxz3eL;L6~)c%q(iyOC_c>g)rD)>D+$YCQ`{Rh`(dqOe^49rOW!4v_%z(IS;ykwA3V<@@uX0&BiL(tjT zw>jhf1qT+^*LvGx`9`IeuIKtHux|d~l{ZGH9`11Mj4gImkzU@nYv5awxRr3uT zzNr{_*Ii+uzYuD2wEe-77#|)2F|ENeUJ{0X{=iK}GbRzAfS=v%?Ts~G5dbsuEkzD|HsfaWs6vaD3wc?IpDF3;LV@`pI?xY8T^!n9 z5++6SL2BSN6btMRuhcD4uB%o9p}!q>y_kpCxXLsO)R+)zPNT6+p|YslO?|wb z{OY9^zfD1LNl5gGg!~ivsmuA5-ElWYq2|UKn~OtI8;=ezO?B$Z%64s1#%S61s%k)% zxlvMf+5VZQ4fZH0q`WwsTAVx|y!|Qwl|~3~Qcj$1Zlccm*0!WkQ!++l7%&?_0)YR14t&yJs%iGyaHiCo-3s1wPZ|uJXK`y_{oL+#TN0gP!bUb zr;~p@_MW_vU;PIq-<{3-fP{OozfhpRR790Y{M;6C&h%SMD)m27Xjo_J(>Cp^IyRQo zed@Khm4nBRCC>IoMXBYj5v#G88J2mu4TBjS;=0b1fpy@QjckCjBLDI{g0Ye>vM%EP z<@b)KHw)k~=z3I9EO_XL#vk{1$dRwprm>cs_8Q+C0AAeC&7p^{Ozk z|GB={g|gb6vbmPM(XO}BdE20Q4tc1FWUI2SYEzB z1};}65owu-X2KEMPh}#M{y!0w-XhInNV5r0SX)#35u2>E8*wu;Jw#W34dK}Y0OTVo z5s=3F9H%7UQ~7Vlrm`}LB5b^Iww~H@V8lq#C%;BSS0l?*OIw|t)j9Q-b6v;6w)*yx z&ejUkm2(>3%4S;Xp-sh5xaorjR&$4=JX;F2g;rh%4`;^Vplyr~DW{lbt`9m;B@6bB z2aDUeNW+bUe$8ZHC0XJ4P0)YeGZ>n*jyN^=_N+(j+9Nq@czHkc@jhb z{(MJovIy)PScNG=rFR0W(hL;y{sz*V4 zod=v3^9cVXLm6dW78IQ4kGIsgT1p!_dq z7P%rvP;k`0mNY41%7m;tErZlQrWr_n{aM zs%HxP@1FMRO+GXNN9aa=_#3rZw4fAZS@)w0Z1XzkY!p7<-ZL5KI2pm!=7t^jT)%hAR zA`1h27>M5rcOxBgk2NF#BOiL9#d!&kV(Tk}^$gIPspJnl&3#X5Re(j(03+c&%R>)|2OoWfnDOPUsqwNHz?D z)`gRv3N!vztbFsH(7Jo&y-XU1VI)mvz#nG6q{NdQDrD4KRP?`SNPp2fFLAu{RLUO) zdGJgzT!8!mb^?GcJEX0EUk0bc8H%h2p=?mkl3Dq@3gcN-J0)vKB7C-~iV}0}&zro| z^Xi%-uBUJpxFw9tdkD$ZhRk4pk(^f6nPK9ouwS&9PwPy}P{hHwu;-nwtqN{?O3g28 z+9?~3S-V{8*m;VG87kAidtL8z^ZR1wXC3J09_SF@?Bx^~^u^i5+SAM0Q(nBeq+oki zrG_=3wi>vwQ>v?C@DADoeZb1WvG&gDg8VH4+)#x828;V!p__{%#L}bCy1mD)u%ZY< zvg{IQ`AW2b;q1Kn8f;;4dr2aZL|Kza{p;nFFx}eF5D_e-)XJI;Fb9~z;fXu*2qFij2w8R- zKJe;_>^*sK|BEUosfGf>|F@4rAGvNj+=*ZDqJ2ZtX-zFG3t-w149COBruynG|BIjM zdtMmHnL!tS)KpM{Mu(;ZU{TL;1NA-L*Hal3x4g%3BZc+BhKLqV*!e-UW@!Xbe!-$@ z6i%W(?a+nSpMoxIC1vZ0m)YXku$UB^wM5-ZE}Fww2VgD$KH$KE^+T7vu#mk_cGbjL z=?O1d%=~iV>C5yI4bMq#tCYJW^zS0ETD`tp{@bix!caOFj2vZVzlam)=H8hQUA5N$NzCZBXad*ZIkvpJpNyE$?M zDHSy(ewxHRD`3*enmP;EC|IdR=Yn6t+f#m^rysGc$iTN@@$O6x#4L&4BF zd|1Kvhg@Hl>*>Fg@g#Zi+*p*e-l z`VnI?WzN*nIEChcK2jJ!kIKE|Ao!8IbP+44pE#zq@+unq_X(fkdD#Oj^)5R7?{VD7 zC=2fHDM;!uZ%`mAD9|>8r0nSKA0nWRl%^(#f+WcPm8m=fP|~aaCTu&fkXgVHe>wi( zfdf#Ko}v}i1<9Y&a){(31GQ7FlqYV^O1~SPHpBo{0Ga?G$e3jc6%g>hOaPW8l6C#y z@U1&K7yt|lCwZ{8H+2*xoGJ9r@fVOb9}ZRKe{-AsXz8WKxLRd43G_kT?D*+3)pJy zei$W2nol1-jqK#gYAG!~^$hf}v*?(tKP8vvvS80^t<^9|V`ks=|JG?V zk2%D}F^UNPgvsK3K9M5liiyM77>a@Mr^f%h6;A2}t;Mqc2hPZ-&|>vELZqliTo^RO zSv%h8v7c+?6M2m9!bY|J!7Nr}*eeSMKRLo`sF*lXa1aj`kDBUnu)rl*F^Kl9J}NNp zL!4<5w|pE0j1sFAbHP|+=fgjSk>XI>My1R{i=`Ir$VqzY8G6d(Q-z{cQ;YeFOaKfz z5joL77Z;XfVZNeZNWT<$_-Uwc?Tlq^T2$NjHT*5#B{*8bOCZ_uzM1%|)qp+Pwu;QZwJ_n_8HC^M>! zhHBG~6b9PPPV8s8!}uBH88#WI+*Jp5Q$YBxV3E$ZYVe-W-QxS{>p<(1yh9uYv@Rb$ zx%0HRQGrhb-}f$|t}dY{=k&CWj~C}vzV^~_gHplcu)(o`cBxi)cckTu0B+a(cE$VQ z_P85}2>IK$+z35cadqW<-l6*sSVdnRSK3cDoIjPK0X*Q;l5}(=DJPa%nk5#N()|N4 zq0&&M-LDNhC66nT2Pw1cGU(Lu5v5qaQ*H`Ekvp7V@whJIpwbFZ-gecW{5$(fLJO&H z?=hczX&~WIk23O)XCD+@wioa z>|x}xtiJltFT9OXd0=_4C|^Wi)_*4=Yj!PmYOAFJ&))rH+g)foei&*M zXMHk0yQZTW9Z6Uzcbt&m=j9_2#v+zFf?*^Ku#GeqVgc7_r~uB)+-J@#>c)z z%%vSD=K0zv^44mrm2g}6=#>!bRFAZ*SLU@2y?s0w0L1p;`FHrt(Fs2il$SFUKL?bq z>Koy3KG%Q%H!c3|O4*9>Z2R4I0ICS^6I*zedN^WK(@y4Ipg=g%PP7nIPyYL@>Z?;o z#|r(`r}P$HZT=Nqp2f}DlwTUaqA6L(c%CCv+HU_X5UIk&Wub)y4yB>~AO!^mKsm2Q zfFCPZ7pQ@~kyzk(y#tuCS8T{CK$Lb6FDU6R`r{1+s-gKWPCbRcZFbrjWDCr-l;Z}J7XqL_DLCCJ(%pNac_qeE0}nZV!W-)wBP>k{(r={AOgMl0o0pLj$tn6;F_P2v4q)S+wB|XW=(R9{X zOVrCm33m#X1aXS8I}7YWKR`7}`i?JcH;2b9-NJ14HS&@5Gs-e*WA7ou|CP^9z9#~k z-X(O00GF6CIuottxZ5TK-O$MBk>F`u{~WqrlzHy$pM6UPC}T!f#dzAj0bRuvGo})K z8MO+HgfZfzw|FBYQSZ-O`p54fPOC8Op-gN>Rn2j|3lkBO6;Wt~xFtTwsn?tky#*pq z)N;`>I}d$GE_t>xwgC>P(; zF!(aao!P`T^}O2wm|#l}((H$JuAc7zqrLi%MIIPsY^?_Uf&%4D0i(n8$nnT^E~qr(lM5ifqnc5)003l9 zwl6L!V1uQk71dpz^wrJXMyGFq-hs9{5!S*QjmPahdkH~IkP4vwIv`O&)JerEpNBku z-<(hzzSN5Z5_DAbjC52y+^isRVQ7g6z@HPU1elQ2ggyO8&s=hd zFLPnkvo3XKFzO+g;I>=$uCvi^37))a_efsjeb3Dd5=pXVd9g?X>B?d~p4V|S~%WbwSN zm0XTCLDMYb%F`1uA17ktMt|-U=V~Xl9UZX#BtIv^zw|-{OJOJXCu(Za z%+F(i-J14X4hN5be@FEAc%`YE<3Ef&XxN20nb>GYXO;%Cn0GZIL}JyUqWj$mkT&6r z__0jS2i>unHX}&Ys8&Aq;#})-47v+?z831KI`WCi+Zb}XK_^OAai^H|Gmom2^JUmK zs1X%$yS3`0@o|TUhdjD-2+kOli^Nes=VF$vHSQMKedRvEsQq_0%l>1f0fMX3Eb%?Y zYRD*b>n|r9ejwdQcBu@QaI?R7(u1 z=gj*L<)S$us=yNtKSHt9QVZB-HH6=apD2nQGQv=6{LX*4(A622YxMYmp-y5D+Vu&Q zcpzVD;1dP>0<=t7k=Ia2s9aU+l57{VZY5EZ^3ufWRO0XIU%Ix>+{k!e?a~Qp!)S^G zbzF@_nU`>!KBwaUfvp6Z9t*aWtd--fF1|~ZvnJmooOK4b^^r3K`3icu^uy;n0R zkyRrZ9sL{_XaJ*3?{&Md@tpN>YZ1PDF`=GO{)_U!dIZ&7e}ldAXP@#QSLB-$&W}R? zz>g;29~SNi((rG0|s)>uxQbWy+Xf5m8-`sy+L(Su#}uJSNa2dtG@ zSZum#45I`VU7r&Z#DBQkJ()h5dH9cfdN6??r$qG^hVg*iR2)J5>He%D=n4K7_4a~a zZAYGq@Z8XQum<=RByf|pp}Y|*#xEB}4Asy9#DtMwOMOs#$<{B;D`!T4fqcwhCp&6 zZ_4jonr%4E3nE7y6uz&|H6BEV0q!b&)X#MaYTNAeZ|47NmuB-h1Srgu7BuYfffHS9 zQ4;WK-arsz49Bqv*sOm946aE3=%?YlWd_kBa-ZNxVdF>xp)^{W0}C!PMe=ks@Qh^~ z;vEo5JUu!ZQIJsouV3=#r-RsTpHxN4GiN*`m3G>r1?r?CtAj7;cRQkGoK+nO#1>)> z7|lvhA_(#&)L1_MSaLBH^)o3FiO6iUKrczg&9N$Ye5cQ{O4j&yMxOSshz+b_@-cb! zWn_-@oXm~@uv3vwOeeaQ^%)(6KXnXw>saGuV6TuuXO0VJ?!dp z28G|1z9@x=?Yl1#2TL@}sE%MWC$X~$FB@482x{P^UKtQ3w+lE4}ts23iwP(MfVJi;tomOh)Z6Qa zo7;Q0FL#(iU4J7i$glK{34H&HGA|K^lNk;mk{s%#T_&i zGIuOd_AD5fy4K6C18INDS#y3jIPeL`iTjbB&Lom>m>K~MXoXGJk7gGqDO0vl8|?WP z{_hr7aTza+)X#B1SNtU(Dy=@h|K7C6=l;7(L73S9q{shT&j+#)WFX}AdiP{|fx%FX z92!z1=G*Z6S+}dFc5MsnRL}mQELdj=NSj2O>R-K{ob9*zckVqi`DtaymWdPh7iM)Zak+Hs>j@<1q(-^2TEy*;~lbct!P9TV3aaov@6K3nRqAp*l`rl^9ms@i{^%Lf zmT`JNpeSq{L3HxYrh);o#oQTeSxk!+c&k~^FN4Zsn7lw{z12MX=>t0GkgKdr#i!A6 zmG7`EPc6KpmTciKa+K`O0_QQx=ZJ5tIyG4;Bz#NhALT;l96#}pz%S&FRKV1=`$o%5 zi$LMIF#-E2Q0hgT=Y0xvEMNz0aBq7{TbI?B18m-lx-zSi7L^FuN%NK`6G-j2(mNR~O=%<8#Lzz#XI|E;{=|V6H6tVrkmz z41Xk;S|`o&f7s!{NKap7VU%(P4Y0kdizO1~!4BVOFk^z(BBfgnEZ!`P_xdn)@OXTn zv%GLuHG=Xp4gk>u5Tnu)&2MoF-8U(s&3nc&+2er!hnpVAcL5Sg<^d(47?{t9vWp8I z(GvmP4?vuNg`d12aeyuqk$6a-`KRSMGUmZt$K<9La_h48WaM3zo{BiH?f}7@nVAwCC z0-@!mq`Ha-8+^Z<1_es+^oWvqNwtkKoGEbb@&Bo;Ii67zl}hC=daLJTFowSS0a+Yn$i9vKARvQ>H-MCjzX50%e&N_%%yd$q0ik z%rs0KGLnO{qn%xPnT~7o=ZC*v(2(xn)MH}Nd93Cc!s+QEpsPzu!?zU@#>ZkOLIi1u zu!ayYWd}X68**hGu?nEucQ5;KP_q7%tj3&CA9f1ef&ds8VF4(p)PEBsRfcF%rnWBA#l2-KIwD>CpX{-Rk#ZVHPjh^&Lu z#BA?SvAK0u$iO`douwQm9nNxXe(fIhgK?rZl+Heg<+u==wV}tsM z3UY*vvaN;N2fY+ZH%T%Qv31j*zIPwEcgoAG{(c2V#5i65B7*dWG^Luci*RSX^WYO` z7%FMw>zSb)TkkoxUS_Khu$+zBpPqM`Zv5I1m|x(S(-Ie+EzLdEnZ9S8!8pMmxl_(m zY$_+;nvHilDfiT|X$#TRA$Wim=S#qh_>D80VvKeyP%W}9SAo2vtG{lX^&f7#ZjvMy zv`*Bdz^Tx~Q)Nu5F@wQmi;!Oaf=};vG~8v2S~w3F^H2-9R>OdaBGVN>8pJA`*E7_q zXRJ-yPS+*S*2M)9aHM;#wSaoU&1JbTU%a;`O+y1CCH)2ketq4925%~;V^Kjq`=Ig- z2WZT84>!Pk{!+NUs!`)eR<{NPSv6^+wsw!XEv78SOm#b`qN;vhZ;jJ|0_kKZ=)+X& znYOid+anZOxEw&;DmRvEI-T(N!H4zRXbB>Mz+wgTxLku4ZC%VW8`8&Tbjbg0Ah{)= z-o?jVtHogaOd`$CAo3Ds@A&a3_eC+ilt*48Ntd0;xss`S1&juCf=fKhD?+0FL4h^4 zc5V{f{7uEVQtJ94%_Tys%VOJ0WS2MrSK!SNklomG(x zxu0&Yu|_@yP2qD}nt6gD%zCfQzC%Wx7Uqc9oMl`8%>U>9K~$ehDRfJRO=~|s&Qzc< z^&-$MswP2ySfwVG9Ac;DqU3r8wu)SUEoFtc$#70M_X8yM*hJRDW}|RYU91`HNgA%h z4mMTN#%RE~fhX)LV}bqwHHQte%awZmVxIY8BC?j&9bc2qCTEn_mUg@uh(z=l`eERw zTGeMQh4k#s$*`SjWs$7dQ}e4^UbdKj
h7HBHV;I5h6D``Uy+U+TTAdSF57;@vq#Unv_Qa5|9{LSlQ@6ZepGDTF(Bl@b$tKgwzckD} z&!M==BD=^YKFK6}h5~gPw*o2y>wJ9qsZQ}xNs*N6m!wi`!%D)GlN;1RY?#92OhR%z zJVNqI0@Cxnqoc#UJ*8meD`5UF4NV4VqmUT7L`IZ2SjU1C#>@H>NgOCF>tr|vYdWF^ zkV^vI=Orx3h_S-Yt6=gB=t64DSsCi`X6}uxZ%2Dfw>Pexmsi+xgoEN}Dkf#L_STzWae5nAEA z;N~w+Qm{b|-AAkVaV$t`#?i0t978F-zcTFlAS4ixe&Y!d=YOm`CQer*&Nidw7FSg8PFoW{jNE@j##C|#t(}MsC`ClqUMRUM6@Kgrszy87^t7pPjU3eMMrm?+2tdeJ#qG*8;Zk{wwJMt?R zL*g@?kM|BoBP}Qc&j)k7ORJGKj#~UbySX~L_~KZPRtOY<*|^V-YFN>=1=33g4L^fb z`a8mI=13?G@AvJ~*SbJwmoMfS0J3X~WlbGOl<2Q)0vrLrMvQ6pX->g$7Rec3=(s`w zG`RDM>9TcPD2MLC>Q;AZYp+n#21r7@hLl#wGtfnh48j5}$;QSSS<1;1HT5mf6wps3 z4L2ZYoU9s*Az8TuNpQz|65SI>k!bS-S*wuLY ztsofBOqr|=Jh+alXD9%jks(v9M=ErDyPT?k{O3}v8#1L5FF$f&6UQ0`;8I-_aa=$7vH7YTjMquGnFD{Gm*beJbnK6k4Br_$(aP~Mza&0;;yojLfy zb7;(PxKupyKhsSzG}tVMu}ro$FD@D>?%TIN_inCV@Y=h89cLPWL2;hV8ND0*V|duh=6M3I0E6l*hvR)#6QLXEL5G>JqQYjLm(aqETG(EahF}qOQn%JAcbMXIL5*xQW zqRPu=15{3NnhlP66X?C1=ri?vcCt=vLxsERGN|wdQb7-Sz5)C}XeGgQ$~Ml6d=(>T zqoO${h=(DE8d)z#YnPH-P?91X;3(F~U={fx!mcxA1kub<)|sn*?Yf5Oji<9IWxSW! z>=j!L$u*7Ek|VP^u(5Q})K1rD5VS0&Z#ytvIMtnA)li(JC*1R)J}f5O@l2w&vZ{I5 zm3Q7XzpW1y;T`@$B6JQQ7_4$2 z7FrYJ0h%&Y@%hOS0G{ac2k@fhHonrLNCu)vpf?npZYa<~NnXkTK>H38MWcmmx=5?r zIY1ogML;9cz*b(o2xv@(OEgr;4OqO>%XCqQc995<0uq>_DpIOR+TLla!8qT<$Ousp zgtqeKnt>@jCP6Tn&46iZGmJ6x+PX2{C_Z_8Z~Zx0ozSXj3Dhq32TH0eSecT3Yny}} z6B$JdX>AMqw27iHZzG|E5llpYqm&YTusi7Ke%RUZM?^GS%tZBldz!XICIWcyo2V(G zepE(QR*M;(j%FQI?r>+Wt`g~ybE|GVboK)@Z`%uwwt#aWD4tFfw80!F!K8MtlTYRd zM47$~-E`NB2R%*^AW%#MGw#y>1hbK)Dg06d(Pyz}>sTB$SnAY;C$6^s(s^)yls(6&LyyJqxu`ngQTY z6#sW9=CL5hlR&n4*0*T%I2iT6o}OP0{Jf)$8WNCyt;_&8KXGahC6`blqY5cG83``o z@#$ftlUgq9s_M4(Al;yAm^?##T;c_C`v*7t*l>}M;4@rYxhY5+D@j`-)4zyM>HhGR zogVmBG`KuizjMrO={YpFCmzeQaznFN*q4R}To@OZ0zhwtr6}lNfKfH|FMJ!7d zb7h>&XqzA0Y9U`R6$?ELT|ZTKIOzm7-s8pwTMwA80YG7Se~WPZVgwYJa}6sz`Knf$ z;V>eJ2#>178jCZtF9%f-8C{S5cat5b|HS=*-ZmAo4+Bd6P5b2MH5q$K5@cF)a%cVb z|S>IlYIUzwz=6LsIPekw%qIz-hCdFJho7*z$!<6M8TfHtNsRt)EfiDtv$ z4r>%>gnXtXvh(-&tZDSgSINVf-b}^7$5lt#I?ynnvZ~utVnb}XK9}giIE!p;EtB_> zRhY$4EgpTkU?YU?=f6Ma6<4M(c2H5TXTN^FzWfS$e(3p!`usiU3F{s9?XCXTKVpr# zFl1E$?^}W@^{*l0FW)NwUj*IhdyH(*nx}@j!(OEIRnwo9DrkhXp|zs5%0Q^JMyv}w z$n7UA2qX>7*!ateD1hIB)7JL!KGrpNH862iQZ;w8@x*4NVP>Nfu|QW9pDNNT7qltC4|kfVQWR z&~Yg%FSjmbh>S!m#cng2#!&_e4zq|RF#$z-Dt1;n7D{Xa1`66v9IOX)0_gXG%!hJ? z(WF0wn&gJmy9%%#Lys=&l&eP7m>@=}1 zG1=mpUEr+IC5BJC!4Zv137A+2V2;dRI@$hKwo`jI7M4de@t!+?SlHwdy8CmvK!_g; zl6&4QBq$&*>VJbPF?4+yaqy8P%{* zt1xD`(qgx%kM9447_ntdP`R&Jp_4#@$IXqSQ7pnf8R3%+T5@``U`Ku2ZBit~|BRS5 zMBS{9OONuKHadb8T4PlOJ5JJqJK9kWGg=k_i|CdTJ`cA4ks(@@W){j@{mBtGPNBp? zMh4o3zV~jd7`}-S8g<`|iW^e_N1as|J_%){6roQjMb&H$$q}A0@gv4>4n$IVB#*!4 zEY$w^&jyjxo(G!dW<}SXg<5_=Ov51WJCE7j~CPZ5fN#TycFp zO}CBm!RrS@$~ev@+&Ct>F+>_+LhY&PuCy9;yy3) zKJSb7r!jYU4dazhPk;L=16Itpn~3-)U#bx{Q2cV-4XaH>0SK1XUN=rjXA;%YNA;6B zCzpDAyUI`YAZa^LYs6@!W|pBrx-mFeLp?QJH!(RaB{PYrARj9u9X}%%=y!1nFp&ze z$#97A$cVAXNXgkykWy|eak3)5g)g5(3DFR@A)gti4V+ng4yhpr-8kj#E%V>e_Ll|G z520u(XA56o06aV%WU53%K z%i?#6=LjTexbqM=Ht?4f>Am(c&sRn3t_n`S?=E62rx>X+h4~3!Q~ZeD;zv%VKo8>8 z&=503i#9V#Oq41lxqBLHe?Z86WtV`{K<~r9u?1#>nQY5Dt&jMbj!9GD@nOVbl$m|#5uXq^F=B&Ty9SPA z>AwE9nnaurHpl(^VcPK25(ACA;P6(5sH4^r>*M&3_0ICsAIp)O>C5ByZ9>-aWo=!Z zx=V`{PDGa{giBs3XiP^V9?DwJWhW-Hg5xVPM5XL)L7>E;mN;b7pe3}PY?QG37?Bdy{> z=fE!ZS;D&#Ns`hh4ZvdewtpBzwctWjF@VY;M?~$l>6dGP71(L+d#*s15Q$Xi#gBQ! zj!|dKxK=N!Lo`t#We|%<$(&M}wl2lhSQuxtJJ`QPb~CV67lIJuCTfsnqF05o(mv|S z(eYNt<{0-))cv;2KxvwY&_5_X=Th{nWT7!l&&SElr&F6iRaH!3pjY0-wqn#{1>;j^ zD9~BNNYL)agagx<=_5=@5eZxY?OsA+qx}%nSc)*zYun^EjRJm!{?!$y%vqOO;z+Z_IUB` zfcKjRC=QDE_YAcl>A*CMf7eIAfxj7H7cZKvpD8f?8T9XV;b|;qMIHzZII@#DfIpQib>S$iV`bvd(R{f1-0fz0VcmfrG0tYuRd4OHF2nC+Wwtl3 zlN_~j`57_xh_yG9(cACa%h~3hp;k3RO6I0()L0s(h*>^P z>kXetl8Tf3R34_8-X>T_g)g-u?lasw%8NiZM;|g*T4L_;9_2pAp0l~P;B{l+2?5do z_r7QA>oHYg4XHZ}gnONP#D*U{n4LYVGIuiy)#J{%s?wrHzkekQ-_pv>{5+HWm{j>7a&NlyF-P${r06@rtc`h7V6 z_78$p-Oz=33$k=n##k`MYwl<29wr=+N1f0|oscJ;kXSxpfKI8?Kx5YS77_XCf}~(! ztVJG@)wbhbjk^mS_w5d6MQ;C!cK?LTKT6m=t6IOST1#r?NMbA=MuBcDTh|ONub1?r zMddW>lQ!KIP6a8??wLA8W1TbVwhOCuvZ}Nr-LmGoB^(NC@8n`StHf3m=0>5jxp?O!kEM)bOz(uKx%SpBV{6 zq-8&95Yd{_k0nULMpDfxh6MAI`eGKMM^7N*{*C6sVpSPdjZU|w8%fynVeGql6iRoU zXg1?qKfN3>6lsj?BaZ1|TtjmRy&S6Mv0o=7bJFEr5%I>a^jbO zYY|%MX%)FvR@WD6tusYPTd5fS&M0~C0W9{@&0nZTB9+-VH1=(P`Rs+Dq2lR6Ywu|c zP)44{`tC^aj4Fbehrdir&u_2au5XT>H+ZTqlwU%esz;e;)TW?dc!YT9WpJs@QQ*Ms z6^L25VquddB?W0y60Z6p^dTdNX57)J@DXFBjrw4!47T(*pT?NiK@Vqpa+;E_x!Tf` zPUzzfbhtbzN}Y^p1{Pn$8=b>f9Sd97;X%u9csW1Ex8%peUX*Z*luhg|dk3N1yYDq^{uBsB zdcRdS=rxkNdvIk^`Lj?G_WPfdwP&r@;&PY$nP}QY(ePzOGu)lts1S{8m%PT6Ivz7v zMqr_(T*Ka&qD<*y?XgX&M{_I?Oz^S)hrOasBMYlxdNMv14h}vsHP9NWaOEgkal+`M zinHbQRwQ+RsT1UOIipF1pkow&yM^}lV|h;a7!9Qb<$-rlF(yk^}U;N0A3?Qz#g{cZd*@qc*w%BU#Yu5CIMknWTkQc936 zNnwDYJC#OKy1S)&=%GWpQ*a3B20^;J!|%GE^{#I%e$5YtVXl3idmsA$?hnv^7>0lmUvjD}0Ev(nx)j zBG1=&MHQn9BG_BtdVTv5v3Oum4haUGs2N^A!|;@8VHcEni%T3TkIEw!PZxhS!hY+J z)ltLm4I6c^X{UJN1ah$^Vugl^9o0C&_vV-mqbv;)uP=ErDGT+ktaYt_{dz~o(`3;1 z>Zb?@pOyKo3=4i8_=4sP(d%dfV#3@9oTt+_Sa#4ynoDI9wV)l)m)oB^~ zzapoV$y_Lz0dnPEtmprVQ4%Od=ftK!-J4;_zzfan0KAh-bFNxmLX)2vB797^DVQ-z zxS^Xyw=ezmet-7tkkS;)(40w0oSZ{h1gjBX>lxG9UyW(S`;f&?Prtk+Z@0ZBro|$y z#DZd^V@@VVP?V)1vF-Lqn|FpbEuXR-d$ey@#GSwpvX4BH-67<6L7GQV zJ_>7dk_-C~A2ALu2CZ-@UNQT@+Od{JLkW&+>b&T_NPgsnjw3fN*4Qg(Eq>R5Bx|P| z8Dtu&C2AU_8R;jj8W_$@f#()=s%@$v&9gRdV0>5BSv3-trUvbH6E&kZ8_@dB3kvAP zKv!fS{G{T?^ZUdb1>i+m04zo7&;$`K0gAp8czv?orpIm6>1ie=&fh5__U&XrSimv8Y4`n(+-JGQXZX+Ru=<07n5V0|ES?*J z71a2(AC>ECT6KO?;Vj3Bg!jJ*o{4*SYlE;~WbBZ+hVD`1NbX4BTM$H;spw_O+YQzFuFW|D!cbDiejWcEZXuNzFp}@0 zBO^f0AIS0=4TDCh2=SdH1Swh?|IC_BUBxGvU6{YTgQcpoOu5v7jApW$;}hixrx2oK z{7oJ;jmTGMQ~c1J{b6tU*Uw-5ZBOtaKZ&ivte754m=Pf0J(9J{H#=7#VPesJnax|N5A3TxR`M;n(DUxrpoe#t0zOjrv-QNOlN2X6i2s z`pAUdjp+!aI90ng|D=sNXIzOlT@qY`Fihi%^RfR*5d6Oq6it`;&9$Lo-Ck2yJPTO+ zGnOL3r7DCRWEz=+*Vd_c=qNp(E>7Yf#Fz9B} z-(e-ouiBY3{3@u!eP1eRalGtec14pG*hQi<`>ej}zT!y=tTe?+k$8JQy(fc~q-=JV zG>oW>lQ%E$3{bgnJ4X`R;hscSp=1$)Fdc^qT*pEYZ*GFgw$clP>i{`w+_&5oll{&M zG|U7?s(dvzM=jv4*P{(*x)VR#qtlk6IqVP6nnWAQ3Q^Rj_>Mh@$3ZLuyIq`)^M@|+2E-caj(vwh6(0k_=^*FH0T z+;th+y)3-tI^t4|EuFK!Jh|BU+hQtHs?Fa#n+72)*)Eb{lu_&}q9n`Xv`GBMPOnIw zPq5HH`(ZUNQXR+|XSv)%q{sOLBEF#k9)Z6L4*SIipP9VJ% z+)eCKsOxBL=RodCwr(2OqkYyo;TCks?PIY83&h4W_tq&uL@Ek-L46=FkhNK+YP_zS zqnxoVIJjXg|D-sCROS}~DJx8KJ6FnL44tjdE?7^#Ivd8&tyQMo>VcW&`;hvh4d#8k z?j8=OL*9=)GJ*PH>#hVp%@zg)D(ZQOpn*uf z8fy!IHr`yUW7M%5uwWBoTl!ST45qvzW3LxaIiX5XuulJ0#8)ta9H4>RT{D7l|A+QJ zTqwj7Qt33`jSMYl+=k4Ser76S8Vpg$aK@D8G>;rq4h$II-hN~CW-m7la%2!*s#J0LsQgl8=|W6SC>Xdt`({~q%Z8nlU6Pu?3h#0^9? zPnmFHGx89x_CU3TPM~;kg~H?(uzQeudYvadj^djC? zW_Ra#9%1IQ?Jl$3F56${4@9rW83xzPx4W91|J7ZrCmyWlKb+L<{ zG52Ugk=k$K1%3BNR)CLGxh2rHvyjIN^a(?cTN|HrvANOxPNunm`*WY1i`SRqKV?nU zRlFA+QCl%ZK}Ri5eGNf?as1zy@~FzpgO0F+pr1=qVI@(ib0AsU*!mxOvCfbKL`Z^M zad$s=fll^}DHQ3zFbl9~A}GnRK77fZZuw>h^r?n{@U{CS(?lj$CK z6DXFp3_x`V?Xyg3ef2#;!4&gb%-&mdFHf+RC~JFe@*iSIIo(|B8-1(b58BTVZ(RN! zHx-ZBcf`8k1~d{R5j5Ziq7};tUS|&?Jf@`}vU8K$<&y$oa*Rto*aikM*K40k7OUR` z6&~>`2I<%FfrzPCp@m2Vs4aAT4nw(ch8UvblO=4^Gam(nK!+_a8X}C?8kfm>6If-b5s?n@yNB5>HI7hvZP}NRWei5C(#ZIabw|9jqyv z8v-Qug3X<_d!(>7u!Y<;^fz{*L-24^diL{0+KTJ{NS%K1gYu)^P^!Cn^IfA=xg^eU zf2=wAoW>R^`>B2f%hRixPfUTH;H$3dx(`myKoJV~%gfvE?^FKDG2TxO%d@wM=SgFw z))p|lwf?0xh`RsDt@wl6;a#n}gBO6jy^^qW`|4wA^~Js3(WdgV4xs*lh4oB8CNo zvbwq}3pgr-j(9Ru>O(*ju${R`r{~ld?bqGzD-3dy?IFhgB83)6K}-U9zm?y(BGp>- zrOjiSvNcdNzhOmsy5cp{1I6jde|XOOgDl)DII=0tyfun0n$o|{8 z{zD~>BlV6Wl^*?fn^8vpM$3n+-Nb&m1p-HcE=z`jDfcksL!vSro&utbvk4w+fW(zt z+Yv1_-d^m8c#kmm*}HcW;mY4gNeM~i>E9@HVVHmMW1-5U9x%fbV&}mn3NFAzQFf?} zAxe|_1h-_`#Zte#Ba_X3A~*F*X{r6FuFlM6`(3*!Y^EW6&O=tf#$5jz6Oa`c_Lsry zl#d}s|9r-K)E|1B;XO8Xe{5iWhI7pfGp9V>H^^>G;<1ysV$ksi3G>dcqEOC#f(Lt0pIW;3m`g!q$oADNGsJ!OKW>Zb5~$?M}Uu{xe>ye zWy@zjyHuTN3!RD!hVv*E%IRgDE*p_8{ULpSOj`N)Lxms!4%hruqy5>tCeJM6DqRjW z5Z;Q6w>`3)L?bS8f4-T-b^2SZ%e~9fL8#9Dq*|5iBmYwMQrkuL%2j#`2IfMg6lY5R z+xjno>>m(`R{LC9GIYLj2q+BiVgxY=VlEcNfc{i6{aLB*33BKT+dpl)4P-niN8Cb? zjq4MAWjf|9(Pnvj^6OxkRZBe|3t;aYy_aN@Z~1%bGt5QFtk?o7e+!<23F# zOa*@{q~@frIQTLK9yW>n;k{1yDINlv*fm2Azr^Rawl^$rH}z#Z6H8pW=A7(lc~y{= zOmV##fzTT@O3qkG?7m>oKB0dAG1eryG>JbNe?UJyeBdZu-r7pf&hsl1xI$}@Z)RTf zT|o}z?5DU}<(U1CVadq8EgHpYfu4ZlGSGuo2Pz7H;f_H-{r$rR_a^Q4_gT{A_pRf_ zy-n{=I}=30iB>9{LK@ye@JC~$r4xp^OcI2SQh9?$-|teUbxHnjnfio@2P!M8{WG6o z8YVv$p`;uqHAoZ6N0$fM>c^KMx!6OZRvP`BuM!92ekc8k;@-6^;rFK6fW_%VB|g{n z+CMq(m~Do>8O$F)`ZZi$$#n&fO-4lcxgl}3ZcX{8y+lKiLIS9-Kx4N(XX<9S4*bOI z9|w0z>Q>bjPXi-^1wO2p$n+oO%lZeIP*qCQ)~(c{_oQubvb6Rg4=Uyp)1T zh;#S#gj`JNoZ52Wx|+}}`nVDMkNH8JnQOh1ChBBtGHHof6?9*3#VDHTsezq2g~YN5 zQ-}#u@ksHvxvNyM^8Mp|wArk78#x zh`#8+D+pdkzW*jSz2iGi@9%tecX1zAZqGgUc5kT=lxa8Ku@0vxUzH{xeMy%1z#kFm zR=4gFDFtayWlnUG-dY*Ple7yFd8d_{N%}n(KQV{eFd+-9m#n9jx(H60*D|jBQZ?f` zcU`9c6P?h7;DM}vqR7-0NM8NoTe}&{$AO z%P30smOW&IL|w2_0%tb<)Ai>GWF0>GEJc%PBrwwZEI6@K@ub?{WI|JSv6|XYwMtqD zu9$;8c)2im9Ta&B<1C^`&%0hF;Qv43t&_EjenjtVnq)#zi` zqT85S=dKe;=SuvFYR0MEW97oaXM>QjrCRVHT7dROT`_3J+u_?&EB>W{>2u*^yx;Za z-#@uZe}W947en}Bn)yQlQH9~6B@KPwM=O&7mIZM2P{hPau&8Mi}>7 z`qYTu#AleO`*gaq{D}5we0k;BI;FgXwTKfzK66-GG~MnLPQMt_ULLQF0o5V~x7P|- z9{(VHr^KzzdZOQu7S93)6gvzLR6zE|ie|uCIoc42SEk`h5=`I^`}m=$+TA47EB=(l z-b3>SU2ljXXI9SkVxAnCA01_zv(r{>MSyY&idB7*pxF==MIqyv(pO3EXH`whn!%Rl ze5Z^P-)Dk4N%nT@1d8%^FqEr)NFQ&KQS$78A}V2#K} z!^dMdh80-26f0OqY+!K5GPV;tPHi^?4nCZl2Gv((J51NnVK3YJy(t-V6Aw>&b5nP7 zQv)~ffwi56)ds)6E5bq_M8pJy;>T_nqPG`J#f7~t4)!aWxr{nQ>K1S8n+&5Zy+41p zwzW02w)XM!voNdo_@Yxb@A1WrLs7G^z8$CRhsvyO#eU^1>%rqpY;arZJKx7EFbPu1 z{@V6p*KF&{b!Z@9JAD0>4JoCSVe=x=^Ln)Hd?0LTs@u=&Y$xM!VI2&P-$Y0~GJ_M#g`t<{9u z2959ftSY|~Lx0`-Fa;|Xp`uO&KkcK4f9MVWh~g?)ko2rGBS9TZ$-eVe7M;XuKr;F#kZmLgr2f?o? zQxcpM;760sCwDw+04pk}oL zH8A!I739wzsIY+}4Y5QAd(PM@j3AeO;I>OCq;6f{T-bQJ7zFV%(rg~Zvl1{29j-UH!5ffV1_u10{GK>x2M zOup+Y4sqnHezNVY|5-P~vw-2@TKxR)fbw6F%Zltdw-rPR-6-hqV~z*-HQR`N%6=1Z zMFwK&B+%0}iyZMk-lQYBF$H-=SLsDU$kp2()R!TVRZ)9dmIfLgik}RPbp0eHogFxJ z=+4hsCjmb1Mq>TCd?dTn+mv%g^=b+DMpQaWafq~eJqsVVS^}x=v}Wy2gOa8lufQh5tia&zBE|2Hfa4k)j%y_`PuTy$$YAwcT-TuiF{XuT}k!GmP@4xQEjce z9-trHtu%FaceQZO{h2n>H_H5;z>b?(ch62Ajf1hPRD3^8Cc~67P=a#K>L=w)o5of$ zw1vlW(8;U+gr(W_I_b6z_XHGs3n>X#jE+CKio@3o>n!E*Ld<|}IqupM+Szp?r^RAJ zx>y5K;!_;O+6u)krk7xuhN;exYl1S>VD$@%^!>q=1(fQD-vt1*TMNGP;}KX`Y0u9t>x=0>Ehz4q$DdXt>EV7M@IahsgtdxlbNfZ z4(#c^XQ4p9XHvT$L(V3+5K<=opb+UH1pYl##i4zJDJIqxA7suNWjWftC85L@k66$% z`)%L$VK&@t--GRcXo+`V@A?_!LnrX9^6(G@ONaR29M`waYe%-eqD!MAt_?*hkr_zE zN=G7Qt)TUBZFI9UUKE}N>n0JU{#pOi^fx~=@w6pa%4)2+A^b#?7^0%KVRN_lUFl!& zLrs`pS$1>soc^#T(hTCqqCq9oTd7`wl4Z~%$LhQElv_e8(p7Fps5|`<0q*rXQche~ zl|R&3HRD?q$jg?-)rLwZ86$JC+g~1B_&Rk#hILZ26RUG`TGK&DB2jDy(IW>~BjwI; zcs{pS7B^uf}7MLexwmW}g}%0p-nb@F%QY7Hzj(6DGw z9Xi%SUx*Luw}E+c<09-+c(IdWD^iL?9x+0g6^$t`6=#U;UhA6};>{1)?5pSb8OkRi}}lJOa>k>glnA8w8MB3ya; zr2;;p@F@ZZlgK55(h4E?xY8av~hm)ii3%8i$%lj6t1xL0VokmW!gRWg3ylz%cP(nVgIK9nN!AC@h zkoVokSB>XEk#ug#42{ib84%?4%-+YYjb*e1DP2C$m&;O20qLThQPS zqHi}$M4HjDiAa6_j&GEZXP}j-tDB*3r~y{X&{xmYg=8k`CtxK9NdO+z{0ss@900-e zW*&O6Oikn^fiNhXKrYXSuYf2PFOQ;VT2V6>k<&qy&l4pnD`qOPe*BZ!ZU2`2e+6KW zR}0ZYg!o+T%mfwO-8m&DFmAnLl)~Z0`|=P;Vu*d*PcxCQDt|AYbdv&dvXgHQu44U5T^x>JRK6J!~cP@qKx< zX=@?(pU}rQ+6@8`0RCzxP;u~ApG@9g~hirWpUlg zyvn)P?pIe=H{}mrOWypi$v<`KEkf02D%vR8gb$aN%d_-B;3cA%J^x0E(ZTK@L* z6qC6T34P*6J&r9lfrEbQ*0>|SUc&O2SPJD0|FRAaRCr&ILt>E#|b&xp?8{_Y}NRbJdr?BB)xHSVA8 z=lzlPysnOa``!&t_u;odX*_NdIE_TdpXPRX`#Qh$-5ia&x;Nm0E!K<$PC4{$Qb)b9 z=Q?GFHojk*_<37e*vORG)quDZl_GJmlK`}ghDkI)7WZ$xJd!(Jyu#K5OAj+8ni?(M zu$g-3qUOwZ9JAoHZtwf9hxMkP5t*?pb=i0+gBpQ%#FY{)tu6U{2(_qcvQ~tY5tY&q z59|?mKm~STnz3Ox=d(j7yEo+Lw2zXl^K|Aazt;Pdv5MuBMax_82Q@~7t@_$kZT)9L z?~v5c0B2vYJ2Jz&aE_#@{Xj?`GWo_ERGrW;c!V0u8l;{p_7#bKK!6-hq6@)aX~3up zFmO~<- zK_-v(`)_F@j83uR?~{U1YcEe% zfKtB=9)nmb5|NJLy)vr``>+=jExfL6Ml(!$Y+z$zYh!)EZ~jb61LD-3E_Xor@1Lys2rGJ+3Sg8#q^}eO9YpCrS_SZ8yoiofQ zX!>ACcJ+u0!VEkC*fYxDh63umt=211Tw7RH+1{u=t?7XZ;@uQg;tiHyJ&ZPAu1Ero z`khY3QqRrNy2qofvq9FL$opA>=|kq7_o`zxlTp`CW!Fy`$79&L%i2SynK@^v*OG zac%9I&rr_)@7d;2QM2iq)v*6~LMW3f=P`jlqTla#wG7Z&P%2>BOh*75N#|;xR1yBm zb1bGo%HfM(uw)^c2ot&pG@+9WB2K)RrgBqUa9|3AdW)xh`mUfeIa%M(Fnp9<^b|B> zz@8`edK+dCfVC!v`nwcRG*wH;vTV?UHxOYc`IWo{xQSa7@;4>C7=P~Z(}k|r^mqj( zsZ1AJ_cTmqF&zBV)BJur2f}HTNMX=}C?l-!nl(z|JcML=8E6m*?Z?jDngkH~FbdAJXEsyAwKeI@X+0I3; z<{A9QYM^`8PTJ>(HQv~7C#Ky*mR=0&Z(hynAi1hY{g!;WbC_PSTD|g`-->c}Ff(@} zA3d3EO`KiK-(Kt;TB4|B4qvau+QbE+tgDMu(pMaIQT{FG}kz=7hk6GnPUAwJRs zVyUpw(!Qn?0XjfO8pzUNh0lrjMXOG?Sqeo9lAHuo>gq9GVtcAEXctz*6uy~nEZeHE zJqS!%bsuOXWGA(XkE>6on_e9XpHrrwO8_jO#Itgy%603idzUf)B2K56@Z5dB%CZKDS$!;h$L^u+`v)G~MW8_faf& zSY0pu%(TQZtbhD{?w^Nt^Tb4a|J@KuY_DcbH`k~db-U(@H+TEkczW7+dAT~Z{19{` z-8`;(*>H_I6`}ARqA(_mr6K30VA4qHuFme2xwYXkTMbQLbp{uBa8`WtA?uq7V8a zHmp1qaX#n%k33gPftKkC*e5))+tnoxmB@#}B(XRW7Gy?vqd;tpyPR#z9FFCcJ+FzPEU@9)}s%UR3 z>@27%?kKKn=<0$2ru5|nMMdn)-F(2X|4vwx6Z24riiC)qh%PAjV37npbUw4S&=MEW-3bmsB~&)o9yeg(_(<-F#Gcgd$0f07JCD z?-?t*U(YSkwBsi{2Rvs|!_?z01?un4(VlSL&y3S>qLotYnD&#R>6cf<&k}!k4mWr| zz-dRwWLYn31`ML>j{(YOR$%0e28j=-5rgrPH~C4vi&qkKdBpgA@sTOajB>>#bXt+B z5fA>TL|6;~@;7vek%3NpF7{r!n2S`-~X4gZOo7`}hkp)dhKbA^diaO0>bDCU-ki5O~^YFn+x1S(pCGzi=nZv}qsPuMs_5H1c5jZ#B>N8i#A9 z{dri!?Qcm_`+g~q-q?@AEl|-Qu#|;$M>e zGN%LS4zBW|A}~sF@T4CRr)2ms06`ckdExygEpJt1aqfEG(zLI|ZKlaO>xRrq`80&U=WETpgfja>dbQ zzIOTT`&`2BZ=UX+vyyIW;{SAu-QS&DZMK)@t%-J?0c-yoV6F}Hdq9c^!j?d-q6JIR znWjN6&Ie0xe~Wb>YYBOKzlIp)>LNfSRO4}eQ+hKu4+}qF05qh80Kah z>v2;5p+CUs^rrlC4aKiMvH8(Xd_p4U^aEhbJNEj-amr5T@iFHrPMmYR9zBE23v1<| z@~;J2uY9scLg=dE&yV|{Q%Qo42CDXa$s)jGeR6`)BVnaUz0p|U4|v0_l}Nd|qGpgs zWNWr5LG}D**`!7a8pJbiifS*JU-YrwIo3dU_*d129Pu zH)??!o-N`-36&R!@uWdJAs9OE>Vm2_4p3eG#rT>mec(2y;!b}o zQSMAC$;B+h;d6(QtsIf+z#KKUOG&I06_+unMsB_EilGaZRhy~A*q=W3j_Hh6zz;%o zP^Fm|l|QHdxJxTN0Cw#g7m$n&$Vb%_RP55gcCmF)9<0QFD1q?yU=Q)fYbsAzTnCsp ziDBWSq^`x9%E5;D?3ZEeec<>N*nex<9Gl`@W3_Gyn>U=OaI(Y2=FbO#DjKTl+@7h9 z6YeTK+*FCwXU5vvPwmm@%a@N?KTMoXE6JL)nBJ%UoG1R6wi8BTg|_?ZIZWYs;M?zo z2rCPD)JmR-`PKR5{8Q_BL>LQ2d)vX%as?iYFKvF7XnuweqP3fnvP(z@R!~w>m6w-+ z;uBGYf$l>Ti_#Z+N$P8W!jL{8tQ0t(SAM8)0Hr~lC0PX%fU2t8?E4qWFqR^R3P?qu zQWxRP(5WBxzNoyb(Vh>uPsH zf9Pa9KTzvs^|x7ad!a2)1W@aRo|K5XFOV&>^$=IUtgEYkenK)s8_ znltJ9%17IsRm-9AnVp@!lckHR6|?C*yZvG3tsb=I-GnmnR}=aOVXOQqs-v$uL;25L zkwrclirN1oX)44shP+^Blnyrr<(7C`=NNrxIrIDax_t}3T9_PLvR&@PpdigUqzo0Q zEf!A7?OJ;UZt-Y2PA%mKooUW+fl5e;{P6Q3Dn_U4NT&g z*x5!(dHRviSFCxVYFKdjR?e~nx+G`Q@X_qy$=BB+v{CjZa{?I9Af3Uf_m*8IDZl!2 zH+;8yB-{Nujq=&JIJjPOzzW~LdVn=`HZb9GVBxn^G>pT#NFaCU)ro)Dv-uJM#sRStNA~ACwE_Vbks(Ae-}mnmP1EJJ(O}7j zz}R*C2KSkq0!;d`(Ix4jI76~WX#GRw770i>#v20kEoT-F^TRp+l?JTC*X`=%8gm?E zEGG636A!hJkP^_3!(+wkEQm(SoPU8@G`3d{dGRQ3uCY--HRIq+;3JpLVT0?AsxsYE z*$#ZSnFN8d$M0uf&H&Y9+gyG@!b?!)Z9hO8)88c4f z0H0cJLxWgLawg7BV?F5Z)uz zv7e6R*K8(aBL0jC%8a1GzEzWyUN$Vobg=W6dUr+aQT429GgD2Hb z!yv2+7y>M0xOIaL7LRqJrVxkwOEJ1nvbc@?M?MHwqWBOC;%dJctT-cMsopIjjPYk` zocsV9#aXDN4?3{!77fO$_N zlkph~puWKlK2j~do7{i#!O>x#ib$B4;I?*(&|xA$f}Q=h1@ARUG)?W143%Q+nfM6B zmx0d2w-jQ}ewEuE@S6Wix5w$dssBGzuML3+H6SWf=l!@zUC=%7iRHF!_ed~8{V;X$ zF`3yzre|~oZK-u?amSa!OYEfuVWVb#Db`BjeMCtPqcAcNNGw^SIeO+xPId0!gOPN1`S{zy@Elujmv(Pe6?U$;+q~=}QMu<2 z%DdyMX)2r1442sGm3AG1OW*uDu#+!{AnYViXA3(pkPa6De~%vk21<2j+D4x-fkfdy5e+GJ)FwHUuQ`6>(1CS<9=sBr&3*F&4(K<) z@DKqBtcPQB!Iyuip62=ZgoAv2z5WrhY>y1>0wx=Pcn{t14K`=CjuSYLF}D&4fc$26 zaer@s5kM>thxm}%y&B_ysOg5t;HaAhpD3Ahv{D&Rntn=?ZvXJEG!JDs8>rOnp!j7=$8B|9WjQ^ zm}#Z(Kz!K*Y#dICq*7w&;FE+ovqy~vc4p|gf-6%B<<=)=Z(dWoDb}~-iHgh=34v0{d2wE5a5Gs$=AmE*8}h{so0C^tjs*wrDS%%)A?KX z;j_IoZa*moWaQV^S-w7Ml^>%5> zZ%>i3VO7l#cf#A4zv#-;Hbdgpc~AnE|M)9`9bmiWeSzHRO1Fo-c#EW=Sz9@(iXEbg zI{BuF3+DCvD!r6#xhfKc@r(;8QDS zf7ul^dL=D=bVNVEC=U+Nz?0_C0yX|}!t2$XSt|RKDh_SvcWkJ-LADw=A@wUFNT+`Y z8@z$ZXxEkSYh%jM)w=_U2;o(&IIyrBr@MPVg_-0VYf}IVej_(zfQ`*K9Ir?RXpydH z1(F8Lk%0s6ibMzm03tupOBq8ym&yS#%?7{K5W$nKv)ARBoRYGSM3CC00pQH8G`>_{ z15!ZF2LZsj``_K)k=-^8L`{vp~Zpsi!ZHZYZ;Sbce>#Kwb9G1TP1(G zfkkr+@C_QjIe^H@8Fk!;E_`<{^?)!Y@O0**)FVTxKMni9fh|?3tuMJA&(vQPUBPWW&dgZE}ka2I& zk2LsJSPDVw|23Z;kRKx_`6yt?6lR}>x%GFSSL@w=TL{(Z=IRI-XJ{MizhBF2$`!iF z6TUG90#}d!+o-wQ`M8;RZlZ`SdcWA64koQ?tKJypQgt9{39b6!R62KfSS^3xcND3! z?}bO&Uk-Qd@BuDJ>ncPDMAJ7<8*^M2``TOu-GKC8`G8r@4FO~>UB2Nr2<`fzIzLzF zFb7Bs+p2L;K=vHlv9B538c{W8F(8VHoDz(zLI+q7sr~^$*jdOZ1vQb#r|l0g1iKXk zBA;z3v0IwQf~I)EiWMlK0Wi>bn$6IS)0`IeA%IQsTG@HfK*mY?h!C?v3k6KNdEmN# z#jj@V$bRflHRC>S90V0#kQDACLI zCJ)&T!TE^{Z=1Y$-2!RE|K%Qe&j!2Xb{;#(bkdkh%=g>P#<2$BgIio6+(qPJcQ()?ec<17}#x26jBPd!xL2*Jo;emVAaOj;0$t*dk z*3z^!^!B#Y*6=ix_H~X1Ro8`23tZ;>J?rZGW^CCO{!kI1hT*h~oFU!wSK znoN>iAcM=q3Dp%X8ifE=nS>i=TF@i8UH4mBOL0S#>MXKf$x>1+x}1U5MjLkD%=o`8 zwuG$9Rv8NT-DDOs>#K6*0YXEilI{XRFf4)L%@6ZJ8Dx!Pm$?^D+kY#Lf$(hKFF4!P zSLUWB&q++rMP;9-U^yb-SSpr}xXHm;_L-3vOE;AbK{K^0NP3N_MGGzQxh0gI)6lj-2UMXlUBBscPYxR;Yu>%75z>5l1B-dZ686vr_V@bqLn|~`kq8I$={4hZBkC-_ z=l1p@0w^@JECW~ol^3Q#>9Kbhz;Cz(4Icbof#!2SE!8ZjRl$C!Ai;|DF2W-W3`oSh zMCc%`bNX8ZBE&RKseR8%HgIHgDg#&{ZdfHA5zNY=T-9n*Fr`&|SUq2n8RmMdLy$rZ zgmJZ+-lYZ{I)PiYE)Ee=p1f>4A6NW1P7d#1E8gFSH~Y7~I&VW{zI)&TWDM=jn4p^O ze^(d4YNSUT;C5nmOfD;dJ--4&{rHhL&;dAkn|hnuxy;rAHeX`m9X!a;DRKTub5gZ9 zT=#=VLS)e#=}e}aHp$7=iCMMzMXlLYgT*o=2hlH633S z|8F^wEiwa}KeBHOoo*zSrwfhDaHghNmw7Am9b<;U&h?STGJ&h2l$*wu`VNC0%^8-47m(*Bi9PP`hOyGP@op?z0X zVA<0P($L$Bmu+^w}AmNn5A`til}S>7-qVzu~0h-oI3kFRE6E>FU|uPduYK zb`q#3CVXNY7xZxa6!^Rl82d96`5AsQ!;{_}Jyde9F%6IOd>&}?v-%_0=-B0S1hlB0 zm~&_QC-;kM_xmqdyqkwV`roU@I*yMzs>VR)(~WXSf5+n*Q=W68Z&><|S925rC-e@swa=eopCrxo6mmQm+ni z+8)C7zu$NU45^-D<$4bQ0BH(Im#bQpReWZz*o_wpLpu6xoGq9G^)bns<}3 zb3P-66rdzxqYfJ96K(lx2s_wjb6*?s+XafQ@(a9AVFY_ia2?}^%1#WwSk#M0qec83 zf#T~BBE_Dl=6h-kckwwROgq-BY4yHwRUKr2x*XhIk8JyKr=_r=pn%V*%r<^KbnOFyM-S;-v&M(EqTCGX+?tF#fpPPFGjglD0kLvAo2Vr4P>}k0L+7vPFb|>Xj6Ev7*#@>k%OIo16XSW>Pg0 zvTt{=LhNvEL07o?wI-*!Qxc&J)2*s8gbwu3X6z zdwa5AwBW?31!v6nka1kxH!^ba_-0b-_zXPi1sC;yqc1f;iaZD9&UzG4ZF+DB6^z3- zuLM=>?ha~ZWza4dD`DyF>Emb*|1Yd;i?1eUsX3QAp9xfxn+{OqHlY_cSDTN_dE#P^ z7x#l{zex-=oWQ-StSwI9`(;Qc~ehjpOo|FZr6XgbTND8Dyc z(@1xB!_X~AcL@Uw4bmVjB?8hNN)FvQba%IOwsN)#aG+^N?i_vR!01!6jrx}Qp03&k-i`**t|qajcAjRQcVdj!<@q^5O>U!12Jo#=)V&&bhj>hP$GQv$~qCva&PsV4Pb? zbzDV#R9%$=WTwN0u=eL~!P{I*9H#!il6^W@?(Y!XSX4?Cre`NxOqvc&9!L6f*7W^| zB4U@=%oQXe-sdNL)$9gVQ2755F`wu; ztR~0@-Q>I7Xg=)!j{`MxFUJ%zBXu)Rj=1CQeL|Za$wW{)n5HNnE7P;`qmSNVl!>D) zo{D3SP>)69w5+sB9L3?ECphTFd;Le2^@05kPzI{fNBv~XtN(~;1L?U(*BizW80Y|P z7;DEg*EWhWmGQ_zbX6;lZ!{_X_G1=!7Wn*8X<)IZ58oI~7UB*&{l35Q{?tRZ>r}Jh zl!9gqnCSej|D|pCm-TcdNvt7a_zM6Ll52W?{{eJm!g0ApT)u==IOn4pLY^Q0n3eeY zvhX@oGRk&p_4@?!`>g-qb*dGwY%6!Hzv06E6&Ai}MKkr7bY%QP< z+v8^=9Ski6l0#CY?O9~g8aSpowBWu6q?GBbjxVGuSim5a%Q$FkDg%DH)YOR7B%J~Q zoSu7>oef+841Tcl)DPK<{;pJ$5Pno&Xpy@hkFdEA^%qM4O3t@_;Vv`K-$^~ zFwi@H`Rrq6j}SC=PiciT&&B)5mN+V;R z;%3%8rfI7B9U)?T+F4cK#!h*+2)D~DMd*cPe5>n40&Bgd89glH@hO}1TEN!2BWLN! z;3NjKNOGCUZ5?+}wMW^4+y%kSZqz18&r-9}NsM$Myvo2R#AOBg8C`D03 zu_?Wd;TLz{M>M+H@pWhb@Q}k@OzW?67h6YVuBQu~{x%z5?AO2A{PsVCQ&|o0{Q60r zjU@7N;_|GG$Q0hWFRa7BOv8*QOT)|?9<(&6eTDMj{uuNKEQej5?&GAaebi9%_~!12 zARgt+O$E}kI`gw17UvB8u^_cGH3bV3j>(0I@wj*t6y$hp15#mLRFL!VxYWGufIUrk z6?g!I^%nLVtbto+CSw4E+`szK+{44}#StE!#L+R&u`!VzkdPlfj2E%g>A!)zr9_WY zO?qRXmj`Us8CgPYOXPN~hg0@h^ipjE7I!`2V2#PZUK|B%M}6p&OL~w)HP5AfsAn4y zuV_8+saDY8myt(?@DwF1YV0a$!2SN!bmez8jo%mdM-1@8Yl$NA9|Ge*rhupM(&P4F z5Y4~K`4P!~kwIfPuu^o&t?@s4{~iDLuV@xT9FG`Y<(;Apd0!tLs(=zGwpZ$H^S;-z z4VSbv5+d|h6fbAs*i7Iy;9FfBQ;^s5(^KU9HQ3PIUDVtRD{s%MGx(~;4e<->a>}h~8qhZ)_;eI>(eZQ&#UgkxrLamfG(C`w z;vo70gQqsCIKh4n8HF2^_755gsDXGhDS-mPC+M^xki9uyQgv(kd+j*P>PVE7kr zAhG52Zobd6lz3ys%#@JV(RleW{#;CA$dxK7G&-;PVZ^X7b$Bb^vz>uMT64;ItZ+TL z&=>vfRp$Rr@abTR-CPfBkIc{*vJGx?Oi^cc&9qmig;6|jY(Jj~nU~LDeoAdAJkn9wtL#FinV)`1K<7d5VdzO$(PrC4&D1>lDvAe znd$ep6R~$dGRYN8_RIwHXx3|LP8Jp>222OxdoV)c(!E`#lA>WoW`*IA2ziB3%|&i9 z4c2WqSglCo_~jr;UU1@JRJWP;J*v+Wve+#muyDBmYV`DM$RyaHR&Hdk()$MqB#Acs zKyet?35LTtw#o1${o!6|hp=7;Nu1RyTWPr2p(@}rn<;9D3 ztQJ_uf)XOtOCj9Jx{SAH`ucFSD0=%_1HexD2HwOL z{p^Im(2|;H=hvkR;M8t)c9io&gn(VLz5~5=Eq$GAoIEXbfKaCi&Pvpa&q_*7{tqh) zP0k=nO{S*GCdErk8yx3guo9Ly`qjPB^(U;vBk=PvRoIPl3kJmaaYQRq%)I}k-lr^^ zTIG*P@lM@i;OxvuVuUu^tD^#3ghCP6FI^x&lqp^TBe+hD4mDm0SW4lJTmF4Yo=JlP zfY&R%{(%omz^pI@m=&TD6c8SBHVvq5U2CP$I8j{ z*#3dUA;K|4S(dCh8_(NToX%r!TS@Z3;=Tkw5yMw+M$=VY(;7^Kn%TfGsgHX8{KV$| zoACf_%>yc(Xm?HWzpk}))L85>Ugp&|UQC3VWZ@-bPIK7Px2WBh;dE=K%}x~5`F`A* zFi(+YOB0q%2KI#Ck%g6nIhX2;%)=^{O?iuFNFoY$NT=DD%J|eYT48_S7ZTpvCs|9fqQBjN)*y_V`%3=V_VWOS&b- zxcVxpinpK7Jr3($MYTS1Jj(U+N1S`4l>0G)b)niZwEh43Gq9JYj!&)M^pVF)bAJx5 zNmNhaAi_A*?S_Z#4=r0~2wQ4eG69KA^<*%|VK-^!rAse(>0_?b0&q^>~ostm!@s z4wA;ArbAW4YfQ66K_!x7Q`hggQ22lI zoD49X8DxkOy2$4Zt1S9X-C2a*2;_=?z89z*8p5{8W~=*OY&)hoj6{GXF6saLoPB()iAX-UuqI=IcbHHjq^K zYr0Z>EL!p3h#X2X@&tTy1N63q?khq}hg}Gevja*lVE9amxYxB0+5H`5m7K90)7WOS zrX+x-P0TekNHoz-F*4Oz zG1i+~G^{#wCOxpDp@5JN`w(Lyei=}_AFhn3P(NNu+6hV3nE3laRH9qr_4(}N31`~> z{(5_ayT<=fWSUe zJCL5^J;0>QG2Nek7n^xI;0XtPISuQ&Sv@5hSlhnee*W?EI?!AzfC}>Hv+mo-NuKPr z07M>_bQkBefewPtl;^rOP$~&6f)mX z_WFwvphFW$m7>m+F`@`Ys>`>!^?{`QacDM}#u(`OM`X-TVs19jpf8zXK%oLWNw4Q^dZ9UJmT_CPwntz|e;AqM z0rl#~C3Rh4*kyG2d}54tSldJBwop%V@e}NuICyCiXD?B;$ICqbq1Ho|eTnv)X2FK? z=@IAIf&Is_(~q3mpP|PPPan_u_-;SoWVeSo*wx#5mwS1Z1gy^Vh>Ev%@ig~{+O6i) zolc5i6KhOVu%s-qG%wnIR|Sjw_sE*~A`b+7zJ9SAxq02={)7y`so}fCMBwD4i=*Ri z!EmIt(OA~yl%@`vE~_S;*dRS>B1*_UvoBVmAsi9M`(lUMQj;3HGfg1`7;A@pUp2C> zogK)|p4AzyqnVjzx@(GC?frD5s!DYGm?jYDMgX@Rsl{Q85TW1DV`9n`lyD3wkZMxOa7YX0*kf()~&{S!0$dq$qVqCKg13;03PRL zNZ@qR`R#nukQWm`1=mho^+=L8x1t{&qN0Or9~UCiqcV4eKHqRP$Jd}e$4>wc0#?fNJK&zO!`~pP9RfY~xx`QQzAOLq$u+ zh{nz-`0Qe?dM&n*2W2C-@7i`YWJQ1+BV0^@jpxXmIxxNO=|a3ffJ4dQ0@Cjae8^Od z{nwMH0|(KS%ivv|kvCB2mS@d$_eQH)yC0@0hH;ai6@68Y42&@>i!|Uv1N~zn($dyu zEKGV&V3ZA<{!AmAokRkSyOfB=>0Yr@>j~Ak)@a^(3M5?3g*-fJwFs@j{k^qfPyU-J zz*W#__i3#tm+v<3C($NxDErZ@3Dm!?*s`{^or$SbfM2X9&2VN@Pxv|8g5s*8S5ZnZ zCP7<&ueHjpy<$^CzhEW+2kTAPtM7;8fFZz3ip^d8Qa!hyA8s(cRb2;=1mDs^;8z9R z+pbMhJC8GW%vV$*EpxrUsD)!g|7T~oca1@O`@ z-Kv9f9X1D#a+IzTr&5@ZXDk(1a{y5GHSO#>x zUGw|ic(o8D1-OX3%4xx?Mq(=la2OPO^EKo`nzp@lK;~fRP$Ic7xZK~aL}4LGw)`Tf zdN!w;&gm(zq)KlwHVb6Zx&y%k<3{_e| zfX^N&yl5rdL=s9Jv(VP(0KNGXJj4v>EvQ3=t!Ht~YG)J2i!ReWgb4aj*8Y$OeIjKf zeiu1O=elae6)|BIyd`5bgFr?Yu)z79T&ffWTm~nU2S?6^E60RvJeDhS&4Ty<4h(o^ zwK7FtjZl%H*M8p85io?yCJy{Adr_F=cW9-LW;Tal;DL~-ZpQnDgE?ptbF&^zhD&pz zR0&C-EsczMPcGt3V!I;Da$urH33xFyg~)pnbbdxGeuTR>Wn_; zWE+@Ddinc2T~AAzU0=4)yygbHJpBH5`sd%D-~O*HJum0~UY<1my|&Q68NNPn|NVE# ze?B_W^L+oB`=9J${E4RL>+|8&dah*GAbNm!z`wK6bzzpj(tlW7a#mfzZ6K|$j;o(D zgg8+vP=6F6ys_6eA~(&V$jK$k%9+vl2H=_O6;YC9{We~@sJhm4zqKouIUbyqpB})R z?W#UHf+`|%o0xmb@sD>5QVjGH6HJqHGIKMa*;&9NC5J3GgE%RTk|39mAQP8}f`pie zAuc*4LQz?!KkOOICN2RrS=e80+be5owX145kNyr?P(Z}gUbxrTIhWJvr_51Vv2c}b zVs*wAO?~zHp&eql;A_v3V_!(3>x#sbVwMo(%&N0^7CxuV`THj0A$s+s8RgXYs@KuM z&4(}@zt=k6n*$U=9UAkU(@r3`=bvn}SZt;ECq;3bK&>oT8g5FNFF-nc6dy+hajl+} zy}v$^C>N6(v{jMoH>2Z@)j(BApyw^74~I<14bm4aC6`WAdt!16l7AM&xXt%|IuCY7 z(hbzvKY63~NyPvO9JJST4Xqd?f)Je4xARuVbh9tPhqSq%f7;VZ8vZcgKYb9S(lcMo zE6~j5u4t>lzYWs)kWJkw_A9`(@!v$-6|fk$S#JeWkHrA*7hBjCN7x2ixWr4B zr6qN^Idw#cSP>}j>+VOS+MtqP`ZesVjnWM%nL5cHkzni$uP3cXO$bTJw-1TU3qzaCjELw}4Q z0!6idfDvyiF;dA663Zy0PENrcrHap7@+K}4<#emfyGjNF1W!}cnW$SwFIQ|5~j_9ZicpOSQ3R1i)SJdP6l?*zmi zhI~&Ki+G$cQYi)rE0hPfkMNWz>Eqb=7>90b@5DlBNEkGtamJ@2xw(IQEay0Kgq>(N z#R0nD(irXetDS*TMO2OdnkZDI zb_4$*yc+_)T04Z_>|N|VBp_o#{-?_jFAT9q0*O=kIA!Y2S3(5;FInGk9h8wVenb&% zZ+}$*Cq3SvI?flPGKUcjT=rTG?n(T71oQm(b938CiG>9C3_Ex*xk0lBrLEcenUCpW zT2na0SF%8Bsi2SF|7d|p9g|32h<<6E~I zZ7ee5KzYQ!L;md9-@Vw+BO!yH5l%ZeR}%&jV%MulLM_C(_blnS_UVbscb zrTCa|LSG`E6lrWk%ysxIN`}PO-kABQ${o=_dqdGcS6c|tp z)PU$DYG-9;B;Y~QNGS<8(J%;*#@_<2BSy1G?_NfTM9hRW{wgNI%AG(oxBl%lZ2$^e zpfq|>62gI%)kVXBv@uG;bkUntOf-_kt2YT3^!>>7N}uJjSow4bsp(>)<3l4NgUvrA z#xPn?D}lDgixAq>NI6K5$3yve73udQy(zl|617)U%IA{oBb2cUcf)zLc94p_qv!bE z+hxk9FDqxcR)c`7>iCwm))VB62tRy!C$O{8O76Jznh)oF)ff%$Z{8{fzRj zqFyQH3*IDhDTXJ_|Fvz&cw=#*&Ph{hsp3SFBF&ce)_XlUWi{-eRc ztr1%@r3P@JYcEd@HYd7$EjB)rFT+D(-^F}UZUG{V(6HI!wk<${2UJcpH&)_ptfWL> zf9HJR|e4>;ZcTt6HP0QL|4C&Q5kJUEI&;o%Bn!3BuJbvn|{w<&QpnqkGqd&Xy7Zz{2Hyr7H81U^ZuOO1xi54AhUsvl9uU?(&7&z&%<>rQP@ zeAK-gp0Hg1P>M`j8VezKhv99u(}Rj%+b~$z`Y78Ab8q3r7v%&a$P6g_gV2g=SkT zy%NRbLlcMf@bZm5eLI1+h z<@H+mNq9zo%29!7po*T;Up??4@HC^Erw6B>8Kp1POv1xr&s@|@5TQ_l<0EYfp!x!g z9q(>l(~)uR`u*4fS#wQk=(@yFzIeu9KCElTm_Qpovzr zgO9F4l(|Bvxt6J^lD$DC+hjgz8OHsQjN>nLd71MV8%TL-T=@+aUMNz?paq^Dt65Aa zrGA16Ze8RMM^1d7xu%^5s1$aXV2_IBUQE=c0VT%u(XWv4``v4Qp=Id&=JO|Q!wsk| z4&%5JQz!ai_t>SBlXp>*_wflWP1U5L<;`+BKrWMsOMvkwHY(Kb#}`QCv9%nUR9t^Y zRu~s3G>_F>pN7rVfdo3;phX3;#!HmOzv!pz-o#2a7vZ84maA#h=J3k!w( zC`O%Q^DoL%gcJy8hmyNAz-^Jr5r<5eCn7mULwesNxoy)k*^SO3x3M)5c~^1jh?tJIwshKcw2b8e!U;;_O;)T;B#8T(XB$Q zcmojv%T7=3MeVh10ZL2nf!_Dv*=D;1Vd>)hy4L3E+V*xhL?}RxN=%1dQ&2-&BhPd8vPreCRdk4CW|R?giBq$Ugf1(ijn3@*3M-J{5Gd_3bta5jhRjjAg>; z31y$6zhLD{*af?8pFHzZk{n-w1=iW?0askXpr$}8d*Jr^f5WbR0=-Qk$IHJfc{Vz3 z^2SktOe7eL^f_vSi7{z>blS|@1)@XvCB{lIF`LWF)*f~-9!X8}sN(L}9GbsGV=YG$ z-6`9&dA5w4Ytf(4-t9aEyb$bF8>7deBBuNPQS);jmJX?NnfS?XA+s0Dg6%VM=SkqZ zmT>bqJHNfEr?at8psia)Yq7uDv2^Ju*bvJg<$AmMDLBG#GkI?aKK#X(goysxQ z?mt1_-WLt0TNxu`{#4a}RP6^PN1sM~=umIFU3a!rzAA!`S`%e3w-OE@kH6k>qYA@ksQkfER5)m_!vZoEXJVpSVR(J4# zb)yNGz;pw3wC(`+*yH(324gso6q}2wzO+jYWw*qJq@?&4PoMM0lk-#lPO75A#nIPv zr}6Hd&K7(9w1v2957@hM3j0e{jCml{vt6*8&_~}E&#=uiybTR|sZBSz&M^JR;jw95@d4e2fM(8Rx|uwedH~4XQB;UpGpi>LZ8rn z^;!|6A*!~D$ukfESDGlep3-o;D@W?Q6c4TIcEx1xlinJfXYXcr1@G^9L+X!Jq0|_4 zBWh>7^U#hc?=XDocVcq*#2nu-ljOsd2V~{)aLFVg6ERWf-(#^+ND15zzY?b_W8`U~ z5o*zXBbQ=%;``A}SuPoYVEQ&l87ug&=|qW&_re=C7G5C%2F~Upp02iTwF*aS8m*ei zxb)8{>Xqx<8t+|l*MIkTbbH(Qx!Ql-UU}E-Y=Ea+8eUA#$FQyRCn4?Ky9O}@0s{7P+)Q+3UYE`;flx+iem!|yM1)@ zZCTvdHfVz!#3ATCG<0l#U7wygzwPBV-Wridups#B_j@^9Le_u`ekQ|I@juSTBtYnN z+*v5#&G!DwuV*Jx6~oMkDV4)jtO{MiWg;&uy<3;2xb%e`g}r3W81eaqY06tdz0$cu zX-6UZ^=Jm*|C6tU7<_Lbv&0h;Wn^w1t}nn``TmVJ4K}!4ilWq z++*VT$c&~$9q`eaa9OCzbCe~srCtqwwbzPmtrIG25?2&77ObntY{23cRoFir$dmuW zT3KkubAN>o`NRwlKk#z}ft?g)+}JoG=s<=S|X}q{*bo4fdV56ZjFf zT~_y7lZL*95B;(J=WLp|=G)5E|0LmloB+=(H)Rb$0opN#E~E?Y;Vl1wr*&J{I_u{(35Ks(y^U%pWsG(!b33iMDxYG`Qo zM9hIEs~KlPM8SrWVSXuJ`M5U?FZ^q6p8!wY?{&cs2H@QI6bfRpzMc9v9J;tkRG}o7 z2qrbhLq+(?a+13C@Wl>@wY#}s0+dn`1SL!nfwV>;T)q&2TT={2ay7G`v?OE)*wiXM z`Qx6w)Q%b&9A63JxIO24YvNK^Nw5zs0)zz}TYN!TpZNY2HEzt4Gc?S3djzXZBt^B- z@zI?t*M?FMb6}>pg%sLhttC#?wWIji$tx5#HmCEu9@gU2Ot&qLTG*#d+Q>MqD0__zXuh6^4{c`+4P7n^ZW# zgEd)?JKBNl<$v;=OqHV}bZ*(6?qC%m6cFHu2JFxf$B$NO`f?sh`k@>g#^mIn6x6F- zt!}j+JAl+vbT*e#DRTVWDZOv*INKRSh~PoeZ3RV(ne_zTxQ1U7JJpKSjUod_GC7Oth-;>qfrsft>MghVZG! zib(imLMa!XHTx6C?%l{VxU7ogRLjS;j>!25Ux;=x%e{{`3lR5mCkh#Te+s*83AITx z_pp6M{W%|uaqRsYb%xK9Dx>*kV&!^%sn1Terms9(;CQ$%nD?mky|}i*DNI?|Vj=d_ z1EAz z7H@lwv%S9rqbV`axii;HXcUee?OLmE^iIevr5SzuNU4SFL zBuLmIl;U9=9NpZ39TdG!4pXzoD}F|SO^C*(DGoKG2Z*^+s?dkuqv8SS-bXmuDqmsJ2)dW`D7-Z2Rj!-$Oku0R=T*0hXnmQfTRlf-d-~^kN&c7 z?958Hz7@n*%c);R1N4oe3EeU`*ERQpHY81ASYahnv_`|P|@I$uk%i4xtzQFaB8YTEm9ftdo(t2>3%}P zQIzt~GcH@)o+9JZ6Y2v#THI(d963?bfMq;ldNSPo@QMdQ?)&6uQarj~V9oWMRn%VI zUwyl_<-gcz{?Xmj$Hns2%)#Hw&&SCes5J#@MxzJ_6;Yq*UEg?o6yyYX?z{(=aknEt zx94Kn!EJ9|R1`%(QOcTdpbIoj-V5^tUb+O{d_2wk{4Ic05+x=_ON0?>kpmvIu4fHm zq>>&}}ofVk-qt^2j#d%b13~l%I34zX=w~R=)?V=(uk?1Do5{S%0IU z5|KUuQL^D(AeJbl+uG2c|E?X`c_N2A!gQTayi|seypIH(k^VcR7aQ-ge-Ocps4O`G zp>OD?86G>6x~^v8%tG3)MLp5RUxESOv}zp*2ysw=&ZKd*@n3B3IyWqv^A{4%m-9=z zk;uOM+y*QTeda%$maqceqdva3)^i#Zp8OGOc1iwo3-6pEKtpQyHa@&X)R&mNoduN9 zBrnKGR!pSaHdHn`F>e+ysd991s7vAE^}U-5BMAyVd@|4maXph!?ZBX3<^ zI~?2lakM{JbWnMHzi@rBVxI{F4-I|zdtZQYgKQ3^4Vj`xT*bm=%YVUWG2|R0-E{i-JWH~u5 z2{nI=$j2fdma?|$Lzj`Iwn3}q-=A^&G$qPoOO+)(YA3F3lij!W&S?>++He@^nW2cZxeJv<56y< zi{FUzVvNdqBK-9=H7~ecXXJxV9Y}d2LR!odsKqD~4h&oQ5mec?Nw6cZc%BR*h&g1h zmZU?P0{T~k&5y{|PN~T}0gU0FmG8Mw36L$upue2%3F_KROWxJ^OEyZK_Wv}K* zntB3tfHE6A#A7WpZ*IZZ2N8lE!+(h}e@;mREEv#0Kyw$ZPqVHMZx#Rs_0i$Ed8f&d z)5j~ll|hT(wx|~&#Alkxl{+s@2u@UeF-$qjBjur$0xXzh@zcnlf0<#L;D@=)ZQP%vy3&WQU=a&zq^+)_D>O0^Yn%~sT9{rz#S(&x z>Lv>%LxgQaTbs=aAqk;h*1g3Gg7Xjj<_7%~AkQHz-~zuumfaK=EXa?;T^Gx#Khm3= z-h>(G%8#h}1*5@#xKcRHhvmKDLLl<|O8(~)DgO2v>R;N|RZWK_6zxyr#~p5C$BC*H z_MK;Iv# zg{InX`6dmJyK-2~hH{AJO7F~;Ig+DohK@61APKng@QdjtXA!0AWPn%VwHCA2CG?9k z9S)6-$$#p8KdE5B@tFT%ObRzAm7nt^-3<=EGH8y{=O-NES4_0^?`cRA&V+=bvGii6 zvZQmEQv>)q&&T}wPt2T;yq43AO)f`Lr8$*Vj>_Duh`%tBRFTDOaD!QevWIMH^$Dqy zILs2hP=X>FhPW*HQ&TlJ$G^G}B~ix)6lC!+#vYGE+DH>q5wApTo>M+K_jz8- zn^GNxZ<^-(s6aXX?4_pnH|90W2IXz!e_g<%=1))9Lt1ok1M%l+Zy-o&Xt`WOGkbi@ z>09$f6NZhL@WpCID1SH*F`E0x)&u)Q-`uVi8Av*Z8mNnRZWL!#bLkOJ#=ZGz&MQvbAkYm-g9=ke^6n0`;d~Twp*PY1Y0}_qVl1x7$RBo-oFRP7GD4dZp zXl65==Gh~3qjC$apUi#S`*>w6rYi`c1!lS5bmEDt#%UbT9o8 zJWzCrn;+R(d?{GcQ3JD4%;Iasd z*6+jrZajgWP`iikTpJo^ABdiwfLHKb346@AR@X`JskCua6m&Qhh7*I6(SO``LL{xG z)kcxB-g;A+5`$ZVLEa~|hf=X=v}xKyj6lR5h)#L#K#^jX*25J;4!j9khrhLJ7W(W; z3AUF~u4~3Rdt6Tn!$!u}eb6&R8;*|n$JqJY*;VF_GW#8jhN{TRmU{pG#+nTb{`JBBqV@U|UGPZSn)G|1*%omm@-rohhjXOrdcREJwxzYJohI4gs6o1Nnf!$} z^5{bt9O!Uw&tOqU#8VI%%QH+*r>FW44D;?Y984{)V5X+{kQxc(j(ChQuwJqAl&M{k zZLr?gpWym1*y&?q;=BLzi+BCgk;gwK3!Ak;lDuAvq*@zm8Y;rIpf$JMq!J) zE#H#u_Q@ZY*CV`$b|T2H;Sh)jXi#XS)q=4`DH!e^bA^!#sSC*k925Qxe(x8m4Q=!f zsUyKMclh1fM)H6q)RB23&*Z86AYWHDny-bIJ&Fxn+h2*zh{8q+3b8>av=T>sGW&dy z#x{YhqQM4LSiP)!!*MqqxGD3&7JE=^FK9@<@YtRkR}4KtEic%-FFI>FS#^c6-9=mD z41xFtJ);2=0oI#}a-5cH|HdkN$mtN?qGxc5t5)0vHfZ*&v*z>H z(uSY28C?rgiY5v&1B7*r{4gciF}wUC!rFm;eKmaO_Irg>4mRUUS>nZ1bu_EQmozy^ zA%?(d^u$lfTYR70zJ8K+*7CIeV5Az>-chHxTAD4T1P@+?fu)$t3&LtoD7lYT2EAwCEV8&7rjYf#z% z5znfzXcV~;;}c+Dm3X~-(dUrK-pU!f57;EwnvMg!NFtzc9FRZIX>=~wWH@Wg@g@IU(aJAB%yA_Bc@oM_>AaWpV`vQ{qxp7n z<kK<6qRATC z%7XDzR&Sc)zL^kHj#ri68RE!3SL^66Sy@V0#u2R@fv)%4}KDJOp`QgesobEq%$D0WIQNf;BMw^)t zZP<}`xqHL!y9Rt!HoofkX?#|9_y6J)ljoOF6!LI{rY8NRH74`uK4r>V)$&FC^h;6g zy~o-8A_X-Gh-Ru}Yny-L(m`<=K+zUl9@_}}MsHY)J;}%)(Js#A$AN4F&kRQx<5$v;cgfB>E>2 zR8YoY7-9uZ`1W$on(*>_O5A5}Wj1{?w3MhAV*&jr9!4vXd=w7S>Ryi}+uA&q?@22I zX=s)>`zEvB(xldaoB5-uNMj8typtz3m(U%Vy^ic6ReOC(b$`iHaCvw^!DDpiaQN>2 zRHUwx{#{z9SN6Qi?d+IisN?i0bjq{_9+RM*aBhS;$tgCFXMBjL5r`1j5W2g8%}F(<-hb4!xdCj`Zy>}dUb^8pLd+BEC?kE( zb#{kQSZ&%LvYaI)f4P$CE$xRtEpRpwi5S>vYHrb|M~>Pid8V=A=PuZGB2U{BN|lmqqg5f~aEdW(kZ(B!lQKD`<}IXluWT zX(p{&;#48JkECmZ)`5ByTO21|9y5;xydv{%cr=ojs9+6ftu`j-rgyo1lQg^O+mr#n%YzLXKH@I0Af7xQ-vWwiPeD3Qfr1U9Pxz`>{vpmLBzSL38F3bhdq;94jX z^_61K_iPv=i1%1@pnsMYiT?+D-7g4_8#sg(}`P>uwY$hJXSz>-K+lYl}?6?m-!4%44yN zogr(dv9-*GCK&HL=^cng+18@vTWGTf^fN^0z`^5+s<5LR4T29A_k(2!9hY*#v5-vD zb1&k29s>Hh{RK3Fccj=)$A(wtZaX~6lqEDtWP@h)q?n)>YNdQ+yaxNPo^b8o*Ht(i zDW*mizik>N{ohMfFQ3cqgUYerw=2zM>Ne-l90L$EQg(JDnTmPu2NO`vWRdGfgT9d& z3FoYxh1E-^0JFcnp(u1r_W+)q`Q z7F0b@xzA~}MR8<4VdJhZIB#=YTf-??Y?Kd><|v`Y?S~>x!s>iaz#;%IczyZXUx$&~ z_+bZf%6K#=I-R@$1o$(prd7xgpN z^1ibYL0`B+P;6ZLXDd4<1UyyHj?w^Af^x#B-K>E@-71+Y^6F^YgYp~5m_G(Y)E+RC z!2v)%pGq;;kwK{zb2<3DdNFsr$1o{t@B2@d{q4fORv#qlD&Jz0)cxRkujejr@WD{a zqc%--r#{kHC~CYQ#F~2(45xnYh@Ttwh=?Axd(QenMfan$rsFd3}P*2Dyy$fT2lwR%vb8ut4{#(7e1uy>A+YlraFoxdy2ECzP1U3!KVl%nZz z&GWP0v`V5&%KBFh9KArQ5b_j*!(qCyF)j)&rF@3rgxWH&O{kk7} zGsTo8#y?V`R`7X^H~>O|7+xwF*))}rjyly5rmlGOYkl0L2zY9mLl+zGAG7qwf(A@- z4q1ZX?y>>ukUFnVMynDA2Kl2?hpdqw?Y2BAl-gfV$q6z>7ZpFK5M&K=rUgTqJ5*v= zqL;ne?P6O5OC;?oDJK;&ooSmi$_p(wOdcn>sT; zeQWJgD$BCFlgyXNoz9e=_o?Tcqg|&C?7jAS_aX@w16M>qSNJJHVX9D&DiKITESZFn zpChWPOmAz>?dmNYGdE7$9-ZGa&3n!Be)Fu?2tsGUYeQw|aJmJM;M(8AoxORcV z7$$c#Q`=+H+hdcDMx76aZMXZ)OWoS(juCsS(%90cZS7XIw~chy4G)(MYx2fy>AD4( z=`P>8L$|munD)<5Q7gz>KxcYymz@vS zC%5m;cs7^z9xiMwJDsK>MQ2TQX--CpP{gJQ7>T^3_@ww7OjazHn+S4ZH~OSSrOd=QDW545FeD)9~Y`(t1P*pnAlG)WS z>a{1RA7SVn3L;Emh8iU18}OXb9-PtcVJ#{B6ZZJyY5&jb)_#0h>%KN}|3cTy#d>XA zSvxa5o6e-s;;zx-uO`J^r6=B`VF*52T@vH2Flg6#>;##ZotYsnDbA{?DXyBrOfD%|+_4B+*%I;F8{-WO>LRa@`snZBw-kS{u#FeLzt#vea#! zbem?;A;JoBX@N}WNA-k5pb9~vQcbfvCWWqv%Zp=U4W)QC{U(zU%cRE$8L=^mu`#)~ zZfGB#(qL&<==qU)=={6k`p!Q%go#QR--!sF`=kN0T@gLtgDB1ZMUDTm=3&ghOhQ#R zCp}xp=g5Q{Br0M-iij(h@MTiIRLYi$=vgV8isIzP`s}v$!X9Ore!P3;fflm~`^+F> zLI|lZ)Uqoz4Fis;KJP?`w;WF10Cb8t3j7tqixxbHMfh}EXZD91 z?oQQUgIZmo)@7?~X=CGZ-J;0+kYRUUvv|)KFw`D_0V*IU10;KCSW5mg0A@R&@t}8} z(d=E+Jj!kE5=%KU%wHr(NtWd0r52WE7FOmKSLavM-lB*~(-GINzMguFQ8{$|GN^ih zs95!iamy$WmH7$H;*@S}&UkOt`CxtW?#lSmjM;7&RCLys7iXqQIRbV9ix$mDjONi3 z1k6NsQVi&eOipwX>js}2o1Drlsz`3=DelqNt7dxbceUf&hFLGhJOl@Y$GV6<6~I!Q zi(Ukb!4*({fw4e?fO9bbnsE?I`;*H@76=ac$@t3t>v0+yxnv|B(<*{?7#QjB({pa5 zD8%o}HnU^2t_=-Xo0|HIYnpQ^t1}DprE&?A$EFdAl0aBWJe4;Y?L|p(OxjI8D=GC> z1-(O`pnZ5!yM0<8IHCREgfI^QjbC)!uUaiHbB_a&Pl^#X}>jz z&E#=`s4yi0nn*|!i5M~oN5rOA)@M2I3{UUsfR-$v_Z_b6z%xH8^f2-VqlVyLAV2N% z9`l^XFy}Tdy3O_n!|LTu?P7bMsk(NkHcO#M>NcLydQM=jWX${lLMck)JE03iX@2rA z7-9SY!~S^E|Le-zKR>D4JFWJh)Y1pesrN4RtzB<4MOP{4*|!qe?3lRd_?WA-*vky` z2fLmGye0lR!8LeCQVA<7OIlo<2`mLrTAo{6LJ^hpoZQq9QAtaeQNi>;RK!x=F`@z# z1wP~BZziyBocKF#D2X>N=9Efqrp z9VW%_c&Bw;IlZHuahph#8pf2)`*Ce?nPj?K#EQ>vENG&_fO9&@5a_YaZ5zf{NAgNU zNwn)6W)h2$z`??=aU8}?R>F;I;`F%w@%Qx}j15D{0G&J#p@+U5o`Qd12osesz6XzC zK8wOB4Wq` za-`MeSq*i$Z5@UEqgAGvp4m+uh$M=fpu-6$OhLMWOM@`=hE$(-f|{N~yQnt3`Cqcc z_*$-^;zhX7g5SOnaLi-C4CYOqcbkb&^i=Cxkqe1~qTUF~ccv$QDn?fu%WUX8L{qpsJPZfR|~ zI!Cf$MQYp-ST^a_y&D$)SrZ1dgk8V>32;V(-v1H${Rqutq49Q3$t_UPBvQ6W!cI*U zG4;Dg80G`d09hNO@Cp(xzV^h zF#bR{vui?5;K418J{5$FvLQpT*yh|i)1PE zesi_s;m9nh+`s6zqJb;m#8-1@&cY{;Y2Td-KxROqq9qxmnHA)leRIsRp_tuP&+ckX z8v}jL)+S9umZG25qrNh_hUuG0eHb*GYaT}$F|3Yb4>*0ltXTUcNxkih09u8C1kh&x zl*V&Ty>m&ie!0<AAKn??m8nehMpKrxXM)kBk3lO6EBgmGqa_I z#TmsVS;Zwe1%;U**RRa1l=KWaa1|<;{+&c6F7|3tVhsFB^z{o@uiZSa-2_DwuIJcO z4dZ__bpH4CzMCcwM}J>3V9QqywrcwnCYUrkUH3+(cl1E27d*gHU?R3IV!C1568b@4 zXe&w~FUXTO#R#%-j6sEdKD}+6x~FdI&tr41uo$siCY=Kmg-&BI5}8RiVxzAoRw>VF zJ?{m*F5OW;ne>4Z`rXrNv?w0G)G+=BBTQ7n_#R*(5t8f@6KjH67HPo5H8AW?YJC@U zJF&ydj8-kZxSlJQibX7`geR8^Wnyl+jGu~CR+us=J3WP0lrOI;Pj76@?&vC1j8z%u zd#4_%7x#>dUJI}Vj0E+7^a1i<1D$F008}4TfM6zGI+#GuY|xV^eF%eb-ykz@6BPnz z^E%X?%740mGd)h{=;h+KE%?yCg}As_K@=`~oy#8U3aC(&8t1bucyY1^F-l<;K0%)1 zf+Z+hwmA=$o1AtVXWaUkUETB})#UwA>+M0yvcfdqr=RN6jQ5N0{a zHtCIJ23M|eKE=2uvfbm`AF}M*G`sh@IdGPw6NRUJlB6(>!r~8Td%=|R_!i(G9yyH( zkr5|@arrn6pluyp-VZQH#sKPt=LD!A22g{LTVjeK5=~3_o{^JaP$QK(nbPM@XhCJ6 zazp|P1v+{NXhU#V8t)Cj*@&l2055=;^+8`m`#zqQB4hAf4CMGLSs6LQyK@ZS(?F^W zeGh&MgMR>2+Gw(k7eEp~W6vb&7hX2jIVMH-Fe@~=+hGm^?8Br$II!+W-4g_QCJ^TY zQW518)*Z*YLlCz;{1)CCULJLY_%QX}_pw@e;9p3lgffjIr>Hv7t82aJzo2!>sLb7!{M2 zu^F3c+CFB{^(lMnYpV(}(!?SzjmL;#Cq;7@aeM};dBUKvlj2ycSfMB}Et_3hpV~26 zVO&r+?y6mzx>>h%k+=q7<_R1QPOP^G8-z&Uh(K>5LP&yxg)|0QvX5VZ#xShb_F;U% zu!7+h9FL)ah9Lg}4nrcmu_6irGzrj^_w9=Tu9`+^7`sIZ_hFwwXe_!Y34~+9Na=6SSv5+ei zu_ZzV5S3(+SSnx^m&r$*P3C*bDW7Q`A1`KQUBm^Q>E*x_yoEL5l7}oY;93m07SOJO zi#78;yK_rrx!XUxtA}?z@3T#9YsVjss21AVEls7W4zXe|R_VN;**s~00pdQbe*&^X z=sH+F_<7~p&rfOg--FA;07ZPZC$;WK4Hkxv)IN?<`!B0DuPbM1&7*?iYL1Xgi@O?s z<62_ejU?JlIvqZ5(BiKp#a^SwTxTW4@`XwAG;U_LB)=d%AA{+UrDSBvF`hnMCQlVh zU% z278R1BhD7{O5fy`YSyiv_n3i}0I6OeWejlr0%l>f?xNilM9PwXd==|2IhXdGFexv3 zV0LybcpdX@%<3__r?;;5_KlXyrSYu9D_m9roe|H(3b61Qn-G63A-(YG(CUx$k0~iZ zT(a>_u@n!^BcU4);|G8+Q3>O31IRCQUeX_&(D}~l_O5I0#||zgH4SqL%XwmHijbWw zPD)MYB#U|JQcpq|?`VD1{fV-9_+ zZRpER^rzK*n1-i_+X<>fxPnO3Bcezhs!HDrinN4Qd4v8f!QIy@G$;qjI!$O7YtNyA z6?9Ic%s%f#s0}vrT4#Mgx=b@}{oifj+Kj%}{P&9r)AO`fY}|5+0}aBn0CR@B3MQOHs- z7&ih}j8dgMRBNd-$~gY1Sj`;i)qPd%XvoFHdF z1`;9+L_wz}C!)az5#k9rIT5x%eh#Jhb10HS z;glvo0VfzTFH#`))4*(IE!|ln*V3BgTwSylS9-lM$%9ZNe!hEreBavYK?vyl9 zUVeIUNmgljZh2*XMOA)nT~T9WNlQyvXL}WzQ@ZMasGvnkG`KXLyhC`4iI5aR* zhz65Dp3wV#Wb}h3ddaYVbL>HEkDcAnBgo94)01e?*Ak;IGU6|C60UH7tHfQW#a>Q` zyF^R8%H|{p#q5lX-_s+EQlS2IG8JXMb^e8k>=DotqTLVZ<@n@eFn> zKj|hT=2~=0ZrtGXS@qTl1Kh>2I}PK9hcHnI<8Q+mt^c$Jy#n4tD=KKTduI)Mmo*RK z24-3HL&Dr!93D#|Vx>s=X)=Ez?t8*axk?Vm|XEstOX|40se_qn1f*^KA3!m7s|VigtjT|iV}Z^ zcuiD}0$Iu-Q3(l9YU8L>4ecHU5*6fH2x5@IFo1bZ{4vkC=4NeX#oN9&Bq#SqjI44@{)3vUp630rq^|sKu%eT2XHXq&Q zkFf@>SOVuw{wN)$MMh;>=RZx>_<>pg@dcT{Pg}w5@x50!SLUTHuNojDc(BK(uY2 z?%e0t0&JU4Y~QZxQ0C_6$>eOYn86nVQ4s@CxmB7CM5Ustpt35zuD+xOz}%d+IPukND>aXB!x?4J(xd;m2x07q3=0PG%X5JKp?!3IZ0(_2Fc zn7?(|Z(D{zf$O051&?FV>zZ>1y}TB@=&iKqw@mG5Eo%cxYklj;t%mOWs>bZnip;ER ziCDtqa^ks63_;PHhF|Lr2T2sgF)Fo-|oiTyT!50Ty<+OGe zRI7h602O~jI{g3vqs)DA>=Ca)A>-4f?0C6|BNrvfgb6Y+OD1GxC3EYlQ^E%&k{Z{PIG`o3JnqLZRGy3}3xSQva60Xpa(7M7%xIv4#nizA9k$96u zj}gJtkSfZ|l4oTnqeUe}B#~o`3J?`8kHO*6X-Tm$vFQ4hm=r@#x*3yjV&301pbR!5~50hLHoymQ~JOegZGBn$29HoHEXFO z_A<4)!>Al`^r&b0%y&knc69S@-MmM?=(WswY%`d#1E)xsN#C4#Q7DBvFt8MmC(GXP zC68+<;F|YiP5MPYuoRPFuBWN1Fe6RKO^W3uMRS=+OnM@RmB?enKz~b+M)2s2kzsmheSfiIA2o zW2MTu>B)i=8DB2t$)udLWI<7eyrL+ht}45!Ik&sNctBHWnd%t7ubSC5fXcN(3Z$UR zbpTflfu8_TSqmJxczp*^!Hpx6G~j3h0Cn(C!Fne@0qrfAst7LN2(S&sPq3Eu;kS;` z72GDNlQ23tsIl4SK`n5@e;sok`y4PCAJ&tZ_L!#JhPfTX%(iZ7OFQvMJ^oPbxIb#% z7&NacEGtU;>X2<|$h2DcGJeaZmuN6GV`T zAc7?1@Ca@M4+bep&~gHxe&z7Df;b0pfbZ}N?H-YN zC)xNQ$9$*Iyj*5ktg*~Cm?m3n(=GO?rn=_*{9I`=UnF4)#H>_8R49uI7L=&YudT~( zY`WFjTGrlL(b-eV4}WN*ep+LcV|l@0y;<7fXa( ziG(c?v(nP|6*cMIBe#qzJ#%iuk{>9CnFJa+=Sf+Wqc1eLy+1Lzt+Sv-#k}Yn#{eW? zj9#E9_Bjv8DCex(J`1nRXPt2yCmyMci~R$(#(qtOLRnVQ*TYfT6Gm39X&#)z0-6UW zw2$9Ief04u-4ht6AWtH+?nulS2Co3EvM?C0==NfV);S$kad`_mIpG4c9EX8cWd zQXC^OnjU|RMZ2C5eIX$Mv&RZWtmI@NmH11RED}pXM1_$=kBg4EibRDTL!;e{iM@XP z#`%j^FVP2PBeWP5ee3~-@pl0T(i12HK{monfe9*6bfZ1ZOG*?RL}?zQIhweHMd1KM z-g4IX_@*VmG(8gPR&!M2H9AwfRikhz)pPx}jp4~HJ;(&oj2|MxL?w*B4XDc+0#Tspl0|o-O5*HwO5;DH z4qP-GT-H8{Q%nN2;Gi3~Uv_8l-2!!&#t&O7n_PG3Qd>d1<3=t!u+cM+A9eZo?X zv^)fIm}8A8gpP!kg%(KNXasRHqp*yb;LV^A%=zrIJ{z&OSm(TsS)XIh=a}`{@wnGE zwP%|2m?pgNX`I;Ak3Uj7H^&?gMy+=TEgMSf#-L+k*ts_1Tpe|+s4UBB<&v&{(V$$g z4lPcMEiI@PR#bCq1M_zjv$uO^Znw`pD4BR5w{LRnyL9_5)8S>>eMvSi%^HZc_-~qh z*DMED%m-Hu2k^emka{msM9W9h1hKB5_HQ@ZsxfK#8Zc#26Xa>2TUrm`41PBd zL9O^=*4F@B1CTLLWYIy(@R+zz^>@9hvEFx@9BZvR-CcuC>fJo9Ejt^WC=j9{XIsV|vgvF*<1t;?7O3VI_6h4(`@4o1CGpgQw^8NneC95QcocnUC%ND&XmtjBs-J(}nuUh~AZ z&USlPJ=xi-t7#i7t!vM&ZOE;x%F4}=i-l}HmqE%n#tC>d5sxn6u|!-Zmz@MkDuW&` z5D8K%>Udq|Sk>K&diN=fH&Ww^(0C(sJ5fN4)r9LpFVcBXVFJ7*qkc$$A)`GwWpqax z_O7TNNn6ImLLr+&6Y|7TF<&NOO2sUhl%JNuuPjULP!Fp;-kYD2rKJC03?!e&TCxsSeN!3 zbAHo=4|5()-Pc$b6hqFg4oh#n8aQ#EM4?I?Ubv>&xuDrUtJ!}ahzbJTCSlN>#_lYrp96m@OjQ1RfHE20 zCYqgnKvZC4;UW~S@_2}xhuPr?DGLTIy_XDu7^8=6xRS-g8FJRn>NaVujZ*$}Os6OQk{{ zElo_%PUfb|c*&_ec``>X=OoKmxtXG}qSV@otmdYSp1$HiO|{C_Y+mS_c%YizHY|du z+IKDPyOs`Iu(ABQoo`hOs1v?!Ocs79>k4Ic z!Ch)KD7YKk##(H_*!|b>FsrcPaV>f$7JU;7Bo=T1$O(q!SQq@jTQJii6}!La2U%=e z1no0mU&K&;be@@Wn`ie7v%7}b9sSIfcIuIO@{!v0Xw>?k-+I5_eor}mZ+Pn7*z_IM z^oDBs&e-(E=;Uqn_=aA;s#h)g!9h3Nu7NRAgMCl%1X_Ehx+^ zE*`Tj8#C*0`zy`*`qQ}x1u_75O zzdWt6zjV~u;ehwMYeEYK@sI?fg2}kO+V9H;pM*YdtII4-_}sC@r_ z0T@+JSx!iw$F7dXli+v(JtJgD1TA^S>zoCyf~)%&qcZC@j&G`ss|ux~sbj37rN5-H zy{M)xx1=~VJ3}JJQie=Eiz(zX1sq^y@nSwr$fa|bbY?;{EB=}|S(04a#_zGksqSCY zx=(7bM*%@SsR3Gh@FU$m26JmZIIVpQ6f^=jjKNK)>~R_O0|KsO6Z>SOeh=>GhHgjJ zZ4jmAvXX8}`Lt9iJ6Xg^7II`FPMTbBt60|3RWR&maXwVf2Qc^1qT9CQK~Y1=D@uZq zmpC!8tp}pA;0M7niJ9C24m=Ls2HsS_hQbHlmmgl=1ZF$LWJ=EYJ$Rv(>0SNQrq;5q z)J=B{*<0I8?d7B0S$!iCrIj(Z60O<1X7XJy9l)SIp+{Q~49t@-Fx4$tcg*f&A!}xn4N`ppq%7RTT7y*Ekl0_@u`^LSi=B+r> zeTHE@Pd{E`GPm1QgA+>KT%UDqz#9dQ5;fBGq1)FCySB)WH9b-)0y;M1+Y-7=If;eMHY0f(V?ed?yUB@iO<-kXE#^bRAZq=6-th&YTi0V^eio0iH;mT{A1yc8KXO~%d5 z6qn>nt1B{FS_->+OBKVF!-jhOc*pqq=**^m!EIdu`E@Y9j9dk?b5e3GsB#^OxsdLK zlkp)%IRzd!!IJQer#@fczS9GF+DIfKQK9`7@zHRPIcMFJ1ow2 zv!mHO+hm@uH%(TXCvTajvQ0B7#+elTLW*vQLaJ^#6+f4fb&JWmg=F25T(>0CF3B{2 zWw~}4c7xC^$|z_S$o4`AunS05&!=b>Q?&D`nz=O1T!wZoOFv&?a#b1~RVI6#*L>W^=2_+-WvRH-wk`}g=7t;# z!;Yn4`^u1YZOFW?G_EW38w%5g!gg=a@nG2baLn~cGrpyF?Tk$9jZN-qCUr{CcQqh=I7mw<1nnn>8zG{C*mo`j zFi?Ja+vvPArk?8T(N}d0S2XwDs%tH%tjQ@XNX|?VCJWdi4wJ`B60(^hE=!Ci6o!D4 z$YCbZ5@YD}Sb0`@YC|Wx#~!PCcoAd>2J8YGc@l~Kdj}--@ctRilT&J3$Umv}1BU^| z8-YybxPbb>;S_OJJgo~v7(krvMVmalURP#uF_TRfa%rg&cCwTQT}v$Hz(#(ayt+BN z-%w>)>z~^-E+dT$$|wqyECL=ov4unhILUHg3Yaa3IP|O8pIY9ZUfG{o@=svk3do;* z`?4Eki+y3&I_);i2K4hD?QB3d;j>Qe7-t@hPTcJ`EVd80S~?xA^@i5Uk*>ngQR#@8 zrJiIN?$AuTH%)=F+JloC&}(5LAep0xc}_SD8sS>4`v8 zkgFslTn8j3-e52jxI6}5z=BUYBaVuszZrieI_64D^o`iK^Jg!e<1~$()R39wtp^y! z-vK&whybE;3Zv)Ia&kuJKBwQiVhY3>b~u{b>6+;ZlcmkB>l+^%n;S4J4cazFCO6eE z`z*Lk3vSD@$F}0JVaR&`7cUlIBJ(;{ytbu)btwSS114DvH381NasXX1ByC<#pzswC zGSE>$$jp1JLHXme&2AY?Gm4JBvZCT-xrEJUC2<)Ed{zRRk;voFIf-$xG}iUf-ixDm zfLXnV9=r!9_3rmHo->9IPH8;H?=*}bAi_i?jK2+$S~o7V5a<3=WC11$3%(+0fw}fH z(v`<2aAh6lE%ynSvGo3Py4~xe>+$VoW_33^w?x8ClnEFqVs5%zl$I<^N#@Iw*(p+f zcABI(Kc%`XqoqEtyS-?juWV?nQf+Cp%=L}mRe>fn2f_=jAFq|FV+iSII90zne}_P1 zA~F-Sq8uSA6#v09962F%9{4u?Kc@0{^q89`) zk{K#p64Ds5dcBCn!inoYE}s*k;5LFz*2%{=<}!dD2}@nQiisum4~?|X?6#%-S2 zGtRmVvwQmS9nI99eriWMv8|fk(N1owTw5cq?J?)(sO$c)o$JE@*X%Y6v?&nQ{sDwSX;CUI0}As%pHbOo9PBp@E6l_Frh$GF>?(T=3P7I` z(QD}MGXfO-CWXSRgs;FC`z*Z*YoF5AJ7nt}w)c)X`_!&}J^b!ek7=qGFJrdPJkx8Q z=`qdpn&$h>3vh+K)`dRDiqf^Fw5|3V*Ly6tm9~u``^K>S?x5qo(z&U`=dd*jk7W`b z(XMWKS3kXHnA$VW>>1|VhIxR;Fy}GOVw!)fr8Bc@n#HPQ=4rQK+O41S7-rn2mB6HN zO;O)ak()11mhgpQ9w91nAS$;?vVo{n)fCm#7S`1jS3r}zloT0H#HWjR4A9g;Uq=X7 z0xnuncsyE8PI7g3{(z&&u`xEgW5O=9KfVB;m`9ij6q#H(n4qwHFoDKK=ym873Eh5b zB{03Z4|oSl35^THVV6F1>v|JMh{{n~6556h_!sHwjv0^6@o-o@->I}U^k}Nvl%;i@ z`E~UL@f% zS;)^Dyo|xcYUX72rl$3CdLA!xvA`@=_O98GDpKv3Q76WvJ!$qP( zW&OGyf9YoQ<>;H&dT{F08x(71tVzI_mr!iF+ z^$m7i=cHzEZbZ8@WWPN+^*}wlt((R9#f%Q0n6KZw=rO}2g_993s{tHjRS~B-BA{?R zWesH1KIVQ{J~B-MLXw9>zTp`{5@vSKY+oB0Hn-L`<>qAarTioTI}xkFGUIscL^>@t zKJI3cJUO;;=={h%T+~1ZU^0|IMCjb;`m6o%TX!194-a9Y62{*KViERXVi5G?!G%Cn z8sH#CZ~M@t2V<0}sAUfvBnB4WX}$M?-hEm9@Umio-7v^4Yv5&M@r4|@n3<8nPfHP| zCyUeMf>bFxOU^FJ5>*r>*H>n>HsrN;756AhhsUbarUuhY_xPQ$=|}o`x0Q0o4;m}{ zAo5--WGMSUJ`#TwUkCs0zuXNUqJNI47K1wMn+g{u03Kh8Oyxagbu`R>)bBz8PhbtZ3G>is%1I7s|8jw zxs90G(M_TOK{c_ZhR^XwD%Ybi0BpN9$6OCbfa;9j8y>$q?7BPZxHIb57;q;wNO<`G6Sl3`<0I@n?Sy5V6U}F$qURIiy6(+z^KLKu=m;22t3JYKrzhzq+1Xynm z+HMcp?+iHscZc8?C+?3-JQyQTO+HjjZmOpqX{NR`lUs1vI&#_6^=l^qyV^;&9*f?f z4azjP2gC#DhjG?x2F&3DfyZKli54(VC7#CA{=|L-4LoK%f|;z~Uqoy7S?7J01s_Ib zECnX?EBzJqmD$1%M0?dQd8st5uYhSLkeBWXGwTW zIbR^*aXD-nn;(}|EN&hs*G?)X9%|=37IbkWL0QmG(0&5l`>=<@ajZ22eeYlrxC*iq zG8Rw)4dMtwA-;>y#zk}v!c2S^2s;b+jyH~3B0#TQ z3fO?l+3yeOmb?3H^IVc88$j+FMQZ?)%b{H&5T*A=YaR*O4QU0pgq$Q1FG(h0%Vh$& zn3pVIWXM?gg`(=_jJ~mA<6;MNbt+bV(d}GzJ6GMnI-IU;jphEpydSzKO1V|^4+O~r z9Rsr|QieF_74XJkdl97&N>P02z*s!viykXT3CuB#L&l60ea|xEwoL9B$G7#ahiVw$ zhQ%)Jbm!1ycl$(7t4&d78Z9+iG7VEg^@2dZ!8C2fSUm5W(9C{Gzn3^L!)zGfaM|%U zud)(uuoG`E60avE-bhNgnV5JJxJrEd^(1;sA}u;00g1|un-_0JUx<#mb~EmB)YU7( ziWZV57nmx%ba-MUh#yzPpr3ew&;nrg0d193s62R2_n0Jlf&PPz%9IPhDSY6s-BUv# z&Zy|}1UO6w@*33f5XdwKz-`h?3EnjIP(OztubBMkVQNHj1lt^bk<60t(n%_L;uZ@( zih+OdFOt$dc+J$mG*~H!M7>izANmZY5+ZiM8QjgCx3yNoRq~J1}7_V`2}S_!a}MC6qr%jMscHOJes!Yw$iaAP%MI zQ;O^wdN0XCgEKWQ@aaYJ~NKbO5}5Bd}a(S;c6U<$0%${ z=%2b^*up?5;yDb@4c<7ucT!a>67%@KelKDCKoKS?Vf;gJTJO88bzd94ozU%I*YyjF zN<_&*iIAQm<)o!>(}1)j^V8(~^b}!6x}+paT2_))S)J9=kl)o()TbyL7_AsF)){8I zT=zyN9%-iC7LbDo)Vgpnm{QQONHa#RgHDMR9x1&a+8}iSwT!5wgtRzuu?G~sqgI+j zazYV_5MDPp1f>!F7jW!6Asqd=?*(X?NV<-JS^}qleC)dsYIYQ$f@8e}FXpNz(+}Cj zI>G1-4+?z<$94fQff1NOm~m4u5`=cYA5>$+;d!XjBH&l3D+%K8I*FCT^2Rmedq*WZ zxQ+FBzVoh+A#?`(A~^|yXzd{!K1QUGp!pe=`^)O8GP6=75{^W|6-&5CREpC}%d&u| zeIO%(fP`A%6GKxTKBzfVOwBX+XA?&s>at^PDm4nDK7>i{aq3X79545p!fYrLiwHEm_D*5{ub#2}Z5R z#2lHFot?p{ES0u)XAfAaEo;i@9mBjIGj>b^yYO0ESnzQWMhfAU@P0vp5tf2LQA70^ zs`F4nFVsT-&<9W;5Pg&ab_q8OHymg)d*(idl2Zw zMEEV}tN~yzgs7mdkH?9B8gc^)K@NZpgjlkP%4Qkl7>8{_IBbboWP-kJ$dr)eAl`U= zY3J7=_m zvnuV9%DAYsuPG-UjJh6a$KfsSn&4f-8xM(7O4#Ch&7o8b$wR8=P?Hq&DC}mW^ci$B zAj#`wIgc#9P|Jw$MY8Y#;sp?JE&)CEJ7&GciASS``EI4EuAx3ZCqpa|(722kJ}XYh zN#wB;81(1_MjRt8liM;%Q{6tN+e3#A>VY451!4R%Axu=l_=f_e38u;ooYVWSsx}h` z=2`73Zf(0DKbJ2P$wcgQ87DP`o0=?0lZvvWytFh?YNj+VC$+RNy}Bl=xiPD~t)RQF zbYQrAOkZi9YIWWjgee(j%@x06B>>76@DmFtSW}=YgAR>TEQnNeia^&04~RwAVa*l_ z&L#BD3UPq%LvT_*L`WU{yWrT#zZV!0`!iaPS+q?n%?pp_gpiX-fL{Sdm@aGW6b^+)whnd#Mx8A&93Zef3-g(!dgGX_sV<3Vq ze235W=;I~Cczk((Qoqz!jzlF{hD3!UmT08*En4S3rv`28-~=dMZ;6O70HDsE!h}oi_x1jpx@}&cBeSwmB4rB%45@&fAs5L- zT$zNQmMYB6lUCGbw)Wo|wl`Tf1}64&GXcv?!0dXYGH(pbdCWLc@I?a;Ike&)c9l?X z!LIVAtOGsr=wLY{Eil$_xPe@y77lQc!fT!J*k<-jGrPK(J>B@W%DOpfyf>s>=^LBx zQ%);-rbk<+G_@{unSE@?*4f@ynVQB+h`UNlxXGa1WD>gyJt-Qv3Kg=FkZ>dV=G7a~ zSFXohz8-xgKKAP6OJ@a{`9KeTgnBvJNk|d}Q1pXU2qUSAw5YA3%P}DUdr=xp6^NR> z_8>z0BtrK%(tr#n1d^@;L3Z(MM}Q52MvtH^1!)6-WH>~(=R-Wk0?D_XKA8v15v@$h5E;l`X|UbuvQ6hBp)<5Y49N6^5BG6$`JpH z-q#wquY*VoFWHaM`!5^)aTY(_>Sda|9FvEy+mvcni!3WO)|m#gz0Gd!p42L*RqDAh z)8epoWx%;HIR0S7wWXcj)z9o`=k^Q>n8(jPi>bQaF(Uvd`5BUm1n=ltXck7xFzHm( z5(%;8F8L-Fy(F~^SyjX`ru)tXuXS=$W0~(8)Ydh%6%`ey$)y55hl#mHSP1|&J(fmK zU`s^8>Ner%Oq}`AX}ues4BA)W)r4z1{_`SCRKoa&0tGGzYXeRy5gN}~o#(P{_u|-t zg#KxIt5#6eB+1T{NLVQ%MuwD=l_E?{706R~8FFq$GA|=jSWu8$UXoE=k=0n2-QJSh z-CfW-P%@;e)lYUg*N0~w>gKkMi5_zVF(1B2dE zNFiQ7dE%M{6n8?m&{I)gm6auzk*XIGDK|SOB`+^UF6RpQbUv3R5-@}UBq|afOUh&O z*fb_PHX~QkI$UO79a-M9ulR@;;(=@V05;y;&EDu3ZygWqh7Q6f^jLDg6g@+i4A@0` zBYC7mjl%>qQEVANA3}8U+0r}jsSJz#1NO!qZB?(Tx^tkst*5lHQ%jOGQ3KVUjRqN5{Qp!}nN44ej6+8)5?&*{PSPEP@GqB++`nvbfpi9k%hl(0Dt`v|3?VYOyXhI~*;OhK@;X&(z4+;;4RU(7e=dTUS`` z4>}*I$9IgAdzQ&PGqGSBDG7iHg}s&qAIzcF1$1zIEmQ&U>dC@QP>NDB&JhHK4d6_7kI?arY?Das#QKtx9`2mJ2Drq(*I7&0_9ca;|2%1B8S3I!}am%-&Gak=qa zCiIQ#G$Bt|-jJrSaCP@?n0)W+y%8E=-xxt*{4*g;RKoa&1FhJ>K$l2j zaEHm}oOb`b!Fxq@C$8JcZ5$C-wDOC~WO9jA#!E>SWXlCvGJd+8mjxSX!pv-OPC;^E zNk&CwPGfy;M{|BpS5fy+Y5!Q|ps~R+*Ee-fJF}^q-8N$7>;MQvjDeo@ppW%5P!}LD zsG?cPI(<;7C6bg($v7W}mfJyj4bI$egA&B=L{M1&|HiS?{zV*BVUJzy_XAhlQAdBh zLKfN%Srw=Ub@;dlj(Q;>H7*ff0e_4y=m-`%(aO6yx-ccH+UPb!|XB z)vh!*D0KDx>W0q2O5i6gEw`#Gvx^IoVF1b{91*Aye3qEU6>*qS9z!Bz^7u3sE1J!? zA>}htGjmewdl?<(Ys$qdhV66uy^|`k@Ttf2tD3 zMp8^7iLg(i#n5hEi;2E;E&57y;^p&~&(P&*4Aa(illO|*chP)s*6{!24K#3AUe*uN)92@ES;X(5?bsBpe2)PLR(~$AgRr5Q$mT zQ71+_3H7_AvJ=@p0$k>YW``$4P|4Xbu#>X1oYwE3Ho%p@yY>R$4o6D<2l_5-ff!?x_O4G)Ee+u63&%8B`=$;tLfW5<-PV^-TWt5Ghf zG)rTaaVS(my_@^IaNwcys7B zyUoI`RqzVRMKWCLgF~wsf<*#sv&0w_@Z2$%{om+-->f4Mq@67lF_9tUXVLk<$|Fq^YJg8Hs z%ew!M?jS%2EMxvQHESY`vkR}Y`iB~XKWoh9sT#zM(7#G$zrI@miJtOlIxKKlxipKuuYYSkKpT+1HUvcok$5}WR4+cql3m+QtSS|%(V)5adatf^<-)VF9DS<~q@RHls~ zASAZCqmG9f$EMD;g-K9mc675lhM67H>>lO|nIQ`kvmggN_9aXZ2QwWA0U!+Kg#cNq zAS8A%Fuv%YT0%iIv*Mo&zGWh04khg|(G--8@ULUyhyb|_%&_Pyh~^q9XNhgbqn~)B zwyq6o#yk7RY8%^%O3G3)(s(j4UCgHo*jW0O$Dy&9ag5X~etD~0;Sd`iB$z$th$$%& z9q|0{LQiUZ@9EqpjPPpU$sZp?Vf@n|OjN@7hawUSoM8%MboPar1EAfH&>UcykdUjJUx2js0oh^|H7=9b>zPZB=r^&m72!tj5O$$bcrxYz@rIybRmx+L_a8&keejo z(Is3Cn;kDpXSa=1Iqs;JL9q*9pvdauDdNLDv33BO0ldNK?_f|E5D%a_+p+EkpA;K` zZ8$CU6{0mdsf3|``NJdx3qCZbOz#*b9%&tSMhuJnBhFTZv7ujE->+)u9;)qDR(JJQ zHZ~VmRAd$8C1<9JlEoackjWD;1bkpzObM4Iya9(@A+*MUBPZcg7*L+#sb|YL|v%eP!4NKtl^PE5eJ_z3f5pO zK>s8>8I3KV*P^A-1p*chuKFjIed9=&1NMb|+dLXx>~lU)jxBR;n|XdTEsIaXgvHS; zdMtc0lVa$ISSHey>$KSGvC-ErUpgz7B^efn#_tb}-&0!dj*Q(__pcdyZd<$7ZJle@ zo@GbplD&Pw);w>opS9IYTdSrl)zhZ*@r9eldl!tmSIj#%jJwwj-YZ7mB|{LGjfhJI z?**gxtik)f9&y&-2j=iT783DONARHDch-P72WK#Nt{HtdP5u}&z!zr*j)WUAz%(dW ze1KSspJv%hvg|S}J50+q)4av9Y_Tnm1g0I4aZ_Nv!#6+R8+S#fN2&JJ!ilN633KC= zrFq8MHf!#fwRFvyd*;nOOQ!xM!|1ABzpgRd9x>b*Fy2$T9*j+FYNsCQrnYp`Tl(1@ z-Q12AMl?pfW3~g!yc-y)Y2IsGz}oNxKJ%Q{bQo4dJUlU6WPAZxsBOh(S@l`alugXY zEBjL`SZoLD?NGubgx8|)&Eo(GPc=$~Y0@4-U`sd7s9!Ab20QJTj30U;pt73@-w?83|0v^(B+zV#T=z8 z6hhJ#Kndb^{QT`j5OZ{V#q zh=YmM1FW0=CO|cR>+6R&eug7Y?AUR@JOA}vjsGG-XA9w213>8b3OZ{|Xy&?Vn`%Iz zmkMYiZoGgCM1?89vLQ?fpDp6jfvAXBbT&7-tSJjv$`a;sK)nzRGSqnj<7@Dgr~>nE z{yO$0>{o;gze!O-2z5>*D*G<#3gL@KiOQ_UJhf|ZZmKPJhBb>l%JHTyOLf1#L7{2r z8?EbB)^_z)x3yK&)fN;NXJ%(7r>2VK5{^X3A|>;gVgc|jnvj#oXC-o&G@*!>mYbPd zT_fz!C3Ki$2bZrK-DhyE!54wSCqP1A@B$|ZL>e$+J`#QFeGxh@<@b*!0wMvz7M1S; z$UqR4?~(esC$%1UP!ZZE=#PXBNa#~U7L|P$bnY1Sy~G}etaT`-q&!{9PZcs`Vos`D zRFa=rS(aOJE48vJqouR3f4E{$TdkV!u-zR3jtXQIdydz>;BmZJfF+2~k#`c6!_G+% zm|Vmvsk{WN z1sTI)P+TQ8i58ob7?+qB&5FC47^{-EPdkLr%PdD+si;%r&Aw=UY6m&RKb9nHXJ7Hm!P z)}}dYTJdao3YVmXtEoc97aH+-2i}%W|ygT+|oW}Y@arE&KSC948604zBxnxyg@N< zRxB9$SG0;XqhiH2xMUt%QyK0ITQ&x5cZVJKN1YE;u7|4eN9xH(+R0u0^e)_^er`(# zgk)-0H@>H#awJm4oiLF)&Q`V=uW`<6T=H61u<)XN$%}a}sj}`1xI6)`+lINeFj~p6 z9Q6G|T0&kTNQ}uPv=4*)aiJ8#to;+q2givD1*#9c0q7xz_LGn}qILnmQh-E_Z)&Y; zLu0Pi9#ut4duc^wR!)vYE@g=YbP1m+*+1}XywW!)%JP4|EwW! z3Z(~*2%;gN?_4Cw&xW=ypz&&8@)&4zY!HR^ScRjqici%d7A& z^e_&_PKKx;OI(yMD=wCoRi@W8WH+?rwssYD^;h&LD-@&EW5y=aWT#`LfAa3o%%*x~ zTSrnc+84d9MUMmX-jX~>Bz2gT1Rnz&Bm|I>#UwzP0M{l#Mh8Kb6{%!mJ{Kvh=EBM?(`j}>a41aw~3P;<2*H@0U`7Rv$7Qy7&b9^01m?33}*nvYu0DcOety`E9J=?Ava0HNfL4~90kDz6`w9f zzb{$}Ke4Jhy=|~Wv)DTa8UrTOp1{1=Kv&>bf*xyVqIvB*0ki~xOVNt~+v2`$2{!yT zgwG0C!XRl-K7w?bV$>L!8nDe_mQUx@uGRHOXT3LM+UOgbYaVpe^%-gv+6INXsb{pl zbGWLrzq+-(thP3{v?L9 z+XvWP2AX0rQMG;r_z70`3!K23j2K%Sq4j`10XhW669*S2@RMv4_kH5xPe6pgYsnDC z5#U4H$08W`+`J$*N#r;6UlPepJwtq$7*!mt+ocaKOFPYZjoq1fIVo~sK~6?-QFeNo zATyO)oFlF-O=+slY;Mo)QWgywDvh)4&JE?n!?D>tBZw?wSjK*Zy$D@{K%ilECQOC_ z0uB1%zH1?X<~vYiq35sqCjl$|>1E&al7DKMDng11F7RZE&rB}wPpyKcOR7{6FGoy~ zw-NyP3DR$3!DCZR_4JI^tZkU*=PhuZ@v&Dp>_jF#j*dhn8i)$e6$b4lJ?X}k8|S$| zR8~hZ$UBr>10!S{SY68F#ISyJYC>QvBG#-l&F$!C-KI&b6J?s+HO}lBv1Zh+VH!04 zZ7pCDD=(>$5bi??Y>WV$8)J@*QOE63`|T0i`mk*cVObrvtikSx z?e+*9cMv!?MniDj85_T=8o#HW*wg@kZ%jSXPH$;v2&Q-RQ$Ro5`e~1m6n8Stx%B|x z9+-sPgKk;?d=dCkFm4155pL_E8#xJ5erp^q11@seV_WjT>#)!IZIf=j^U;W9O`)0S1hLcBQ(jx2Ra_)WPZ0r~7V(mV z>_h=8fzPIKn2C)1N_u5?LWd=K`1U1@`wY5xJ)w%>5Q>7e+DPgG*u~HzlqY(UKMfZ? zKtnhjJ7N6OAxu=l_<`Ys!FxjIj?()tn)WYhw{DEC(-hN;HUqa#DJ*M9$;?ica-}jx zaw<1P%1)8-(9Dv;&raj#WeJN4BxNNjl~ozFwb?B#MO~exT|Fh;N{qrEG1O_Ct(L{U zi916x4@RdRjX8J6Cf&MOG^69pLFLRvCoxhMkeU?dh(xB6nNXpnBr!ZSH;gY}>?xLN z!Fd|%y||VSNDZuz!`KnP+;B`Arf~G&J8@LS|A*n&HH2`i+4o@m@$_-L)#ezE{nlTJ z(35=Qc^=#STL9leqT(M1q5|56W_+Ncs#qjp@Hq?#mnq~hgrr`IgwK?Tm_lwMj}_a| zbZdKS)-kPY87?xeD4`GF>N0@P5+trZJ1BW8Kt;ST3QWi0z0DxFXK*L+` zS~0a9x^h|O{nk0ZbtV{pVwv7G+V2gSH~RD|U1QTNL-vLNbJKvKS*fn;8>;S6)^zvP zw6s=K*A$kNW#{M0GExO{8CNQ1O9U)2k0IvLMI5?-lf>uIgaVpW$dV^ZGm8r|o4bUa zCT5R=Ho6jP+`eD{zHtzV`HPMZ&oKVMAn_GI6c0}653U$}3F_NSrCr*q%5Ck=E-6b* z7N<&C$x=p|oSl;)&d-*Y+)AygNo(lHY8}YyHI%8QTWoiSU7Kpq=`jl&QIt{d$8JVI za=~F2rIySIT?rBotUv{tD=4*8@mxw929RNeRq!XE8En8V2uTotsLMis_Bz!Qy&VJP zQ!`_~{k8YA&we>EsZUC}PN&DPSu_SCo=%UZ(})ikJ%+)EjgGz~kR@4HRKTf1>-7IM zP-{s+szYh{M-uN++W<1t8WqQj149&iw&`h={HMQ-1#kEb@MYobOGjKf-EzInAE+dxBN@PpK%$!@y$}UEu z`nq!dy6WB~nA~-^I!W9v!fP2}Ai_i?j2{?I={%=2duO!1DD8fv29r5n&>dXS_%Dw= zx<0r@@15YctAvdml9F;sYHEs@l_Fs$OE_{dCq=@|kco1WB^jC0>>PPvQASx=PDNF2 zO+#K|dtpmYVdp?e_egpFSjDiWT5W04Pjy*W2V8eYCm(92x3sgnhIx-^(QhGn8!6Ia zo%7jdeAa2NW!h_oN&V1a%#KzUk}!TA{TxX0cnnemmNEewXm5E3j?XAZkA!B9W4nN3 z+WavbZFv;On*HN(oTVJ%ShHg|b{GG396sdZ$Nvfhzle(31Q3-myE4BZlP{$4xl9R< zB|@%3%Fhc}KvcNQ*pi|QkN3`3U;X;0Ke=bJw6*pZ7#8}`S^oe!1~OZm^?|7P?JGFb zqk=;CFj-i|JfSwsM2FSlad{PMSzrcd*PPcrsqZBVn&uXMKdn;QFc zwF+&WQdO@QY3Lnn>g;Q5Ypt%UDJr{_UXUxzO5-O>SrQRlET9W`G)w@>qx1Pm0$!qs z8CQlC9;a3N0j=&~_zM(a7HbrVZS;G{ZmN*#_c`F{_zR_XjF z(C_6SO8@wj%6nGhyJ7MrXzp@{rZPH5@+xXF(vxK}u28~|OIRr~PIiW{Fi%!|OI}); zQq!E>(OaZYm8&NkE$dy*&7sL%{j3Lz>MVFIix|jfTOu}=6*m^Xn1{~kw*x?1T{>_g z_NiqT6k;I{F&HLm0UTvG(Dp%&x!_bN8v|3bZu96wXLEn4ZCv@UKfV9;XTN;${0|>~ z^l#PG1#xj#*z80mBM$hN;3r6CNU~x8%ZV{$FMgLP)oQB zXG8xnzilR91rfCD8DI5~0Hgp2E5r1jetbvgcra?eta#~xhL?wvs+VITr+vuH3mi5b=Qf<OR2GEnV-YA_9xXNiQ$TDvfk``|SW;^wt*9NYr_pYfQ#15^{x}3bWA$~)T zpuAp~lP^w|%B9>?5j|DPNt1CiQv}&*;@k`|sBX84&hX^@F^q57*3a%5=RKBrzio~H)9Db*k=(~~UK`LD zoWpQ_a)1K5#4G`z^pE6!Z!^3ThUiCY8{<650j)9hLadsr~^t#!o05YjYIG zF7>Ym^~8?hSeyR}Kr;ZM;x-POdehRRJYIr;xO)LnLDK|}A>lKG+(fyUwLI_q>YG14 zd;0sYKl|CgKG`ss+8TTFM#np5_Kcw95v9k8ej1+f#XY1h3jx~#bSJ-c-fuyh37So8 zq^zEKa?j{^q;YJjEcb^Et9{zp&M{Z3dZJ^@*)eKqSE?JEyGtr+ax2Pnsw?tpD+{X2 zbBl{ova^NBX%d;7FB3DR!Xz<|1{cN$u<4jOnMaq1*z$CFR#{n5Ye!bER-mxc2dCpT zcWxMV&uiULYRqeNQtys5cp?qn2t8))i_!pZ2@{q7_lU%BzWqq8CsMr|rFEYs#R}1x zR_i&h^W4<#(1ur}N_$?{P=0l7PDXOFj4zdNl4abqWNuC>zbI3Dt3X^)o?P3M(b|{S zqbVD5H5itAZ1;!9A8BUxOhl%kfo0weBF?$!8;8$%jC~?;4%l_6)&1a7O9<#?L@X-M za?x*JMpKMqYTKf)wA6PO*e3^m`qPJBeEN%T{`BjoPk;CF)u$VGX4&kxq@rf%dN58(|FM*$?&u_GZ+axA!w zz?0*ZlRE;Tw18OwNqL-LZ7Vx*Hp4|a*q-+}X57|^9iwYgW4||ST^i81x)r*HuA%a# z_Wa6<%>10RG`UD7Wbkmzs0VVd&&NS)`jb~jRU z5TQ9Z8O$Gz{@$2-?X33qC-5UHr_jlDIk3(HJ
  • 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 z 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 literal 0 HcmV?d00001 diff --git a/screenshot/control1.png b/screenshot/control1.png new file mode 100644 index 0000000000000000000000000000000000000000..eb58e8befe3128ab9df7089cae2edb9b4ed10eb7 GIT binary patch literal 225210 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%0DP2!)lmU+hKYh6^hB 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
  • 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 z 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 literal 0 HcmV?d00001 diff --git a/screenshot/glance.png b/screenshot/glance.png new file mode 100644 index 0000000000000000000000000000000000000000..ffb55e77210de7af25c539dd161c79169487185a GIT binary patch literal 92720 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%0DrC2aNM-fdeGezKo8x=1Gll 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

    IZ51(mXKAsqwAY0x2>4AAx`&PMxw*Mr{S} zc;+xq;`?)CnU9VJvT`5k<)hrDUfEslx-aNnv2W^ z*;(1p-!HP5qzn7Jc0FCQELAJFD9WEV;&oTR;zf_(lyT?-y<_)O5?bOrG#PDhS;4|9 zebZn=9mAZ%YM9LG0}Z(;XutUChx#g}z}kc;`>s+UmPu77r{*5&w*EN7w@&|3BK?&S z$1zEL;{@qf3KEJ8i$IxN!>3ZJci0xGyRj)&fXSkl)MJFHV@96{V_1P9SLz9>dp8q{ zOzFaJ-Jun_7YX!KZ(-6eyW{)4LGB(Svtja_DF3Hmcz>n#^d&_$cw)fn>i{0|(H13b?fdL(==r-lQAw5RZkz*rN_K(G0s-o5 zVb?~vGVT;uxf8@^fV?_~+9EAd7Ba(Nuqb73_@>=Y^K zZ*rU)?TYn3G+rwOu9QoRA3xD6yfV>$m&9fgLdIlvNn7g^KHvPjLdM6ye=sewsp{0P zHDPR1f0AbzXcgpM?`+(QV`OC}#+^%y@kxrdiCjcFyrQPt%i_V%oI+o@KnYp7a;!S= zIeOP0ep~jliGU1mmE~D5zkL{vj{EyGweFJKPY#x!>GZ|Dvad|_gcpG8@v#&s?P1

    + * These are separated out from the main API, since they aren't + * authenticated like the main API, and as such need different + * headers. + */ + public interface IdentityApi { + + @POST("/v1/users") + Response signUp(@Body SignUpInfo signUpInfo); + + + // NOTE: the `LogInResponse` used here as a return type is intentional. It looks + // a little odd, but that's how this endpoint works. + @POST("/v1/orgs/{orgSlug}/customers") + Responses.LogInResponse signUpAndLogInWithCustomer(@Body SignUpInfo signUpInfo, + @Path("orgSlug") String orgSlug); + + @FormUrlEncoded + @POST("/oauth/token") + Responses.LogInResponse logIn(@Field("grant_type") String grantType, + @Field("username") String username, + @Field("password") String password); + + @FormUrlEncoded + @POST("/v1/password/reset") +// @POST("/v1/orgs/{orgName}/customers/reset_password") + Response requestPasswordReset(@Field("email") String email);//, +// @Path("orgName") String orgName); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiFactory.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiFactory.java new file mode 100644 index 0000000..163342b --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiFactory.java @@ -0,0 +1,148 @@ +package io.particle.android.sdk.cloud; + +import android.content.Context; +import android.net.Uri; +import android.support.annotation.StringRes; +import android.util.Base64; + +import com.google.gson.Gson; +import com.squareup.okhttp.OkHttpClient; + +import java.util.concurrent.TimeUnit; + +import javax.annotation.ParametersAreNonnullByDefault; + +import retrofit.RestAdapter; +import retrofit.RestAdapter.LogLevel; +import retrofit.client.OkClient; +import retrofit.converter.GsonConverter; + +/** + * Constructs ParticleCloud instances + */ +@ParametersAreNonnullByDefault +public class ApiFactory { + + // both values are in seconds + private static final int REGULAR_TIMEOUT = 35; + // FIXME: find a less cheesy solution to the "rapid timeout" problem + private static final int PER_DEVICE_FAST_TIMEOUT = 5; + + + // FIXME: this feels kind of lame... but maybe it's OK in practice. Need to think more about it. + public interface TokenGetterDelegate { + + String getTokenValue(); + + } + + + public interface OauthBasicAuthCredentialsProvider { + + String getClientId(); + + String getClientSecret(); + } + + + private final Context ctx; + private final TokenGetterDelegate tokenDelegate; + private final OkHttpClient normalTimeoutClient; + private final OkHttpClient fastTimeoutClient; + private final OauthBasicAuthCredentialsProvider basicAuthCredentialsProvider; + private final Gson gson; + + ApiFactory(Context ctx, TokenGetterDelegate tokenGetterDelegate, + OauthBasicAuthCredentialsProvider basicAuthProvider) { + this.ctx = ctx.getApplicationContext(); + this.tokenDelegate = tokenGetterDelegate; + this.basicAuthCredentialsProvider = basicAuthProvider; + this.gson = new Gson(); + + normalTimeoutClient = buildClientWithTimeout(REGULAR_TIMEOUT); + fastTimeoutClient = buildClientWithTimeout(PER_DEVICE_FAST_TIMEOUT); + } + + private static OkHttpClient buildClientWithTimeout(int timeoutInSeconds) { + OkHttpClient client = new OkHttpClient(); + client.setConnectTimeout(timeoutInSeconds, TimeUnit.SECONDS); + client.setReadTimeout(timeoutInSeconds, TimeUnit.SECONDS); + client.setWriteTimeout(timeoutInSeconds, TimeUnit.SECONDS); + return client; + } + + ApiDefs.CloudApi buildNewCloudApi() { + RestAdapter restAdapter = buildCommonRestAdapterBuilder(gson, normalTimeoutClient) + .setRequestInterceptor(request -> request.addHeader("Authorization", "Bearer " + + tokenDelegate.getTokenValue())) + .build(); + return restAdapter.create(ApiDefs.CloudApi.class); + } + + // FIXME: fix this ugliness + ApiDefs.CloudApi buildNewFastTimeoutCloudApi() { + RestAdapter restAdapter = buildCommonRestAdapterBuilder(gson, fastTimeoutClient) + .setRequestInterceptor(request -> request.addHeader("Authorization", "Bearer " + + tokenDelegate.getTokenValue())) + .build(); + return restAdapter.create(ApiDefs.CloudApi.class); + } + + ApiDefs.IdentityApi buildNewIdentityApi() { + final String basicAuthValue = getBasicAuthValue(); + + RestAdapter restAdapter = buildCommonRestAdapterBuilder(gson, normalTimeoutClient) + .setRequestInterceptor(request -> request.addHeader("Authorization", basicAuthValue)) + .build(); + return restAdapter.create(ApiDefs.IdentityApi.class); + } + + Uri getApiUri() { + return Uri.parse(ctx.getString(R.string.api_base_uri)); + } + + Gson getGsonInstance() { + return gson; + } + + private String getBasicAuthValue() { + String authString = String.format("%s:%s", + basicAuthCredentialsProvider.getClientId(), + basicAuthCredentialsProvider.getClientSecret()); + return "Basic " + Base64.encodeToString(authString.getBytes(), Base64.NO_WRAP); + } + + private RestAdapter.Builder buildCommonRestAdapterBuilder(Gson gson, OkHttpClient client) { + return new RestAdapter.Builder() + .setClient(new OkClient(client)) + .setConverter(new GsonConverter(gson)) + .setEndpoint(getApiUri().toString()) + .setLogLevel(LogLevel.valueOf(ctx.getString(R.string.http_log_level))); + } + + + public static class ResourceValueBasicAuthCredentialsProvider + implements OauthBasicAuthCredentialsProvider { + + private final String clientId; + private final String clientSecret; + + public ResourceValueBasicAuthCredentialsProvider( + Context ctx, @StringRes int clientIdResId, @StringRes int clientSecretResId) { + this.clientId = ctx.getString(clientIdResId); + this.clientSecret = ctx.getString(clientSecretResId); + } + + + @Override + public String getClientId() { + return clientId; + } + + @Override + public String getClientSecret() { + return clientSecret; + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/BroadcastContract.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/BroadcastContract.java new file mode 100644 index 0000000..126036d --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/BroadcastContract.java @@ -0,0 +1,9 @@ +package io.particle.android.sdk.cloud; + + +public interface BroadcastContract { + + String BROADCAST_DEVICES_UPDATED = "BROADCAST_DEVICES_UPDATED"; + String BROADCAST_SYSTEM_EVENT = "BROADCAST_SYSTEM_EVENT"; + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/DeviceState.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/DeviceState.java new file mode 100644 index 0000000..c3117fc --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/DeviceState.java @@ -0,0 +1,284 @@ +package io.particle.android.sdk.cloud; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ParticleDevice.VariableType; +import io.particle.android.sdk.utils.Parcelables; + + +// FIXME: I'm about ready to give up on trying to make this actually immutable. Bah. +// Instead, make an IDeviceState or something, which is an immutable interface, and then have a +// MutableDeviceState class which will also have setters, and only expose the mutable concrete +// class to whatever class ends up doing the device state management; *everything* else only ever +// gets to see IDeviceState objects. (This might interfere with using Parcelable though.) +// FIXME: is device "state" really the right naming here? +@ParametersAreNonnullByDefault +class DeviceState implements Parcelable { + + final String deviceId; + @Nullable final Integer platformId; + @Nullable final Integer productId; + @Nullable final String ipAddress; + @Nullable final String lastAppName; + @Nullable final String status; + @Nullable final String name; + @Nullable final Boolean isConnected; + @Nullable final Boolean cellular; + @Nullable final String imei; + @Nullable final String currentBuild; + @Nullable final String defaultBuild; + final Set functions; + final Map variables; + @Nullable final String version; + @Nullable final ParticleDevice.ParticleDeviceType deviceType; + @Nullable final Boolean requiresUpdate; + @Nullable final Date lastHeard; + + DeviceState(DeviceStateBuilder deviceStateBuilder) { + this.deviceId = deviceStateBuilder.deviceId; + this.name = deviceStateBuilder.name; + this.isConnected = deviceStateBuilder.isConnected; + this.cellular = deviceStateBuilder.cellular; + this.imei = deviceStateBuilder.imei; + this.currentBuild = deviceStateBuilder.currentBuild; + this.defaultBuild = deviceStateBuilder.defaultBuild; + this.functions = deviceStateBuilder.functions; + this.variables = deviceStateBuilder.variables; + this.version = deviceStateBuilder.version == null ? "" : deviceStateBuilder.version; + this.deviceType = deviceStateBuilder.deviceType; + this.platformId = deviceStateBuilder.platformId; + this.productId = deviceStateBuilder.productId; + this.ipAddress = deviceStateBuilder.ipAddress; + this.lastAppName = deviceStateBuilder.lastAppName; + this.status = deviceStateBuilder.status; + this.requiresUpdate = deviceStateBuilder.requiresUpdate; + this.lastHeard = deviceStateBuilder.lastHeard; + } + + //region ImmutabilityPhun + // The following static builder methods are awkward and a little absurd, but they still seem + // better than the alternatives. If we have to add another couple mutable fields though, it + // might be time to reconsider this... + static DeviceState withNewName(DeviceState other, String newName) { + return new DeviceStateBuilder(other.deviceId, other.functions, other.variables) + .name(newName) + .cellular(other.cellular) + .connected(other.isConnected) + .version(other.version) + .deviceType(other.deviceType) + .platformId(other.platformId) + .productId(other.productId) + .imei(other.imei) + .currentBuild(other.currentBuild) + .defaultBuild(other.defaultBuild) + .ipAddress(other.ipAddress) + .lastAppName(other.lastAppName) + .status(other.status) + .requiresUpdate(other.requiresUpdate) + .lastHeard(other.lastHeard) + .build(); + } + + + static DeviceState withNewConnectedState(DeviceState other, boolean newConnectedState) { + return new DeviceStateBuilder(other.deviceId, other.functions, other.variables) + .name(other.name) + .cellular(other.cellular) + .connected(newConnectedState) + .version(other.version) + .deviceType(other.deviceType) + .platformId(other.platformId) + .productId(other.productId) + .imei(other.imei) + .currentBuild(other.currentBuild) + .defaultBuild(other.defaultBuild) + .ipAddress(other.ipAddress) + .lastAppName(other.lastAppName) + .status(other.status) + .requiresUpdate(other.requiresUpdate) + .lastHeard(other.lastHeard) + .build(); + } + //endregion + + //region Parcelable + private DeviceState(Parcel in) { + deviceId = in.readString(); + name = (String) in.readValue(String.class.getClassLoader()); + isConnected = (Boolean) in.readValue(Boolean.class.getClassLoader()); + functions = new HashSet<>(Parcelables.readStringList(in)); + variables = Parcelables.readSerializableMap(in); + version = (String) in.readValue(String.class.getClassLoader()); + deviceType = ParticleDevice.ParticleDeviceType.valueOf((String) in.readValue(String.class.getClassLoader())); + platformId = (Integer) in.readValue(Integer.class.getClassLoader()); + productId = (Integer) in.readValue(Integer.class.getClassLoader()); + cellular = (Boolean) in.readValue(Boolean.class.getClassLoader()); + imei = (String) in.readValue(String.class.getClassLoader()); + currentBuild = (String) in.readValue(String.class.getClassLoader()); + defaultBuild = (String) in.readValue(String.class.getClassLoader()); + ipAddress = (String) in.readValue(String.class.getClassLoader()); + lastAppName = (String) in.readValue(String.class.getClassLoader()); + status = (String) in.readValue(String.class.getClassLoader()); + requiresUpdate = (Boolean) in.readValue(Boolean.class.getClassLoader()); + lastHeard = new Date((Long) in.readValue(Long.class.getClassLoader())); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(deviceId); + dest.writeValue(name); + dest.writeValue(isConnected); + dest.writeStringList(new ArrayList<>(functions)); + Parcelables.writeSerializableMap(dest, variables); + dest.writeValue(version); + dest.writeValue(deviceType != null ? deviceType.name() : null); + dest.writeValue(platformId); + dest.writeValue(productId); + dest.writeValue(cellular); + dest.writeValue(imei); + dest.writeValue(currentBuild); + dest.writeValue(defaultBuild); + dest.writeValue(ipAddress); + dest.writeValue(lastAppName); + dest.writeValue(status); + dest.writeValue(requiresUpdate); + dest.writeValue(lastHeard != null ? lastHeard.getTime() : 0); + } + + public static final Creator CREATOR = new Creator() { + @Override + public DeviceState createFromParcel(Parcel in) { + return new DeviceState(in); + } + + @Override + public DeviceState[] newArray(int size) { + return new DeviceState[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + //endregion + + public static class DeviceStateBuilder { + private final String deviceId; + @Nullable private Integer platformId; + @Nullable private Integer productId; + @Nullable private String ipAddress; + @Nullable private String lastAppName; + @Nullable private String status; + @Nullable private String name; + @Nullable private Boolean isConnected; + @Nullable private Boolean cellular; + @Nullable private String imei; + @Nullable private String currentBuild; + @Nullable private String defaultBuild; + private final Set functions; + private final Map variables; + @Nullable String version; + @Nullable ParticleDevice.ParticleDeviceType deviceType; + @Nullable Boolean requiresUpdate; + @Nullable Date lastHeard; + + + DeviceStateBuilder(String deviceId, Set functions, Map variables) { + this.deviceId = deviceId; + this.functions = functions; + this.variables = variables; + this.version = version == null ? "" : version; + } + + public DeviceStateBuilder platformId(@Nullable Integer platformId) { + this.platformId = platformId; + return this; + } + + public DeviceStateBuilder productId(@Nullable Integer productId) { + this.productId = productId; + return this; + } + + public DeviceStateBuilder ipAddress(@Nullable String ipAddress) { + this.ipAddress = ipAddress; + return this; + } + + public DeviceStateBuilder lastAppName(@Nullable String lastAppName) { + this.lastAppName = lastAppName; + return this; + } + + public DeviceStateBuilder status(@Nullable String status) { + this.status = status; + return this; + } + + public DeviceStateBuilder name(@Nullable String name) { + this.name = name; + return this; + } + + public DeviceStateBuilder connected(@Nullable Boolean connected) { + isConnected = connected; + return this; + } + + public DeviceStateBuilder cellular(@Nullable Boolean cellular) { + this.cellular = cellular; + return this; + } + + public DeviceStateBuilder imei(@Nullable String imei) { + this.imei = imei; + return this; + } + + public DeviceStateBuilder currentBuild(@Nullable String currentBuild) { + this.currentBuild = currentBuild; + return this; + } + + public DeviceStateBuilder defaultBuild(@Nullable String defaultBuild) { + this.defaultBuild = defaultBuild; + return this; + } + + public DeviceStateBuilder version(@Nullable String version) { + this.version = version; + return this; + } + + public DeviceStateBuilder deviceType(@Nullable ParticleDevice.ParticleDeviceType deviceType) { + this.deviceType = deviceType; + return this; + } + + public DeviceStateBuilder requiresUpdate(@Nullable Boolean requiresUpdate) { + this.requiresUpdate = requiresUpdate; + return this; + } + + public DeviceStateBuilder lastHeard(@Nullable Date lastHeard) { + this.lastHeard = lastHeard; + return this; + } + + public DeviceState build() { + return new DeviceState(this); + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/EventsDelegate.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/EventsDelegate.java new file mode 100644 index 0000000..28541d1 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/EventsDelegate.java @@ -0,0 +1,255 @@ +package io.particle.android.sdk.cloud; + + +import android.net.Uri; +import android.net.Uri.Builder; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.support.v4.util.LongSparseArray; + +import com.google.gson.Gson; + +import org.kaazing.net.sse.SseEventReader; +import org.kaazing.net.sse.SseEventSource; +import org.kaazing.net.sse.SseEventSourceFactory; +import org.kaazing.net.sse.SseEventType; +import org.kaazing.net.sse.impl.AuthenticatedEventSourceFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ApiDefs.CloudApi; +import io.particle.android.sdk.utils.TLog; +import retrofit.RetrofitError; + +import static io.particle.android.sdk.utils.Py.truthy; + + +// See javadoc on ParticleCloud for the intended behavior of these methods +@ParametersAreNonnullByDefault +class EventsDelegate { + + private static final TLog log = TLog.get(EventsDelegate.class); + + private final CloudApi cloudApi; + private final EventApiUris uris; + private final Gson gson; + private final ExecutorService executor; + private final SseEventSourceFactory eventSourceFactory; + + private final AtomicLong subscriptionIdGenerator = new AtomicLong(1); + private final LongSparseArray eventReaders = new LongSparseArray<>(); + + EventsDelegate(CloudApi cloudApi, Uri baseApiUri, Gson gson, ExecutorService executor, + ParticleCloud cloud) { + this.cloudApi = cloudApi; + this.gson = gson; + this.executor = executor; + this.eventSourceFactory = new AuthenticatedEventSourceFactory(cloud); + this.uris = new EventApiUris(baseApiUri); + } + + @WorkerThread + void publishEvent(String eventName, String event, + @ParticleEventVisibility int eventVisibility, int timeToLive) + throws ParticleCloudException { + + boolean isPrivate = eventVisibility != ParticleEventVisibility.PUBLIC; + try { + cloudApi.publishEvent(eventName, event, isPrivate, timeToLive); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + @WorkerThread + long subscribeToAllEvents(@Nullable String eventNamePrefix, + ParticleEventHandler handler) throws IOException { + return subscribeToEventWithUri(uris.buildAllEventsUri(eventNamePrefix), handler); + } + + @WorkerThread + long subscribeToMyDevicesEvents(@Nullable String eventNamePrefix, + ParticleEventHandler handler) throws IOException { + return subscribeToEventWithUri(uris.buildMyDevicesEventUri(eventNamePrefix), handler); + } + + @WorkerThread + long subscribeToDeviceEvents(@Nullable String eventNamePrefix, String deviceID, + ParticleEventHandler eventHandler) throws IOException { + return subscribeToEventWithUri( + uris.buildSingleDeviceEventUri(eventNamePrefix, deviceID), + eventHandler); + } + + @WorkerThread + void unsubscribeFromEventWithID(long eventListenerID) throws ParticleCloudException { + synchronized (eventReaders) { + EventReader reader = eventReaders.get(eventListenerID); + if (reader == null) { + log.w("No event listener subscription found for ID '" + eventListenerID + "'!"); + return; + } + eventReaders.remove(eventListenerID); + try { + reader.stopListening(); + } catch (IOException e) { + // handling the exception here instead of putting it in the method signature + // is inconsistent, but SDK consumers aren't going to care about receiving + // this exception, so just swallow it here. + log.w("Error while trying to stop event listener", e); + } + } + } + + @WorkerThread + void unsubscribeFromEventWithHandler(SimpleParticleEventHandler handler) throws ParticleCloudException { + synchronized (eventReaders) { + for (int i = 0; i < eventReaders.size(); i++) { + EventReader reader = eventReaders.valueAt(i); + + if (reader.handler == handler) { + eventReaders.remove(i); + try { + reader.stopListening(); + } catch (IOException e) { + // handling the exception here instead of putting it in the method signature + // is inconsistent, but SDK consumers aren't going to care about receiving + // this exception, so just swallow it here. + log.w("Error while trying to stop event listener", e); + } + return; + } + } + } + } + + private long subscribeToEventWithUri(Uri uri, ParticleEventHandler handler) throws IOException { + synchronized (eventReaders) { + + long subscriptionId = subscriptionIdGenerator.getAndIncrement(); + EventReader reader = new EventReader(handler, executor, gson, uri, eventSourceFactory); + eventReaders.put(subscriptionId, reader); + + log.d("Created event subscription with ID " + subscriptionId + " for URI " + uri); + + reader.startListening(); + + return subscriptionId; + } + } + + + private static class EventReader { + + final ParticleEventHandler handler; + final SseEventSource sseEventSource; + final ExecutorService executor; + final Gson gson; + + volatile Future future; + + private EventReader(ParticleEventHandler handler, ExecutorService executor, Gson gson, + Uri uri, SseEventSourceFactory factory) { + this.handler = handler; + this.executor = executor; + this.gson = gson; + try { + sseEventSource = factory.createEventSource(URI.create(uri.toString())); + } catch (URISyntaxException e) { + // I don't like throwing exceptions in constructors, but this URI shouldn't be in + // the wrong format... + throw new RuntimeException(e); + } + } + + void startListening() throws IOException { + sseEventSource.connect(); + final SseEventReader sseEventReader = sseEventSource.getEventReader(); + future = executor.submit(() -> startHandlingEvents(sseEventReader)); + } + + void stopListening() throws IOException { + future.cancel(false); + sseEventSource.close(); + } + + + private void startHandlingEvents(SseEventReader sseEventReader) { + SseEventType type; + try { + type = sseEventReader.next(); + while (type != SseEventType.EOS) { + + if (type != null && type.equals(SseEventType.DATA)) { + CharSequence data = sseEventReader.getData(); + String asStr = data.toString(); + + ParticleEvent event = gson.fromJson(asStr, ParticleEvent.class); + + try { + handler.onEvent(sseEventReader.getName(), event); + } catch (Exception ex) { + handler.onEventError(ex); + } + } else { + log.w("type null or not data: " + type); + } + type = sseEventReader.next(); + } + } catch (IOException e) { + handler.onEventError(e); + } + } + } + + + // FIXME: Start sharing some of the strings with the constants that need to be defined in ApiDefs + private static class EventApiUris { + + private final String EVENTS = "events"; + + private final Uri allEventsUri; + private final Uri devicesBaseUri; + private final Uri myDevicesEventsUri; + + EventApiUris(Uri baseUri) { + allEventsUri = baseUri.buildUpon().path("/v1/" + EVENTS).build(); + devicesBaseUri = baseUri.buildUpon().path("/v1/devices").build(); + myDevicesEventsUri = devicesBaseUri.buildUpon().appendPath(EVENTS).build(); + } + + Uri buildAllEventsUri(@Nullable String eventNamePrefix) { + if (truthy(eventNamePrefix)) { + return allEventsUri.buildUpon().appendPath(eventNamePrefix).build(); + } else { + return allEventsUri; + } + } + + Uri buildMyDevicesEventUri(@Nullable String eventNamePrefix) { + if (truthy(eventNamePrefix)) { + return myDevicesEventsUri.buildUpon().appendPath(eventNamePrefix).build(); + } else { + return myDevicesEventsUri; + } + } + + Uri buildSingleDeviceEventUri(@Nullable String eventNamePrefix, String deviceId) { + Builder builder = devicesBaseUri.buildUpon() + .appendPath(deviceId) + .appendPath(EVENTS); + if (truthy(eventNamePrefix)) { + builder.appendPath(eventNamePrefix); + } + return builder.build(); + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/FunctionArgs.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/FunctionArgs.java new file mode 100644 index 0000000..8a27d66 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/FunctionArgs.java @@ -0,0 +1,13 @@ +package io.particle.android.sdk.cloud; + +import com.google.gson.annotations.SerializedName; + +public class FunctionArgs { + + @SerializedName("params") + public final String params; + + public FunctionArgs(String params) { + this.params = params; + } +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParallelDeviceFetcher.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParallelDeviceFetcher.java new file mode 100644 index 0000000..307f4ab --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParallelDeviceFetcher.java @@ -0,0 +1,122 @@ +package io.particle.android.sdk.cloud; + +import android.support.annotation.CheckResult; +import android.support.annotation.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ApiDefs.CloudApi; +import io.particle.android.sdk.cloud.Responses.Models; +import io.particle.android.sdk.cloud.Responses.Models.CompleteDevice; +import io.particle.android.sdk.cloud.Responses.Models.SimpleDevice; + +import static io.particle.android.sdk.utils.Py.list; + +/** + * Does parallel fetching of {@link Models.CompleteDevice} + * + * FIXME: review this solution + */ +@ParametersAreNonnullByDefault +class ParallelDeviceFetcher { + + static class DeviceFetchResult { + + final String deviceId; + /** + * Will be null if device could not be fetched. + */ + @Nullable + final CompleteDevice fetchedDevice; + + DeviceFetchResult(String deviceId, @Nullable CompleteDevice fetchedDevice) { + this.deviceId = deviceId; + this.fetchedDevice = fetchedDevice; + } + } + + + // FIXME: insert slower API here + static ParallelDeviceFetcher newFetcherUsingExecutor(ExecutorService executor) { + return new ParallelDeviceFetcher(executor); + } + + + private final ExecutorService executor; + + private ParallelDeviceFetcher(ExecutorService executor) { + this.executor = executor; + } + + // FIXME: ugh, so lame. Figure out the smarter way to do per-device fetch timeouts + // without having to resort to two Retrofit API instances. look into jsr166 ForkJoinPool + // or similar (since we can't use the Java 7 one until API 21...) + /** + * Fetch the devices in parallel. Ordering of results not guaranteed to be preserved or + * respected in any way. + */ + @CheckResult + Collection fetchDevicesInParallel(Collection simpleDevices, + final CloudApi cloudApi, + int perDeviceTimeoutInSeconds) { + // Assemble the list of Callables + List> callables = list(); + for (final SimpleDevice device : simpleDevices) { + callables.add(new Callable() { + @Override + public DeviceFetchResult call() throws Exception { + return getDevice(cloudApi, device.id); + } + }); + } + + + // Submit callables, receive list of Futures. invokeAll() will block until they finish. + List> futures = list(); + try { + long timeout = perDeviceTimeoutInSeconds * simpleDevices.size(); + futures = executor.invokeAll(callables, timeout, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // FIXME: think about what to do in this implausible(?) scenario, or how to avoid it. + e.printStackTrace(); + } + + + // turn the results into something usable. + List results = list(); + for (Future future : futures) { + try { + DeviceFetchResult result = future.get(); + if (result != null) { + results.add(result); + } + } catch (InterruptedException | ExecutionException e) { + // FIXME: see above; think more about what to do in this scenario, or how to avoid it. + e.printStackTrace(); + } + } + + return results; + } + + + private DeviceFetchResult getDevice(CloudApi cloudApi, String deviceID) { + CompleteDevice device = null; + try { + device = cloudApi.getDevice(deviceID); + } catch (Exception e) { + // doesn't matter why it fails, just don't abort the whole operation because of it + } + return new DeviceFetchResult(deviceID, device); + } + + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleAccessToken.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleAccessToken.java new file mode 100644 index 0000000..323eaab --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleAccessToken.java @@ -0,0 +1,195 @@ +package io.particle.android.sdk.cloud; + + +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.Nullable; + +import java.lang.ref.WeakReference; +import java.util.Date; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.persistance.SensitiveDataStorage; +import io.particle.android.sdk.utils.EZ; +import io.particle.android.sdk.utils.Py; +import io.particle.android.sdk.utils.TLog; + + +@ParametersAreNonnullByDefault +public class ParticleAccessToken { + + + public interface ParticleAccessTokenDelegate { + + void accessTokenExpiredAt(ParticleAccessToken token, Date expirationDate); + + } + + + public static synchronized ParticleAccessToken fromNewSession(Responses.LogInResponse logInResponse) { + if (logInResponse == null + || !Py.truthy(logInResponse.accessToken) + || !"bearer".equalsIgnoreCase(logInResponse.tokenType)) { + throw new IllegalArgumentException("Invalid LogInResponse: " + logInResponse); + } + + long expirationMillis = logInResponse.expiresInSeconds * 1000; + Date expirationDate = new Date(System.currentTimeMillis() + expirationMillis); + return fromTokenData(expirationDate, logInResponse.accessToken); + } + + + @Nullable + public static synchronized ParticleAccessToken fromSavedSession() { + SensitiveDataStorage sensitiveDataStorage = SDKGlobals.getSensitiveDataStorage(); + String accessToken = sensitiveDataStorage.getToken(); + Date expirationDate = sensitiveDataStorage.getTokenExpirationDate(); + + // are either of the fields "falsey" or has the expr date passed? + if (!Py.truthy(accessToken) || !Py.truthy(expirationDate) || expirationDate.before(new Date())) { + return null; + } + + ParticleAccessToken token = new ParticleAccessToken(accessToken, expirationDate, + new Handler(Looper.getMainLooper())); + token.scheduleExpiration(); + return token; + } + + + public static synchronized ParticleAccessToken fromTokenData(Date expirationDate, String accessToken) { + SensitiveDataStorage sensitiveDataStorage = SDKGlobals.getSensitiveDataStorage(); + sensitiveDataStorage.saveToken(accessToken); + sensitiveDataStorage.saveTokenExpirationDate(expirationDate); + + ParticleAccessToken token = new ParticleAccessToken(accessToken, expirationDate, + new Handler(Looper.getMainLooper())); + token.scheduleExpiration(); + return token; + } + + + /** + * Remove access token session data from keychain + */ + public static synchronized void removeSession() { + SensitiveDataStorage sensitiveDataStorage = SDKGlobals.getSensitiveDataStorage(); + sensitiveDataStorage.resetToken(); + sensitiveDataStorage.resetTokenExpirationDate(); + } + + private static final TLog log = TLog.get(ParticleAccessToken.class); + + // how many seconds before expiry date will a token be considered expired + // (0 = expire on expiry date, 24*60*60 = expire a day before) + // FIXME: should this be considered configurable? + private static final int ACCESS_TOKEN_EXPIRY_MARGIN = 0; + + private final Handler handler; + + private String accessToken; + private Date expiryDate; + + private volatile Runnable expirationRunnable; + private volatile ParticleAccessTokenDelegate delegate; + + private ParticleAccessToken(String accessToken, Date expiryDate, Handler handler) { + this.accessToken = accessToken; + this.expiryDate = expiryDate; + this.handler = handler; + } + + /** + * Access token string to be used when calling cloud API + * + * @return null if token is expired. + */ + public String getAccessToken() { + if (expiryDate.getTime() + ACCESS_TOKEN_EXPIRY_MARGIN < System.currentTimeMillis()) { + return null; + } + return accessToken; + } + + /** + * Delegate to receive didExpireAt method call whenever a token is detected as expired + */ + public ParticleAccessTokenDelegate getDelegate() { + return delegate; + } + + /** + * Set the delegate described in #getDelegate() + */ + public void setDelegate(ParticleAccessTokenDelegate delegate) { + this.delegate = delegate; + } + + private void onExpiration() { + log.d("Entering onExpiration()"); + this.expirationRunnable = null; + + if (this.delegate == null) { + log.w("Token expiration delegate is null"); + this.accessToken = null; + return; + } + + // ensure that we don't call accessTokenExpiredAt() on the main thread, since + // the delegate (in the default impl) will make a call to try logging back + // in, but making network calls on the main thread is doubleplus ungood. + // (It'll throw an exception if you even try this, as well it should!) + EZ.runAsync(new Runnable() { + @Override + public void run() { + delegate.accessTokenExpiredAt(ParticleAccessToken.this, expiryDate); + } + }); + } + + private void scheduleExpiration() { + long delay = expiryDate.getTime() - System.currentTimeMillis(); + log.d("Scheduling token expiration for " + expiryDate + " (" + delay + "ms."); + handler.postDelayed(new ExpirationHandler(this), delay); + } + + // visible because I don't want to completely trust the finalizer to call this... + void cancelExpiration() { + if (expirationRunnable != null) { + handler.removeCallbacks(expirationRunnable); + expirationRunnable = null; + } + } + + // FIXME: finalizers are a _last resort_. Look for something better. + @Override + protected void finalize() throws Throwable { + cancelExpiration(); + super.finalize(); + } + + + private static class ExpirationHandler implements Runnable { + + final WeakReference tokenRef; + + private ExpirationHandler(ParticleAccessToken token) { + this.tokenRef = new WeakReference<>(token); + } + + + @Override + public void run() { + log.d("Running token expiration handler..."); + ParticleAccessToken token = tokenRef.get(); + if (token == null) { + log.d("...but the token was null, doing nothing."); + return; + } + token.onExpiration(); + tokenRef.clear(); + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloud.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloud.java new file mode 100644 index 0000000..05fc17c --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloud.java @@ -0,0 +1,781 @@ +package io.particle.android.sdk.cloud; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.util.ArrayMap; + +import com.google.gson.Gson; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ExecutorService; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ApiDefs.CloudApi; +import io.particle.android.sdk.cloud.ParallelDeviceFetcher.DeviceFetchResult; +import io.particle.android.sdk.cloud.ParticleDevice.ParticleDeviceType; +import io.particle.android.sdk.cloud.ParticleDevice.VariableType; +import io.particle.android.sdk.cloud.Responses.Models; +import io.particle.android.sdk.cloud.Responses.Models.CompleteDevice; +import io.particle.android.sdk.cloud.Responses.Models.SimpleDevice; +import io.particle.android.sdk.cloud.models.DeviceStateChange; +import io.particle.android.sdk.cloud.models.SignUpInfo; +import io.particle.android.sdk.persistance.AppDataStorage; +import io.particle.android.sdk.utils.Funcy; +import io.particle.android.sdk.utils.Funcy.Func; +import io.particle.android.sdk.utils.Py.PySet; +import io.particle.android.sdk.utils.TLog; +import retrofit.RetrofitError; + +import static io.particle.android.sdk.utils.Py.all; +import static io.particle.android.sdk.utils.Py.list; +import static io.particle.android.sdk.utils.Py.set; +import static io.particle.android.sdk.utils.Py.truthy; + + +// FIXME: move device state management out to another class +// FIXME: move some of the type conversion junk out of this into another class, too + +// this is an SDK; it's expected it won't reference all its own methods +@SuppressWarnings({"UnusedDeclaration"}) +@ParametersAreNonnullByDefault +public class ParticleCloud { + + private static final TLog log = TLog.get(ParticleCloud.class); + + /** + * Singleton instance of ParticleCloud class + * + * @return ParticleCloud + * @deprecated use {@link ParticleCloudSDK#getCloud()} instead. This interface will be removed + * some time before the 1.0 release. + */ + @Deprecated + public synchronized static ParticleCloud get(Context context) { + log.w("ParticleCloud.get() is deprecated and will be removed before the 1.0 release. " + + "Use ParticleCloudSDK.getCloud() instead!"); + if (!ParticleCloudSDK.isInitialized()) { + ParticleCloudSDK.init(context); + } + return ParticleCloudSDK.getCloud(); + } + + private final ApiDefs.CloudApi mainApi; + private final ApiDefs.IdentityApi identityApi; + // FIXME: document why this exists (and try to make it not exist...) + private final ApiDefs.CloudApi deviceFastTimeoutApi; + private final AppDataStorage appDataStorage; + private final TokenDelegate tokenDelegate = new TokenDelegate(); + private final LocalBroadcastManager broadcastManager; + private final EventsDelegate eventsDelegate; + private final ParallelDeviceFetcher parallelDeviceFetcher; + + private final Map devices = new ArrayMap<>(); + + // We should be able to mark these both @Nullable, but Android Studio has been incorrectly + // inferring that these could be null in code blocks which _directly follow a null check_. + // Try again later after a few more releases, I guess... +// @Nullable + private volatile ParticleAccessToken token; + // @Nullable + private volatile ParticleUser user; + + ParticleCloud(Uri schemeAndHostname, + ApiDefs.CloudApi mainApi, + ApiDefs.IdentityApi identityApi, + ApiDefs.CloudApi perDeviceFastTimeoutApi, + AppDataStorage appDataStorage, LocalBroadcastManager broadcastManager, + Gson gson, ExecutorService executor) { + this.mainApi = mainApi; + this.identityApi = identityApi; + this.deviceFastTimeoutApi = perDeviceFastTimeoutApi; + this.appDataStorage = appDataStorage; + this.broadcastManager = broadcastManager; + this.user = ParticleUser.fromSavedSession(); + this.token = ParticleAccessToken.fromSavedSession(); + if (this.token != null) { + this.token.setDelegate(new TokenDelegate()); + } + this.eventsDelegate = new EventsDelegate(mainApi, schemeAndHostname, gson, executor, this); + this.parallelDeviceFetcher = ParallelDeviceFetcher.newFetcherUsingExecutor(executor); + } + + //region general public API + + /** + * Current session access token string. Can be null. + */ + @Nullable + public String getAccessToken() { + return (this.token == null) ? null : this.token.getAccessToken(); + } + + public void setAccessToken(String tokenString, Date expirationDate) { + ParticleAccessToken.removeSession(); + this.token = ParticleAccessToken.fromTokenData(expirationDate, tokenString); + this.token.setDelegate(tokenDelegate); + } + + /** + * Currently logged in user name, or null if no session exists + */ + @Nullable + public String getLoggedInUsername() { + return all(this.token, this.user) ? this.user.getUser() : null; + } + + public boolean isLoggedIn() { + return getLoggedInUsername() != null; + } + + /** + * Login with existing account credentials to Particle cloud + * + * @param user User name, must be a valid email address + * @param password Password + */ + @WorkerThread + public void logIn(String user, String password) throws ParticleCloudException { + try { + Responses.LogInResponse response = identityApi.logIn("password", user, password); + onLogIn(response, user, password); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Sign up with new account credentials to Particle cloud + * + * @param user Required user name, must be a valid email address + * @param password Required password + */ + @WorkerThread + public void signUpWithUser(String user, String password) throws ParticleCloudException { + try { + identityApi.signUp(new SignUpInfo(user, password)); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Sign up with new account credentials to Particle cloud + * + * @param signUpInfo Required sign up information, must contain a valid email address and password + */ + @WorkerThread + public void signUpWithUser(SignUpInfo signUpInfo) throws ParticleCloudException { + try { + identityApi.signUp(signUpInfo); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Create new customer account on the Particle cloud and log in + * + * @param email Required user name, must be a valid email address + * @param password Required password + * @param orgSlug Organization slug to use + */ + @WorkerThread + public void signUpAndLogInWithCustomer(String email, String password, String orgSlug) + throws ParticleCloudException { + try { + signUpAndLogInWithCustomer(new SignUpInfo(email, password), orgSlug); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Create new customer account on the Particle cloud and log in + * + * @param signUpInfo Required sign up information, must contain a valid email address and password + * @param orgSlug Organization slug to use + */ + @WorkerThread + public void signUpAndLogInWithCustomer(SignUpInfo signUpInfo, String orgSlug) + throws ParticleCloudException { + if (!all(signUpInfo.getUsername(), signUpInfo.getPassword(), orgSlug)) { + throw new IllegalArgumentException( + "Email, password, and organization must all be specified"); + } + + signUpInfo.setGrantType("client_credentials"); + try { + Responses.LogInResponse response = identityApi.signUpAndLogInWithCustomer(signUpInfo, orgSlug); + onLogIn(response, signUpInfo.getUsername(), signUpInfo.getPassword()); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Logout user, remove session data + */ + public void logOut() { + if (token != null) { + token.cancelExpiration(); + } + ParticleUser.removeSession(); + ParticleAccessToken.removeSession(); + token = null; + user = null; + } + + /** + * Get an array of instances of all user's claimed devices + */ + @WorkerThread + public List getDevices() throws ParticleCloudException { + List simpleDevices; + try { + simpleDevices = mainApi.getDevices(); + + appDataStorage.saveUserHasClaimedDevices(truthy(simpleDevices)); + + List result = list(); + + for (Models.SimpleDevice simpleDevice : simpleDevices) { + ParticleDevice device; + if (simpleDevice.isConnected) { + device = getDevice(simpleDevice.id, false); + } else { + device = getOfflineDevice(simpleDevice); + } + result.add(device); + } + + pruneDeviceMap(simpleDevices); + + return result; + + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + // FIXME: devise a less temporary way to expose this method + // FIXME: stop the duplication that's happening here + // FIXME: ...think harder about this whole thing. This is unique in that it's the only + // operation that could _partially_ succeed. + @WorkerThread + List getDevicesParallel(boolean useShortTimeout) + throws PartialDeviceListResultException, ParticleCloudException { + List simpleDevices; + try { + simpleDevices = mainApi.getDevices(); + appDataStorage.saveUserHasClaimedDevices(truthy(simpleDevices)); + + + // divide up into online and offline + List offlineDevices = list(); + List onlineDevices = list(); + + for (Models.SimpleDevice simpleDevice : simpleDevices) { + List targetList = (simpleDevice.isConnected) + ? onlineDevices + : offlineDevices; + targetList.add(simpleDevice); + } + + + List result = list(); + + // handle the offline devices + for (SimpleDevice offlineDevice : offlineDevices) { + result.add(getOfflineDevice(offlineDevice)); + } + + + // handle the online devices + CloudApi apiToUse = (useShortTimeout) + ? deviceFastTimeoutApi + : mainApi; + // FIXME: don't hardcode this here + int timeoutInSecs = useShortTimeout ? 5 : 35; + Collection results = parallelDeviceFetcher.fetchDevicesInParallel( + onlineDevices, apiToUse, timeoutInSecs); + + // FIXME: make this logic more elegant + boolean shouldThrowIncompleteException = false; + for (DeviceFetchResult fetchResult : results) { + // fetchResult shouldn't be null, but... + // FIXME: eliminate this ambiguity ^^^, it's either possible that it's null, or it isn't. + if (fetchResult == null || fetchResult.fetchedDevice == null) { + shouldThrowIncompleteException = true; + } else { + result.add(getDevice(fetchResult.fetchedDevice, false)); + } + } + + pruneDeviceMap(simpleDevices); + + if (shouldThrowIncompleteException) { + throw new PartialDeviceListResultException(result); + } + + return result; + + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Get a specific device instance by its deviceID + * + * @param deviceID required deviceID + * @return the device instance on success + */ + @WorkerThread + public ParticleDevice getDevice(String deviceID) throws ParticleCloudException { + return getDevice(deviceID, true); + } + + /** + * Claim the specified device to the currently logged in user (without claim code mechanism) + * + * @param deviceID the deviceID + */ + @WorkerThread + public void claimDevice(String deviceID) throws ParticleCloudException { + try { + mainApi.claimDevice(deviceID); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + /** + * Get a short-lived claiming token for transmitting to soon-to-be-claimed device in + * soft AP setup process + * + * @return a claim code string set on success (48 random bytes, base64 encoded + * to 64 ASCII characters) + */ + @WorkerThread + public Responses.ClaimCodeResponse generateClaimCode() throws ParticleCloudException { + try { + // Offer empty string to appease newer OkHttp versions which require a POST body, + // even if it's empty or (as far as the endpoint cares) nonsense + return mainApi.generateClaimCode("okhttp_appeasement"); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + @WorkerThread + public Responses.ClaimCodeResponse generateClaimCodeForOrg(String orgSlug, String productSlug) + throws ParticleCloudException { + try { + // Offer empty string to appease newer OkHttp versions which require a POST body, + // even if it's empty or (as far as the endpoint cares) nonsense + return mainApi.generateClaimCodeForOrg("okhttp_appeasement", orgSlug, productSlug); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + + // TODO: check if any javadoc has been added for this method in the iOS SDK + @WorkerThread + public void requestPasswordReset(String email) throws ParticleCloudException { + try { + identityApi.requestPasswordReset(email); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + } + //endregion + + + //region Events pub/sub methods + + /** + * Subscribe to events from one specific device. If the API user has the device claimed, then + * she will receive all events, public and private, published by that device. If the API user + * does not own the device she will only receive public events. + * + * @param eventName The name for the event + * @param event A JSON-formatted string to use as the event payload + * @param eventVisibility An IntDef "enum" determining the visibility of the event + * @param timeToLive TTL, or Time To Live: a piece of event metadata representing the + * number of seconds that the event data is still considered relevant. + * After the TTL has passed, event listeners should consider the + * information stale or out of date. + * e.g.: an outdoor temperature reading might have a TTL of somewhere + * between 600 (10 minutes) and 1800 (30 minutes). The geolocation of a + * large piece of farm equipment which remains stationary most of the + * time but may be moved to a different field once in a while might + * have a TTL of 86400 (24 hours). + */ + @WorkerThread + public void publishEvent(String eventName, String event, + @ParticleEventVisibility int eventVisibility, int timeToLive) + throws ParticleCloudException { + eventsDelegate.publishEvent(eventName, event, eventVisibility, timeToLive); + } + + /** + * Subscribe to the firehose of public events, plus all private events published by + * the devices the API user owns. + * + * @param eventNamePrefix A string to filter on for events. If null, all events will be matched. + * @param handler The ParticleEventHandler to receive the events + * @return a unique subscription ID for the eventListener that's been registered. This ID is + * used to unsubscribe this event listener later. + */ + @WorkerThread + public long subscribeToAllEvents(@Nullable String eventNamePrefix, ParticleEventHandler handler) + throws IOException { + return eventsDelegate.subscribeToAllEvents(eventNamePrefix, handler); + } + + /** + * Subscribe to all events, public and private, published by devices owned by the logged-in account. + *

    + * see {@link #subscribeToAllEvents(String, ParticleEventHandler)} for info on the + * arguments and return value. + */ + @WorkerThread + public long subscribeToMyDevicesEvents(@Nullable String eventNamePrefix, + ParticleEventHandler handler) + throws IOException { + return eventsDelegate.subscribeToMyDevicesEvents(eventNamePrefix, handler); + } + + /** + * Subscribe to events from a specific device. + *

    + * If the API user has claimed the device, then she will receive all events, public and private, + * published by this device. If the API user does not own the device, she will only + * receive public events. + * + * @param deviceID the device to listen to events from + *

    + * see {@link #subscribeToAllEvents(String, ParticleEventHandler)} for info on the + * arguments and return value. + */ + @WorkerThread + public long subscribeToDeviceEvents(@Nullable String eventNamePrefix, String deviceID, + ParticleEventHandler eventHandler) + throws IOException { + return eventsDelegate.subscribeToDeviceEvents(eventNamePrefix, deviceID, eventHandler); + } + + /** + * Unsubscribe event listener from events. + * + * @param eventListenerID The ID of the event listener you want to unsubscribe from events + */ + @WorkerThread + public void unsubscribeFromEventWithID(long eventListenerID) throws ParticleCloudException { + eventsDelegate.unsubscribeFromEventWithID(eventListenerID); + } + + /** + * Unsubscribe event listener from events. + * + * @param handler Particle event listener you want to unsubscribe from events + */ + @WorkerThread + void unsubscribeFromEventWithHandler(SimpleParticleEventHandler handler) throws ParticleCloudException { + eventsDelegate.unsubscribeFromEventWithHandler(handler); + } + //endregion + + + //region package-only API + @WorkerThread + void unclaimDevice(String deviceId) { + mainApi.unclaimDevice(deviceId); + synchronized (devices) { + devices.remove(deviceId); + } + sendUpdateBroadcast(); + } + + @WorkerThread + void rename(String deviceId, String newName) throws ParticleCloudException { + ParticleDevice particleDevice; + synchronized (devices) { + particleDevice = devices.get(deviceId); + } + DeviceState originalDeviceState = particleDevice.deviceState; + + DeviceState stateWithNewName = DeviceState.withNewName(originalDeviceState, newName); + updateDeviceState(stateWithNewName, true); + try { + mainApi.nameDevice(originalDeviceState.deviceId, newName); + } catch (RetrofitError e) { + // oops, change the name back. + updateDeviceState(originalDeviceState, true); + throw new ParticleCloudException(e); + } + } + + @Deprecated + @WorkerThread + void changeDeviceName(String deviceId, String newName) throws ParticleCloudException { + rename(deviceId, newName); + } + + @WorkerThread + // Called when a cloud API call receives a result in which the "coreInfo.connected" is false + void onDeviceNotConnected(DeviceState deviceState) { + DeviceState newState = DeviceState.withNewConnectedState(deviceState, false); + updateDeviceState(newState, true); + } + + // FIXME: exposing this is weak, figure out something better + void notifyDeviceChanged() { + sendUpdateBroadcast(); + } + + void sendSystemEventBroadcast(DeviceStateChange stateChange) { + Intent intent = new Intent(BroadcastContract.BROADCAST_SYSTEM_EVENT); + intent.putExtra("event", stateChange); + broadcastManager.sendBroadcast(intent); + } + + // this is accessible at the package level for access from ParticleDevice's Parcelable impl + ParticleDevice getDeviceFromState(DeviceState deviceState) { + synchronized (devices) { + if (devices.containsKey(deviceState.deviceId)) { + return devices.get(deviceState.deviceId); + } else { + ParticleDevice device = new ParticleDevice(mainApi, this, deviceState); + devices.put(deviceState.deviceId, device); + return device; + } + } + } + //endregion + + + //region private API + @WorkerThread + private ParticleDevice getDevice(String deviceID, boolean sendUpdate) + throws ParticleCloudException { + CompleteDevice deviceCloudModel; + try { + deviceCloudModel = mainApi.getDevice(deviceID); + } catch (RetrofitError error) { + throw new ParticleCloudException(error); + } + + return getDevice(deviceCloudModel, sendUpdate); + } + + private ParticleDevice getDevice(CompleteDevice deviceModel, boolean sendUpdate) { + DeviceState newDeviceState = fromCompleteDevice(deviceModel); + ParticleDevice device = getDeviceFromState(newDeviceState); + updateDeviceState(newDeviceState, sendUpdate); + return device; + } + + private ParticleDevice getOfflineDevice(Models.SimpleDevice offlineDevice) { + DeviceState newDeviceState = fromSimpleDeviceModel(offlineDevice); + ParticleDevice device = getDeviceFromState(newDeviceState); + updateDeviceState(newDeviceState, false); + return device; + } + + private void updateDeviceState(DeviceState newState, boolean sendUpdateBroadcast) { + ParticleDevice device = getDeviceFromState(newState); + device.deviceState = newState; + if (sendUpdateBroadcast) { + sendUpdateBroadcast(); + } + } + + private void sendUpdateBroadcast() { + broadcastManager.sendBroadcast(new Intent(BroadcastContract.BROADCAST_DEVICES_UPDATED)); + } + + private void onLogIn(Responses.LogInResponse response, String user, String password) { + ParticleAccessToken.removeSession(); + this.token = ParticleAccessToken.fromNewSession(response); + this.token.setDelegate(tokenDelegate); + this.user = ParticleUser.fromNewCredentials(user, password); + } + + private DeviceState fromCompleteDevice(CompleteDevice completeDevice) { + // FIXME: we're sometimes getting back nulls in the list of functions... WUT? + // Once analytics are in place, look into adding something here so we know where + // this is coming from. In the meantime, filter out nulls from this list, since that's + // obviously doubleplusungood. + Set functions = set(Funcy.filter(completeDevice.functions, Funcy.notNull())); + Map variables = transformVariables(completeDevice); + + return new DeviceState.DeviceStateBuilder(completeDevice.deviceId, functions, variables) + .name(completeDevice.name) + .cellular(completeDevice.cellular) + .connected(completeDevice.isConnected) + .version(completeDevice.version) + .deviceType(ParticleDeviceType.fromInt(completeDevice.productId)) + .platformId(completeDevice.platformId) + .productId(completeDevice.productId) + .imei(completeDevice.imei) + .currentBuild(completeDevice.currentBuild) + .defaultBuild(completeDevice.defaultBuild) + .ipAddress(completeDevice.ipAddress) + .lastAppName(completeDevice.lastAppName) + .status(completeDevice.status) + .requiresUpdate(completeDevice.requiresUpdate) + .lastHeard(completeDevice.lastHeard) + .build(); + } + + // for offline devices + private DeviceState fromSimpleDeviceModel(Models.SimpleDevice offlineDevice) { + Set functions = new HashSet<>(); + Map variables = new ArrayMap<>(); + + return new DeviceState.DeviceStateBuilder(offlineDevice.id, functions, variables) + .name(offlineDevice.name) + .cellular(offlineDevice.cellular) + .connected(offlineDevice.isConnected) + .version("") + .deviceType(ParticleDeviceType.fromInt(offlineDevice.productId)) + .platformId(offlineDevice.platformId) + .productId(offlineDevice.productId) + .imei(offlineDevice.imei) + .currentBuild(offlineDevice.currentBuild) + .defaultBuild(offlineDevice.defaultBuild) + .ipAddress(offlineDevice.ipAddress) + .lastAppName("") + .status(offlineDevice.status) + .requiresUpdate(false) + .lastHeard(offlineDevice.lastHeard) + .build(); + } + + + private static Map transformVariables(CompleteDevice completeDevice) { + if (completeDevice.variables == null) { + return Collections.emptyMap(); + } + + Map variables = new ArrayMap<>(); + + for (Entry entry : completeDevice.variables.entrySet()) { + if (!all(entry.getKey(), entry.getValue())) { + log.w(String.format( + "Found null key and/or value for variable in device $1%s. key=$2%s, value=$3%s", + completeDevice.name, entry.getKey(), entry.getValue())); + continue; + } + + VariableType variableType = toVariableType.apply(entry.getValue()); + if (variableType == null) { + log.w(String.format("Unknown variable type for device $1%s: '$2%s'", + completeDevice.name, entry.getKey())); + continue; + } + + variables.put(entry.getKey(), variableType); + } + + return variables; + } + + + private void pruneDeviceMap(List latestCloudDeviceList) { + synchronized (devices) { + // make a copy of the current keyset since we mutate `devices` below + PySet currentDeviceIds = set(devices.keySet()); + PySet newDeviceIds = set(Funcy.transformList(latestCloudDeviceList, toDeviceId)); + // quoting the Sets docs for this next operation: + // "The returned set contains all elements that are contained by set1 and + // not contained by set2" + // In short, this set is all the device IDs which we have in our devices map, + // but which we did not hear about in this latest update from the cloud + Set toRemove = currentDeviceIds.getDifference(newDeviceIds); + for (String deviceId : toRemove) { + devices.remove(deviceId); + } + } + } + + + private static final Func toDeviceId = input -> input.id; + + private class TokenDelegate implements ParticleAccessToken.ParticleAccessTokenDelegate { + + @Override + public void accessTokenExpiredAt(final ParticleAccessToken accessToken, Date expirationDate) { + // handle auto-renewal of expired access tokens by internal timer event + // If user is null, don't bother because we have no credentials. + if (user != null) { + try { + logIn(user.getUser(), user.getPassword()); + return; + + } catch (ParticleCloudException e) { + log.e("Error while trying to log in: ", e); + } + } + + ParticleAccessToken.removeSession(); + token = null; + } + } + //endregion + + private static Func toVariableType = value -> { + if (value == null) { + return null; + } + + switch (value) { + case "int32": + return VariableType.INT; + case "double": + return VariableType.DOUBLE; + case "string": + return VariableType.STRING; + default: + return null; + } + }; + + + // FIXME: review and polish this. The more I think about it, the more I like it, but + // make sure it's what we _really_ want. Maybe apply it to the regular getDevices() too? + public static class PartialDeviceListResultException extends Exception { + + final List devices; + + public PartialDeviceListResultException(List devices, Exception cause) { + super(cause); + this.devices = devices; + } + + public PartialDeviceListResultException(List devices, RetrofitError error) { + super(error); + this.devices = devices; + } + + public PartialDeviceListResultException(List devices) { + super("Undefined error while fetching devices"); + this.devices = devices; + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudException.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudException.java new file mode 100644 index 0000000..9700597 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudException.java @@ -0,0 +1,225 @@ +package io.particle.android.sdk.cloud; + + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.utils.EZ; +import io.particle.android.sdk.utils.ParticleInternalStringUtils; +import io.particle.android.sdk.utils.TLog; +import okio.BufferedSource; +import okio.Okio; +import retrofit.RetrofitError; + +import static io.particle.android.sdk.utils.Py.list; + + +// Heavily inspired by RetrofitError, which we are mostly wrapping here, but +// we're making our own exception to make it a checked exception, and to avoid +// tying the API to a particular library used by the API's implementation +@ParametersAreNonnullByDefault +public class ParticleCloudException extends Exception { + + private static final TLog log = TLog.get(ParticleCloudException.class); + + /** Identifies the event kind which triggered a {@link ParticleCloudException}. */ + public enum Kind { + + /** An {@link java.io.IOException} occurred while communicating to the server. */ + NETWORK, + + /** An exception was thrown while (de)serializing a body. */ + CONVERSION, + + /** A non-200 HTTP status code was received from the server. */ + HTTP, + + /** + * An internal error occurred while attempting to execute a request. It is best practice to + * re-throw this exception so your application intentionally crashes. + */ + UNEXPECTED + } + + + public static class ResponseErrorData { + + private final int httpStatusCode; + private final InputStream httpBodyInputStream; + + private String lazyLoadedBody; + private boolean isBodyLoaded; + + ResponseErrorData(int httpStatusCode, InputStream httpBodyInputStream) { + this.httpStatusCode = httpStatusCode; + this.httpBodyInputStream = httpBodyInputStream; + } + + public int getHttpStatusCode() { + return httpStatusCode; + } + + /** + * @return response body as a String, or null if no body was returned. + */ + public String getBody() { + if (!isBodyLoaded) { + isBodyLoaded = true; + lazyLoadedBody = loadBody(); + } + return lazyLoadedBody; + } + + private String loadBody() { + if (httpBodyInputStream == null) { + return null; + } + BufferedSource buffer = null; + try { + buffer = Okio.buffer(Okio.source(httpBodyInputStream)); + return buffer.readUtf8(); + + } catch (IOException e) { + log.i("Error reading HTTP response body: ", e); + return null; + + } finally { + EZ.closeThisThingOrMaybeDont(buffer); + } + } + + } + + + private final RetrofitError innerError; + private final ResponseErrorData responseData; + private boolean checkedForServerErrorMsg = false; + private String serverErrorMessage; + + public ParticleCloudException(Exception exception) { + super(exception); + // FIXME: ugly hack to get around even uglier bug. + this.innerError = RetrofitError.unexpectedError("(URL UNKNOWN)", exception); + this.responseData = null; + } + + ParticleCloudException(RetrofitError innerError) { + this.innerError = innerError; + this.responseData = buildResponseData(innerError); + } + + /** + * Response containing HTTP status code & body. + * + * May be null depending on the nature of the error. + */ + public ResponseErrorData getResponseData() { + return responseData; + } + + /** The event kind which triggered this error. */ + public Kind getKind() { + return Kind.valueOf(innerError.getKind().toString()); + } + + /** + * Any server-provided error message. May be null. + * + * If the server sent multiple errors, they will be concatenated together with newline characters. + * + * @return server-provided error or null + */ + public String getServerErrorMsg() { + if (!checkedForServerErrorMsg) { + checkedForServerErrorMsg = true; + serverErrorMessage = loadServerErrorMsg(); + } + return serverErrorMessage; + } + + /** + * Returns a server provided message, if found, else just returns the result of the inner + * exception's .getMessage() + */ + public String getBestMessage() { + // FIXME: this isn't the right place for user-facing data + if (getKind() == Kind.NETWORK ) { + return "Unable to connect to the server."; + + } else if (getKind() == Kind.UNEXPECTED) { + return "Unknown error communicating with server."; + } + String serverMsg = getServerErrorMsg(); + return (serverMsg == null) ? getMessage() : serverMsg; + } + + private String loadServerErrorMsg() { + if (responseData == null || responseData.getBody() == null) { + return null; + } + try { + JSONObject jsonObject = new JSONObject(responseData.getBody()); + + if (jsonObject.has("error_description")) { + return jsonObject.getString("error_description"); + + } else if (jsonObject.has("errors")) { + List errors = getErrors(jsonObject); + return errors.isEmpty() ? null : ParticleInternalStringUtils.join(errors, '\n'); + + } else if (jsonObject.has("error")) { + return jsonObject.getString("error"); + } + + } catch (JSONException e) { + } + return null; + } + + private List getErrors(JSONObject jsonObject) throws JSONException { + List errors = list(); + JSONArray jsonArray = jsonObject.getJSONArray("errors"); + if (jsonArray == null || jsonArray.length() == 0) { + return errors; + } + for (int i=0; i < jsonArray.length(); i++){ + String msg = null; + + JSONObject msgObj = jsonArray.optJSONObject(i); + if (msgObj != null) { + msg = msgObj.getString("message"); + } else { + msg = jsonArray.get(i).toString(); + } + + errors.add(msg); + } + + return errors; + } + + + private ResponseErrorData buildResponseData(RetrofitError error) { + if (error.getResponse() == null) { + return null; + } + + InputStream in = null; + if (error.getResponse().getBody() != null) { + try { + in = error.getResponse().getBody().in(); + } catch (IOException e) { + // Yo, dawg, I heard you like error handling in your error handling... + } + } + return new ResponseErrorData(error.getResponse().getStatus(), in); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudSDK.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudSDK.java new file mode 100644 index 0000000..5144073 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudSDK.java @@ -0,0 +1,80 @@ +package io.particle.android.sdk.cloud; + +import android.content.Context; +import android.support.annotation.Nullable; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ApiFactory.OauthBasicAuthCredentialsProvider; +import io.particle.android.sdk.utils.TLog; + +/** + * Entry point for the Particle Cloud SDK. + */ +@ParametersAreNonnullByDefault +public class ParticleCloudSDK { + // NOTE: pay attention to the interface, try to ignore the implementation, it's going to change. + + /** + * Initialize the cloud SDK. Must be called somewhere in your Application.onCreate() + * + * (or anywhere else before your first Activity.onCreate() is called) + */ + public static void init(Context ctx) { + initWithParams(ctx, null); + } + + public static void initWithOauthCredentialsProvider( + Context ctx, @Nullable OauthBasicAuthCredentialsProvider oauthProvider) { + initWithParams(ctx, oauthProvider); + } + + public static ParticleCloud getCloud() { + verifyInitCalled(); + return instance.sdkProvider.getParticleCloud(); + } + + + // NOTE: This is closer to the interface I'd like to provide eventually + static void initWithParams(Context ctx, + @Nullable OauthBasicAuthCredentialsProvider oauthProvider) { + if (instance != null) { + log.w("Calling ParticleCloudSDK.init() more than once does not re-initialize the SDK."); + return; + } + + Context appContext = ctx.getApplicationContext(); + SDKProvider sdkProvider = new SDKProvider(appContext, oauthProvider); + instance = new ParticleCloudSDK(sdkProvider); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + static boolean isInitialized() { + return instance != null; + } + + static SDKProvider getSdkProvider() { + verifyInitCalled(); + return instance.sdkProvider; + } + + static void verifyInitCalled() { + if (!isInitialized()) { + throw new IllegalStateException("init not called before using the Particle SDK. " + + "Are you calling ParticleCloudSDK.init() in your Application.onCreate()?"); + } + } + + + private static final TLog log = TLog.get(ParticleCloudSDK.class); + + private static ParticleCloudSDK instance; + + + private final SDKProvider sdkProvider; + + private ParticleCloudSDK(SDKProvider sdkProvider) { + this.sdkProvider = sdkProvider; + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleDevice.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleDevice.java new file mode 100644 index 0000000..158db57 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleDevice.java @@ -0,0 +1,690 @@ +package io.particle.android.sdk.cloud; + +import android.annotation.SuppressLint; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; + +import org.greenrobot.eventbus.EventBus; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.Responses.ReadDoubleVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadIntVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadObjectVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadStringVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadVariableResponse; +import io.particle.android.sdk.cloud.models.DeviceStateChange; +import io.particle.android.sdk.utils.ParticleInternalStringUtils; +import io.particle.android.sdk.utils.Preconditions; +import io.particle.android.sdk.utils.TLog; +import okio.Okio; +import retrofit.RetrofitError; +import retrofit.mime.TypedByteArray; +import retrofit.mime.TypedFile; + +import static io.particle.android.sdk.utils.Py.list; + + +// don't warn about public APIs not being referenced inside this module, or about +// the _default locale_ in a bunch of backend code +@SuppressLint("DefaultLocale") +@SuppressWarnings({"UnusedDeclaration"}) +@ParametersAreNonnullByDefault +public class ParticleDevice implements Parcelable { + + public enum ParticleDeviceType { + CORE, + PHOTON, + ELECTRON; + + public static ParticleDeviceType fromInt(int intValue) { + switch (intValue) { + case 0: + return CORE; + case 10: + return ELECTRON; + case 5: + case 6: + default: + return PHOTON; + } + } + } + + public enum ParticleDeviceState { + CAME_ONLINE, + FLASH_STARTED, + FLASH_SUCCEEDED, + FLASH_FAILED, + APP_HASH_UPDATED, + ENTERED_SAFE_MODE, + SAFE_MODE_UPDATER, + WENT_OFFLINE, + UNKNOWN + } + + public enum VariableType { + INT, + DOUBLE, + STRING + } + + + public static class FunctionDoesNotExistException extends Exception { + + public FunctionDoesNotExistException(String functionName) { + super("Function " + functionName + " does not exist on this device"); + } + } + + + public static class VariableDoesNotExistException extends Exception { + + public VariableDoesNotExistException(String variableName) { + super("Variable " + variableName + " does not exist on this device"); + } + } + + + public enum KnownApp { + TINKER("tinker"); + + private final String appName; + + KnownApp(String appName) { + this.appName = appName; + } + + public String getAppName() { + return appName; + } + } + + private static final int MAX_PARTICLE_FUNCTION_ARG_LENGTH = 63; + + private static final TLog log = TLog.get(ParticleDevice.class); + + private final CopyOnWriteArrayList subscriptions = new CopyOnWriteArrayList<>(); + private final ApiDefs.CloudApi mainApi; + private final ParticleCloud cloud; + + volatile DeviceState deviceState; + + private volatile boolean isFlashing = false; + + ParticleDevice(ApiDefs.CloudApi mainApi, ParticleCloud cloud, DeviceState deviceState) { + this.mainApi = mainApi; + this.cloud = cloud; + this.deviceState = deviceState; + } + + /** + * Device ID string + */ + public String getID() { + return deviceState.deviceId; + } + + /** + * Device name. Device can be renamed in the cloud via #setName(String) + */ + public String getName() { + return deviceState.name; + } + + /** + * Rename the device in the cloud. If renaming fails name will stay the same. + */ + public void setName(String newName) throws ParticleCloudException { + cloud.rename(this.deviceState.deviceId, newName); + } + + /** + * Is device connected to the cloud? + */ + public boolean isConnected() { + return deviceState.isConnected; + } + + /** + * Get an immutable set of all the function names exposed by device + */ + public Set getFunctions() { + // no need for a defensive copy, this is an immutable set + return deviceState.functions; + } + + /** + * Get an immutable map of exposed variables on device with their respective types. + */ + public Map getVariables() { + // no need for a defensive copy, this is an immutable set + return deviceState.variables; + } + + /** + * Device firmware version string + */ + public String getVersion() { + return deviceState.version; + } + + public boolean requiresUpdate() { + return deviceState.requiresUpdate; + } + + public ParticleDeviceType getDeviceType() { + return deviceState.deviceType; + } + + public int getPlatformID() { + return deviceState.platformId; + } + + public int getProductID() { + return deviceState.productId; + } + + public boolean isCellular() { + return deviceState.cellular; + } + + public String getImei() { + return deviceState.imei; + } + + public String getCurrentBuild() { + return deviceState.currentBuild; + } + + public String getDefaultBuild() { + return deviceState.defaultBuild; + } + + public String getIpAddress() { + return deviceState.ipAddress; + } + + public String getLastAppName() { + return deviceState.lastAppName; + } + + public String getStatus() { + return deviceState.status; + } + + public Date getLastHeard() { + return deviceState.lastHeard; + } + + /** + * Return the value for variableName on this Particle device. + *

    + * Unless you specifically require generic handling, it is recommended that you use the + * get(type)Variable methods instead, e.g.: getIntVariable(). + * These type-specific methods don't require extra casting or type checking on your part, and + * they more clearly and succinctly express your intent. + */ + @WorkerThread + public Object getVariable(String variableName) + throws ParticleCloudException, IOException, VariableDoesNotExistException { + + VariableRequester requester = + new VariableRequester(this) { + @Override + ReadObjectVariableResponse callApi(String variableName) { + return mainApi.getVariable(deviceState.deviceId, variableName); + } + }; + + return requester.getVariable(variableName); + } + + /** + * Return the value for variableName as an int. + *

    + * Where practical, this method is recommended over the generic {@link #getVariable(String)}. + * See the javadoc on that method for details. + */ + @WorkerThread + public int getIntVariable(String variableName) throws ParticleCloudException, + IOException, VariableDoesNotExistException, ClassCastException { + + VariableRequester requester = + new VariableRequester(this) { + @Override + ReadIntVariableResponse callApi(String variableName) { + return mainApi.getIntVariable(deviceState.deviceId, variableName); + } + }; + + return requester.getVariable(variableName); + } + + /** + * Return the value for variableName as a String. + *

    + * Where practical, this method is recommended over the generic {@link #getVariable(String)}. + * See the javadoc on that method for details. + */ + @WorkerThread + public String getStringVariable(String variableName) throws ParticleCloudException, + IOException, VariableDoesNotExistException, ClassCastException { + + VariableRequester requester = + new VariableRequester(this) { + @Override + ReadStringVariableResponse callApi(String variableName) { + return mainApi.getStringVariable(deviceState.deviceId, variableName); + } + }; + + return requester.getVariable(variableName); + } + + /** + * Return the value for variableName as a double. + *

    + * Where practical, this method is recommended over the generic {@link #getVariable(String)}. + * See the javadoc on that method for details. + */ + @WorkerThread + public double getDoubleVariable(String variableName) throws ParticleCloudException, + IOException, VariableDoesNotExistException, ClassCastException { + + VariableRequester requester = + new VariableRequester(this) { + @Override + ReadDoubleVariableResponse callApi(String variableName) { + return mainApi.getDoubleVariable(deviceState.deviceId, variableName); + } + }; + + return requester.getVariable(variableName); + } + + + /** + * Call a function on the device + * + * @param functionName Function name + * @param args Array of arguments to pass to the function on the device. + * Arguments must not be more than MAX_PARTICLE_FUNCTION_ARG_LENGTH chars + * in length. If any arguments are longer, a runtime exception will be thrown. + * @return result code: a value of 1 indicates success + */ + @WorkerThread + public int callFunction(String functionName, @Nullable List args) + throws ParticleCloudException, IOException, FunctionDoesNotExistException { + // TODO: check response of calling a non-existent function + if (!deviceState.functions.contains(functionName)) { + throw new FunctionDoesNotExistException(functionName); + } + + // null is accepted here, but it won't be in the Retrofit API call later + if (args == null) { + args = list(); + } + + String argsString = ParticleInternalStringUtils.join(args, ','); + Preconditions.checkArgument(argsString.length() < MAX_PARTICLE_FUNCTION_ARG_LENGTH, + String.format("Arguments '%s' exceed max args length of %d", + argsString, MAX_PARTICLE_FUNCTION_ARG_LENGTH)); + + Responses.CallFunctionResponse response; + try { + response = mainApi.callFunction(deviceState.deviceId, functionName, + new FunctionArgs(argsString)); + } catch (RetrofitError e) { + throw new ParticleCloudException(e); + } + + if (!response.connected) { + cloud.onDeviceNotConnected(deviceState); + throw new IOException("Device is not connected."); + } else { + return response.returnValue; + } + } + + /** + * Call a function on the device + * + * @param functionName Function name + * @return value of the function + */ + @WorkerThread + public int callFunction(String functionName) throws ParticleCloudException, IOException, + FunctionDoesNotExistException { + return callFunction(functionName, null); + } + + /** + * Subscribe to events from this device + * + * @param eventNamePrefix (optional, may be null) a filter to match against for events. If + * null or an empty string, all device events will be received by the handler + * trigger eventHandler + * @param handler The handler for the events received for this subscription. + * @return the subscription ID + * (see {@link ParticleCloud#subscribeToAllEvents(String, ParticleEventHandler)} for more info + */ + public long subscribeToEvents(@Nullable String eventNamePrefix, + ParticleEventHandler handler) + throws IOException { + return cloud.subscribeToDeviceEvents(eventNamePrefix, deviceState.deviceId, handler); + } + + /** + * Unsubscribe from events. + * + * @param eventListenerID The ID of the subscription to be cancelled. (returned from + * {@link #subscribeToEvents(String, ParticleEventHandler)} + */ + public void unsubscribeFromEvents(long eventListenerID) throws ParticleCloudException { + cloud.unsubscribeFromEventWithID(eventListenerID); + } + + /** + * Remove device from current logged in user account + */ + @WorkerThread + public void unclaim() throws ParticleCloudException { + try { + cloud.unclaimDevice(deviceState.deviceId); + } catch (RetrofitError e) { + throw new ParticleCloudException(e); + } + } + + public boolean isRunningTinker() { + List lowercaseFunctions = list(); + for (String func : deviceState.functions) { + lowercaseFunctions.add(func.toLowerCase()); + } + List tinkerFunctions = list("analogread", "analogwrite", "digitalread", "digitalwrite"); + return (isConnected() && lowercaseFunctions.containsAll(tinkerFunctions)); + } + + public boolean isFlashing() { + return isFlashing; + } + + @WorkerThread + public void flashKnownApp(final KnownApp knownApp) throws ParticleCloudException { + performFlashingChange(() -> mainApi.flashKnownApp(deviceState.deviceId, knownApp.appName)); + } + + @WorkerThread + public void flashBinaryFile(final File file) throws ParticleCloudException { + performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, + new TypedFile("application/octet-stream", file))); + } + + @WorkerThread + public void flashBinaryFile(InputStream stream) throws ParticleCloudException, IOException { + final byte[] bytes = Okio.buffer(Okio.source(stream)).readByteArray(); + performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, new TypedFakeFile(bytes))); + } + + @WorkerThread + public void flashCodeFile(final File file) throws ParticleCloudException { + performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, + new TypedFile("multipart/form-data", file))); + } + + + @WorkerThread + public void flashCodeFile(InputStream stream) throws ParticleCloudException, IOException { + final byte[] bytes = Okio.buffer(Okio.source(stream)).readByteArray(); + performFlashingChange(() -> mainApi.flashFile(deviceState.deviceId, new TypedFakeFile(bytes, "multipart/form-data", "code.ino"))); + } + + public ParticleCloud getCloud() { + return cloud; + } + + @WorkerThread + public void refresh() throws ParticleCloudException { + // just calling this get method will update everything as expected. + cloud.getDevice(deviceState.deviceId); + } + + private interface FlashingChange { + void executeFlashingChange() throws RetrofitError; + } + + // FIXME: ugh. these "cloud.notifyDeviceChanged();" calls are a hint that flashing maybe + // should just live in a class of its own, or that it should just be a delegate on + // ParticleCloud. Review this later. + private void performFlashingChange(FlashingChange flashingChange) throws ParticleCloudException { + try { + flashingChange.executeFlashingChange(); + //listens for flashing event, on success unsubscribe from listening. + subscribeToSystemEvent("spark/flash/status", new SimpleParticleEventHandler() { + @Override + public void onEvent(String eventName, ParticleEvent particleEvent) { + if ("success".equals(particleEvent.dataPayload)) { + isFlashing = false; + try { + ParticleDevice.this.refresh(); + cloud.unsubscribeFromEventWithHandler(this); + } catch (ParticleCloudException e) { + // not much else we can really do here... + log.w("Unable to reset flashing state for %s" + deviceState.deviceId, e); + } + } else { + isFlashing = true; + } + cloud.notifyDeviceChanged(); + } + }); + } catch (RetrofitError | IOException e) { + throw new ParticleCloudException(e); + } + } + + /** + * Subscribes to system events of current device. Events emitted to EventBus listener. + * + * @throws ParticleCloudException Failure to subscribe to system events. + * @see EventBus + */ + @MainThread + public void subscribeToSystemEvents() throws ParticleCloudException { + try { + EventBus eventBus = EventBus.getDefault(); + subscriptions.add(subscribeToSystemEvent("spark/status", (eventName, particleEvent) -> + sendUpdateStatusChange(eventBus, particleEvent.dataPayload))); + subscriptions.add(subscribeToSystemEvent("spark/flash/status", (eventName, particleEvent) -> + sendUpdateFlashChange(eventBus, particleEvent.dataPayload))); + subscriptions.add(subscribeToSystemEvent("spark/device/app-hash", (eventName, particleEvent) -> + sendSystemEventBroadcast(new DeviceStateChange(ParticleDevice.this, + ParticleDeviceState.APP_HASH_UPDATED), eventBus))); + subscriptions.add(subscribeToSystemEvent("spark/status/safe-mode", (eventName, particleEvent) -> + sendSystemEventBroadcast(new DeviceStateChange(ParticleDevice.this, + ParticleDeviceState.SAFE_MODE_UPDATER), eventBus))); + subscriptions.add(subscribeToSystemEvent("spark/safe-mode-updater/updating", (eventName, particleEvent) -> + sendSystemEventBroadcast(new DeviceStateChange(ParticleDevice.this, + ParticleDeviceState.ENTERED_SAFE_MODE), eventBus))); + } catch (IOException e) { + log.d("Failed to auto-subscribe to system events"); + throw new ParticleCloudException(e); + } + } + + private void sendSystemEventBroadcast(DeviceStateChange deviceStateChange, EventBus eventBus) { + cloud.sendSystemEventBroadcast(deviceStateChange); + eventBus.post(deviceStateChange); + } + + /** + * Unsubscribes from system events of current device. + * + * @throws ParticleCloudException Failure to unsubscribe from system events. + */ + public void unsubscribeFromSystemEvents() throws ParticleCloudException { + for (Long subscriptionId : subscriptions) { + unsubscribeFromEvents(subscriptionId); + } + } + + private long subscribeToSystemEvent(String eventNamePrefix, + SimpleParticleEventHandler + particleEventHandler) throws IOException { + //Error would be handled in same way for every event name prefix, thus only simple onEvent listener is needed + return subscribeToEvents(eventNamePrefix, new ParticleEventHandler() { + @Override + public void onEvent(String eventName, ParticleEvent particleEvent) { + particleEventHandler.onEvent(eventName, particleEvent); + } + + @Override + public void onEventError(Exception e) { + log.d("Event error in system event handler"); + } + }); + } + + private void sendUpdateStatusChange(EventBus eventBus, String data) { + DeviceStateChange deviceStateChange = null; + switch (data) { + case "online": + sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.CAME_ONLINE), + eventBus); + break; + case "offline": + sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.WENT_OFFLINE), + eventBus); + break; + } + } + + private void sendUpdateFlashChange(EventBus eventBus, String data) { + DeviceStateChange deviceStateChange = null; + switch (data) { + case "started": + sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.FLASH_STARTED), + eventBus); + break; + case "success": + sendSystemEventBroadcast(new DeviceStateChange(this, ParticleDeviceState.FLASH_SUCCEEDED), + eventBus); + break; + } + } + + @Override + public String toString() { + return "ParticleDevice{" + + "deviceId=" + deviceState.deviceId + + ", isConnected=" + deviceState.isConnected + + '}'; + } + + //region Parcelable + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(deviceState, flags); + } + + @Override + public int describeContents() { + return 0; + } + + + public static final Creator CREATOR = new Creator() { + @Override + public ParticleDevice createFromParcel(Parcel in) { + SDKProvider sdkProvider = ParticleCloudSDK.getSdkProvider(); + DeviceState deviceState = in.readParcelable(DeviceState.class.getClassLoader()); + return sdkProvider.getParticleCloud().getDeviceFromState(deviceState); + } + + @Override + public ParticleDevice[] newArray(int size) { + return new ParticleDevice[size]; + } + }; + //endregion + + + private static class TypedFakeFile extends TypedByteArray { + + private final String fileName; + + /** + * Constructs a new typed byte array. Sets mimeType to {@code application/unknown} if absent. + * + * @throws NullPointerException if bytes are null + */ + public TypedFakeFile(byte[] bytes) { + this(bytes, "application/octet-stream", "tinker_firmware.bin"); + } + + public TypedFakeFile(byte[] bytes, String mimeType, String fileName) { + super(mimeType, bytes); + this.fileName = fileName; + } + + @Override + public String fileName() { + return fileName; + } + } + + + private static abstract class VariableRequester> { + + @WorkerThread + abstract R callApi(String variableName); + + + private final ParticleDevice device; + + VariableRequester(ParticleDevice device) { + this.device = device; + } + + + @WorkerThread + T getVariable(String variableName) + throws ParticleCloudException, IOException, VariableDoesNotExistException { + + if (!device.deviceState.variables.containsKey(variableName)) { + throw new VariableDoesNotExistException(variableName); + } + + R reply; + try { + reply = callApi(variableName); + } catch (RetrofitError e) { + throw new ParticleCloudException(e); + } + + if (!reply.coreInfo.connected) { + // FIXME: we should be doing this "connected" check on _any_ reply that comes back + // with a "coreInfo" block. + device.cloud.onDeviceNotConnected(device.deviceState); + throw new IOException("Device is not connected."); + } else { + return reply.result; + } + } + + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEvent.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEvent.java new file mode 100644 index 0000000..01f7fd9 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEvent.java @@ -0,0 +1,35 @@ +package io.particle.android.sdk.cloud; + +import com.google.gson.annotations.SerializedName; + +import java.util.Date; + +import javax.annotation.ParametersAreNonnullByDefault; + + +// Normally it's bad form to use network data models as API data models, but considering that +// for the moment, they'd be a 1:1 mapping, we'll just reuse this data model class. If the +// network API changes, then we can write new classes for the network API models, without +// impacting the public API of the SDK. +@ParametersAreNonnullByDefault +public class ParticleEvent { + + @SerializedName("coreid") + public final String deviceId; + + @SerializedName("data") + public final String dataPayload; + + @SerializedName("published_at") + public final Date publishedAt; + + @SerializedName("ttl") + public final int timeToLive; + + public ParticleEvent(String deviceId, String dataPayload, Date publishedAt, int timeToLive) { + this.deviceId = deviceId; + this.dataPayload = dataPayload; + this.publishedAt = publishedAt; + this.timeToLive = timeToLive; + } +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventHandler.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventHandler.java new file mode 100644 index 0000000..db228d4 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventHandler.java @@ -0,0 +1,8 @@ +package io.particle.android.sdk.cloud; + + +public interface ParticleEventHandler extends SimpleParticleEventHandler { + + // FIXME: ugh, use a more specific exception here + void onEventError(Exception e); +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventVisibility.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventVisibility.java new file mode 100644 index 0000000..58aa472 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventVisibility.java @@ -0,0 +1,16 @@ +package io.particle.android.sdk.cloud; + + +import android.support.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + + +@IntDef({ParticleEventVisibility.PRIVATE, + ParticleEventVisibility.PUBLIC}) +@Retention(RetentionPolicy.SOURCE) +public @interface ParticleEventVisibility { + int PRIVATE = 1; + int PUBLIC = 2; +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleUser.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleUser.java new file mode 100644 index 0000000..b8447a6 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleUser.java @@ -0,0 +1,69 @@ +package io.particle.android.sdk.cloud; + + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.persistance.SensitiveDataStorage; +import io.particle.android.sdk.utils.Preconditions; + +import static io.particle.android.sdk.utils.Py.truthy; + + +@ParametersAreNonnullByDefault +public class ParticleUser { + + /** + * Initialize ParticleUser class with new credentials and store session in keychain + */ + public static synchronized ParticleUser fromNewCredentials(String user, String password) { + Preconditions.checkArgument(truthy(user), "Username cannot be empty or null"); + Preconditions.checkArgument(truthy(password), "Password cannot be empty or null"); + + SensitiveDataStorage sensitiveDataStorage = SDKGlobals.getSensitiveDataStorage(); + sensitiveDataStorage.saveUser(user); + sensitiveDataStorage.savePassword(password); + + return new ParticleUser(user, password); + } + + /** + * Try to initialize a ParticleUser class with stored credentials + * + * @return ParticleUser instance if successfully retrieved session, else null + */ + public static synchronized ParticleUser fromSavedSession() { + SensitiveDataStorage sensitiveDataStorage = SDKGlobals.getSensitiveDataStorage(); + String user = sensitiveDataStorage.getUser(); + String password = sensitiveDataStorage.getPassword(); + + if (truthy(user) && truthy(password)) { + return new ParticleUser(user, password); + } else { + return null; + } + } + + public static void removeSession() { + SensitiveDataStorage sensitiveDataStorage = SDKGlobals.getSensitiveDataStorage(); + sensitiveDataStorage.resetPassword(); + sensitiveDataStorage.resetUser(); + } + + + private final String user; + private final String password; + + + private ParticleUser(String user, String password) { + this.user = user; + this.password = password; + } + + public String getPassword() { + return password; + } + + public String getUser() { + return user; + } +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/Responses.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/Responses.java new file mode 100644 index 0000000..8230071 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/Responses.java @@ -0,0 +1,321 @@ +package io.particle.android.sdk.cloud; + +import com.google.gson.annotations.SerializedName; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import io.particle.android.sdk.cloud.Responses.Models.CoreInfo; + +/** + * All API responses, collected together in one outer class for simplicity's sake. + */ +public class Responses { + + /** + * ...and to go with the responses, a series of model objects only + * used internally when dealing with the REST API, never returned + * outside of the cloudapi package. + */ + static class Models { + + + public static class CoreInfo { + + @SerializedName("last_app") + public final String lastApp; + + @SerializedName("last_heard") + public final Date lastHeard; + + public final boolean connected; + + public final String deviceId; + + public CoreInfo(String lastApp, Date lastHeard, boolean connected, String deviceId) { + this.lastApp = lastApp; + this.lastHeard = lastHeard; + this.connected = connected; + this.deviceId = deviceId; + } + } + + + /** + * Represents a single Particle device in the list returned + * by a call to "GET /v1/devices" + */ + public static class SimpleDevice { + + public final String id; + + public final String name; + + public final boolean cellular; + + public final String imei; + + @SerializedName("current_build_target") + public final String currentBuild; + + @SerializedName("default_build_target") + public final String defaultBuild; + + @SerializedName("connected") + public final boolean isConnected; + + @SerializedName("product_id") + public final int productId; + + @SerializedName("platform_id") + public final int platformId; + + @SerializedName("last_ip_address") + public final String ipAddress; + + @SerializedName("status") + public final String status; + + @SerializedName("last_heard") + public final Date lastHeard; + + public SimpleDevice(String id, String name, boolean isConnected, boolean cellular, + String imei, String currentBuild, String defaultBuild, int platformId, + int productId, String ipAddress, String status, Date lastHeard) { + this.id = id; + this.name = name; + this.isConnected = isConnected; + this.cellular = cellular; + this.imei = imei; + this.currentBuild = currentBuild; + this.defaultBuild = defaultBuild; + this.platformId = platformId; + this.productId = productId; + this.ipAddress = ipAddress; + this.status = status; + this.lastHeard = lastHeard; + } + } + + /** + * Represents a single Particle device as returned from the + * call to "GET /v1/devices/{device id}" + */ + class CompleteDevice { + @SerializedName("id") + public final String deviceId; + + public final String name; + + public final boolean cellular; + + public final String imei; + + @SerializedName("current_build_target") + public final String currentBuild; + + @SerializedName("default_build_target") + public final String defaultBuild; + + @SerializedName("connected") + public final boolean isConnected; + + public final Map variables; + + public final List functions; + + @SerializedName("cc3000_patch_version") + public final String version; + + @SerializedName("product_id") + public final int productId; + + @SerializedName("platform_id") + public final int platformId; + + @SerializedName("last_ip_address") + public final String ipAddress; + + @SerializedName("last_app") + public final String lastAppName; + + @SerializedName("status") + public final String status; + + @SerializedName("device_needs_update") + public final boolean requiresUpdate; + + @SerializedName("last_heard") + public final Date lastHeard; + + CompleteDevice(String deviceId, String name, boolean isConnected, boolean cellular, + String imei, String currentBuild, String defaultBuild, + Map variables, List functions, String version, + int productId, int platformId, String ipAddress, String lastAppName, + String status, boolean requiresUpdate, Date lastHeard) { + this.deviceId = deviceId; + this.name = name; + this.isConnected = isConnected; + this.cellular = cellular; + this.imei = imei; + this.currentBuild = currentBuild; + this.defaultBuild = defaultBuild; + this.variables = variables; + this.functions = functions; + this.version = version; + this.productId = productId; + this.platformId = platformId; + this.ipAddress = ipAddress; + this.lastAppName = lastAppName; + this.status = status; + this.requiresUpdate = requiresUpdate; + this.lastHeard = lastHeard; + } + } + + } + + + public static class TokenResponse { + + public final String token; + + public TokenResponse(String token) { + this.token = token; + } + } + + + public static class CallFunctionResponse { + + @SerializedName("id") + public final String deviceId; + + @SerializedName("name") + public final String deviceName; + + public final boolean connected; + + @SerializedName("return_value") + public final int returnValue; + + public CallFunctionResponse(String deviceId, String deviceName, boolean connected, + int returnValue) { + this.deviceId = deviceId; + this.deviceName = deviceName; + this.connected = connected; + this.returnValue = returnValue; + } + } + + + public static class LogInResponse { + + @SerializedName("expires_in") + public final long expiresInSeconds; + + @SerializedName("access_token") + public final String accessToken; + + @SerializedName("token_type") + public final String tokenType; + + public LogInResponse(long expiresInSeconds, String accessToken, String tokenType) { + this.expiresInSeconds = expiresInSeconds; + this.accessToken = accessToken; + this.tokenType = tokenType; + } + } + + + public static class SimpleResponse { + + public final boolean ok; + public final String error; + + public SimpleResponse(boolean ok, String error) { + this.ok = ok; + this.error = error; + } + + @Override + public String toString() { + return "SimpleResponse [ok=" + ok + ", error=" + error + "]"; + } + } + + + public static class ClaimCodeResponse { + + @SerializedName("claim_code") + public final String claimCode; + + @SerializedName("device_ids") + public final String[] deviceIds; + + public ClaimCodeResponse(String claimCode, String[] deviceIds) { + this.claimCode = claimCode; + this.deviceIds = deviceIds; + } + } + + + public abstract static class ReadVariableResponse { + + @SerializedName("cmd") + public final String commandName; + + @SerializedName("name") + public final String variableName; + + public final T result; + + public final Models.CoreInfo coreInfo; + + public ReadVariableResponse(String commandName, String variableName, + Models.CoreInfo coreInfo, T result) { + this.commandName = commandName; + this.variableName = variableName; + this.result = result; + this.coreInfo = coreInfo; + } + } + + + public static class ReadIntVariableResponse extends ReadVariableResponse { + + public ReadIntVariableResponse(String commandName, String variableName, CoreInfo coreInfo, + Integer result) { + super(commandName, variableName, coreInfo, result); + } + } + + + public static class ReadDoubleVariableResponse extends ReadVariableResponse { + + public ReadDoubleVariableResponse(String commandName, String variableName, CoreInfo coreInfo, + Double result) { + super(commandName, variableName, coreInfo, result); + } + } + + + public static class ReadStringVariableResponse extends ReadVariableResponse { + + public ReadStringVariableResponse(String commandName, String variableName, CoreInfo coreInfo, + String result) { + super(commandName, variableName, coreInfo, result); + } + } + + + public static class ReadObjectVariableResponse extends ReadVariableResponse { + + public ReadObjectVariableResponse(String commandName, String variableName, CoreInfo coreInfo, + Object result) { + super(commandName, variableName, coreInfo, result); + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKGlobals.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKGlobals.java new file mode 100644 index 0000000..0d1070d --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKGlobals.java @@ -0,0 +1,42 @@ +package io.particle.android.sdk.cloud; + + +import android.content.Context; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.persistance.AppDataStorage; +import io.particle.android.sdk.persistance.SensitiveDataStorage; + + +@ParametersAreNonnullByDefault +public class SDKGlobals { + + private static volatile SensitiveDataStorage sensitiveDataStorage; + private static volatile AppDataStorage appDataStorage; + + private static boolean isInitialized = false; + + + public static synchronized void init(Context ctx) { + ctx = ctx.getApplicationContext(); + if (isInitialized) { + return; + } + + sensitiveDataStorage = new SensitiveDataStorage(ctx); + appDataStorage = new AppDataStorage(ctx); + + isInitialized = true; + } + + + public static SensitiveDataStorage getSensitiveDataStorage() { + return sensitiveDataStorage; + } + + public static AppDataStorage getAppDataStorage() { + return appDataStorage; + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKProvider.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKProvider.java new file mode 100644 index 0000000..b20d06e --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKProvider.java @@ -0,0 +1,113 @@ +package io.particle.android.sdk.cloud; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ApiDefs.CloudApi; +import io.particle.android.sdk.cloud.ApiDefs.IdentityApi; +import io.particle.android.sdk.cloud.ApiFactory.OauthBasicAuthCredentialsProvider; +import io.particle.android.sdk.cloud.ApiFactory.ResourceValueBasicAuthCredentialsProvider; +import io.particle.android.sdk.cloud.ApiFactory.TokenGetterDelegate; + + +// FIXME: there are a lot of details lacking in this class, but it's not public API, and the +// structure makes it easy enough to do something better later on. +@ParametersAreNonnullByDefault +class SDKProvider { + + private final Context ctx; + private final CloudApi cloudApi; + private final CloudApi fastTimeoutCloudApi; + private final IdentityApi identityApi; + private final ParticleCloud particleCloud; + private final TokenGetterDelegateImpl tokenGetter; + + SDKProvider(Context context, + @Nullable OauthBasicAuthCredentialsProvider oAuthCredentialsProvider) { + + this.ctx = context.getApplicationContext(); + + if (oAuthCredentialsProvider == null) { + oAuthCredentialsProvider = new ResourceValueBasicAuthCredentialsProvider( + ctx, R.string.oauth_client_id, R.string.oauth_client_secret); + } + + tokenGetter = new TokenGetterDelegateImpl(); + + ApiFactory apiFactory = new ApiFactory(ctx, tokenGetter, oAuthCredentialsProvider); + cloudApi = apiFactory.buildNewCloudApi(); + identityApi = apiFactory.buildNewIdentityApi(); + fastTimeoutCloudApi = apiFactory.buildNewFastTimeoutCloudApi(); + particleCloud = buildCloud(apiFactory); + } + + + CloudApi getCloudApi() { + return cloudApi; + } + + IdentityApi getIdentityApi() { + return identityApi; + } + + ParticleCloud getParticleCloud() { + return particleCloud; + } + + + private ParticleCloud buildCloud(ApiFactory apiFactory) { + SDKGlobals.init(ctx); + + // FIXME: see if this TokenGetterDelegate setter issue can be resolved reasonably + ParticleCloud cloud = new ParticleCloud( + apiFactory.getApiUri(), cloudApi, identityApi, fastTimeoutCloudApi, + SDKGlobals.getAppDataStorage(), LocalBroadcastManager.getInstance(ctx), + apiFactory.getGsonInstance(), buildExecutor()); + // FIXME: gross circular dependency + tokenGetter.cloud = cloud; + + return cloud; + } + + + private static ExecutorService buildExecutor() { + // lifted from AsyncTask's executor config + int CPU_COUNT = Runtime.getRuntime().availableProcessors(); + int CORE_POOL_SIZE = CPU_COUNT + 1; + int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; + int KEEP_ALIVE = 1; + // FIXME: how big should this queue be? + BlockingQueue poolWorkQueue = new LinkedBlockingQueue<>(1024); + ThreadFactory threadFactory = new ThreadFactory() { + private final AtomicInteger mCount = new AtomicInteger(1); + public Thread newThread(Runnable r) { + return new Thread(r, "Particle Exec #" + mCount.getAndIncrement()); + } + }; + + return new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE, + TimeUnit.SECONDS, poolWorkQueue, threadFactory); + } + + + private static class TokenGetterDelegateImpl implements TokenGetterDelegate { + + private volatile ParticleCloud cloud; + + @Override + public String getTokenValue() { + return cloud.getAccessToken(); + } + } +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SimpleParticleEventHandler.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SimpleParticleEventHandler.java new file mode 100644 index 0000000..8d2be89 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/SimpleParticleEventHandler.java @@ -0,0 +1,9 @@ +package io.particle.android.sdk.cloud; + +/** + * Created by Julius. + */ + +public interface SimpleParticleEventHandler { + void onEvent(String eventName, ParticleEvent particleEvent); +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/AccountInfo.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/AccountInfo.java new file mode 100644 index 0000000..b04b794 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/AccountInfo.java @@ -0,0 +1,96 @@ +package io.particle.android.sdk.cloud.models; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nullable; + +/** + * Keeps secondary user account information. + */ +public class AccountInfo implements Parcelable { + @SerializedName("first_name") + private String firstName; + @SerializedName("last_name") + private String lastName; + @SerializedName("company_name") + private String companyName; + @SerializedName("business_account") + private boolean businessAccount; + + public AccountInfo() { + } + + public AccountInfo(String firstName, String lastName, String companyName, boolean businessAccount) { + this.firstName = firstName; + this.lastName = lastName; + this.companyName = companyName; + this.businessAccount = businessAccount; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public boolean isBusinessAccount() { + return businessAccount; + } + + public void setBusinessAccount(boolean isBusinessAccount) { + this.businessAccount = isBusinessAccount; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(this.firstName); + dest.writeString(this.lastName); + dest.writeString(this.companyName); + dest.writeByte(this.businessAccount ? (byte) 1 : (byte) 0); + } + + protected AccountInfo(Parcel in) { + this.firstName = in.readString(); + this.lastName = in.readString(); + this.companyName = in.readString(); + this.businessAccount = in.readByte() != 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public AccountInfo createFromParcel(Parcel source) { + return new AccountInfo(source); + } + + @Override + public AccountInfo[] newArray(int size) { + return new AccountInfo[size]; + } + }; +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/DeviceStateChange.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/DeviceStateChange.java new file mode 100644 index 0000000..92e25c8 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/DeviceStateChange.java @@ -0,0 +1,60 @@ +package io.particle.android.sdk.cloud.models; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ParticleDevice; + +@ParametersAreNonnullByDefault +public class DeviceStateChange implements Parcelable { + private final ParticleDevice device; + @NonNull private final ParticleDevice.ParticleDeviceState state; + + public DeviceStateChange(ParticleDevice device, @NonNull ParticleDevice.ParticleDeviceState state) { + this.device = device; + this.state = state; + } + + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(this.device, flags); + dest.writeInt(this.state == ParticleDevice.ParticleDeviceState.UNKNOWN ? -1 : this.state.ordinal()); + } + + protected DeviceStateChange(Parcel in) { + this.device = in.readParcelable(ParticleDevice.class.getClassLoader()); + int tmpState = in.readInt(); + this.state = tmpState == -1 ? ParticleDevice.ParticleDeviceState.UNKNOWN : + ParticleDevice.ParticleDeviceState.values()[tmpState]; + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public DeviceStateChange createFromParcel(Parcel source) { + return new DeviceStateChange(source); + } + + @Override + public DeviceStateChange[] newArray(int size) { + return new DeviceStateChange[size]; + } + }; + + public ParticleDevice getDevice() { + return device; + } + + @NonNull + public ParticleDevice.ParticleDeviceState getState() { + return state; + } +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/SignUpInfo.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/SignUpInfo.java new file mode 100644 index 0000000..b2319a4 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/SignUpInfo.java @@ -0,0 +1,91 @@ +package io.particle.android.sdk.cloud.models; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; + +/** + * Required and optional user information used in sign up process. + */ +@ParametersAreNonnullByDefault +public class SignUpInfo implements Parcelable { + private String username, password; + @SerializedName("grant_type") @Nullable + private String grantType; + @SerializedName("account_info") @Nullable + private AccountInfo accountInfo; + + public SignUpInfo(String username, String password) { + this.username = username; + this.password = password; + } + + public SignUpInfo(String username, String password, AccountInfo accountInfo) { + this.username = username; + this.password = password; + this.accountInfo = accountInfo; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + @Nullable + public String getGrantType() { + return grantType; + } + + public void setGrantType(@Nullable String grantType) { + this.grantType = grantType; + } + + @Nullable + public AccountInfo getAccountInfo() { + return accountInfo; + } + + public void setAccountInfo(@Nullable AccountInfo accountInfo) { + this.accountInfo = accountInfo; + } + + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(this.username); + dest.writeString(this.password); + dest.writeString(this.grantType); + dest.writeParcelable(this.accountInfo, flags); + } + + protected SignUpInfo(Parcel in) { + this.username = in.readString(); + this.password = in.readString(); + this.grantType = in.readString(); + this.accountInfo = in.readParcelable(AccountInfo.class.getClassLoader()); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public SignUpInfo createFromParcel(Parcel source) { + return new SignUpInfo(source); + } + + @Override + public SignUpInfo[] newArray(int size) { + return new SignUpInfo[size]; + } + }; +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/persistance/AppDataStorage.java b/cloudsdk/src/main/java/io/particle/android/sdk/persistance/AppDataStorage.java new file mode 100644 index 0000000..0f94c7f --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/persistance/AppDataStorage.java @@ -0,0 +1,41 @@ +package io.particle.android.sdk.persistance; + +import android.content.Context; +import android.content.SharedPreferences; + +import javax.annotation.ParametersAreNonnullByDefault; + +/** + * Storage for misc settings to be persisted which aren't related to + * identity, authorization, or any other sensitive data. + */ +@ParametersAreNonnullByDefault +public class AppDataStorage { + + private static final String KEY_USER_HAS_CLAIMED_DEVICES = "KEY_USER_HAS_CLAIMED_DEVICES"; + + private final SharedPreferences sharedPrefs; + + + public AppDataStorage(Context ctx) { + ctx = ctx.getApplicationContext(); + this.sharedPrefs = ctx.getSharedPreferences("spark_sdk_prefs", Context.MODE_PRIVATE); + } + + public void saveUserHasClaimedDevices(boolean value) { + sharedPrefs.edit() + .putBoolean(KEY_USER_HAS_CLAIMED_DEVICES, value) + .apply(); + } + + public boolean getUserHasClaimedDevices() { + return sharedPrefs.getBoolean(KEY_USER_HAS_CLAIMED_DEVICES, false); + } + + public void resetUserHasClaimedDevices() { + sharedPrefs.edit() + .remove(KEY_USER_HAS_CLAIMED_DEVICES) + .apply(); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/persistance/SensitiveDataStorage.java b/cloudsdk/src/main/java/io/particle/android/sdk/persistance/SensitiveDataStorage.java new file mode 100644 index 0000000..bb0ccec --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/persistance/SensitiveDataStorage.java @@ -0,0 +1,94 @@ +package io.particle.android.sdk.persistance; + + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Date; + +import javax.annotation.ParametersAreNonnullByDefault; + + +// FIXME: crib the code from the Vault example to do crypto for all these values. +@ParametersAreNonnullByDefault +public class SensitiveDataStorage { + + private static final String KEY_USERNAME = "KEY_USERNAME"; + private static final String KEY_PASSWORD = "KEY_PASSWORD"; + private static final String KEY_TOKEN = "KEY_TOKEN"; + private static final String KEY_TOKEN_EXPIRATION_DATE = "KEY_TOKEN_EXPIRATION_DATE"; + + private final SharedPreferences sharedPrefs; + + + public SensitiveDataStorage(Context ctx) { + ctx = ctx.getApplicationContext(); + this.sharedPrefs = ctx.getSharedPreferences("spark_sdk_sensitive_data", Context.MODE_PRIVATE); + } + + public void saveUser(String user) { + sharedPrefs.edit() + .putString(KEY_USERNAME, user) + .apply(); + } + + public String getUser() { + return sharedPrefs.getString(KEY_USERNAME, null); + } + + public void resetUser() { + sharedPrefs.edit() + .remove(KEY_USERNAME) + .apply(); + } + + public void savePassword(String password) { + sharedPrefs.edit() + .putString(KEY_PASSWORD, password) + .apply(); + } + + public String getPassword() { + return sharedPrefs.getString(KEY_PASSWORD, null); + } + + public void resetPassword() { + sharedPrefs.edit() + .remove(KEY_PASSWORD) + .apply(); + } + + public void saveToken(String token) { + sharedPrefs.edit() + .putString(KEY_TOKEN, token) + .apply(); + } + + public String getToken() { + return sharedPrefs.getString(KEY_TOKEN, null); + } + + public void resetToken() { + sharedPrefs.edit() + .remove(KEY_TOKEN) + .apply(); + } + + public void saveTokenExpirationDate(Date expirationDate) { + sharedPrefs.edit() + .putLong(KEY_TOKEN_EXPIRATION_DATE, expirationDate.getTime()) + .apply(); + } + + public Date getTokenExpirationDate() { + long expirationTs = sharedPrefs.getLong(KEY_TOKEN_EXPIRATION_DATE, -1); + return (expirationTs == -1) ? null : new Date(expirationTs); + } + + public void resetTokenExpirationDate() { + sharedPrefs.edit() + .remove(KEY_TOKEN_EXPIRATION_DATE) + .apply(); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/Async.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Async.java new file mode 100644 index 0000000..0249e4c --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Async.java @@ -0,0 +1,155 @@ +package io.particle.android.sdk.utils; + +import android.app.Activity; +import android.os.AsyncTask; + +import java.io.IOException; + +import javax.annotation.ParametersAreNonnullByDefault; + +import io.particle.android.sdk.cloud.ParticleCloud; +import io.particle.android.sdk.cloud.ParticleCloudException; +import io.particle.android.sdk.cloud.ParticleDevice; + + +/** + * Analgesic AsyncTask wrapper for making Particle cloud API calls + */ +@ParametersAreNonnullByDefault +public class Async { + + private static final TLog log = TLog.get(Async.class); + + + public abstract static class ApiWork { + + public abstract Result callApi(ApiCaller apiCaller) throws ParticleCloudException, IOException; + + public abstract void onSuccess(Result result); + + public abstract void onFailure(ParticleCloudException exception); + + /** + * Called at the end of the async task execution, before + * onSuccess(), onFailure(), or onCancel() + */ + public void onTaskFinished() { + // default: no-op + } + + public void onCancelled() { + // default: no-op + } + } + + + /** + * For when you don't care about the return value (or there isn't one), you just want to make + * the REST call + */ + public abstract static class ApiProcedure extends ApiWork { + + @Override + public void onSuccess(Void voyd) { + // no-op, because that's the whole point of this class. + } + } + + + public static AsyncApiWorker executeAsync(ParticleCloud particleCloud, + ApiWork work) { + return (AsyncApiWorker) new AsyncApiWorker<>(particleCloud, work) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + + public static AsyncApiWorker executeAsync(ParticleDevice particleDevice, + ApiWork work) { + return (AsyncApiWorker) new AsyncApiWorker<>(particleDevice, work) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + + public static class AsyncApiWorker extends AsyncTask { + + private final ApiCaller caller; + private final ApiWork work; + + private Activity activity; + + private volatile ParticleCloudException exception; + // FIXME: this is Bad and Wrong, but this needs to SHIP, so I'm leaving it for now. + public volatile IOException ioException; + + + private AsyncApiWorker(ApiCaller caller, ApiWork work) { + this.caller = caller; + this.work = work; + } + + // This method name looks weird on its own, but looks fine in use. + + /** + * Prevent all callbacks (onTaskFinished(), onCancelled(), onSuccess(), and onFailure()) + * from being called if the supplied Activity is finishing (i.e.: you don't necessarily + * care about the ) + */ + public AsyncApiWorker andIgnoreCallbacksIfActivityIsFinishing(Activity activity) { + this.activity = activity; + return this; + } + + @Override + protected Result doInBackground(Void... voids) { + try { + return work.callApi(caller); + } catch (ParticleCloudException e) { + exception = e; + return null; + } catch (IOException e) { + ioException = e; + return null; + } + } + + @Override + protected void onCancelled() { + if (shouldCallCallbacks()) { + work.onTaskFinished(); + work.onCancelled(); + } + } + + @Override + protected void onPostExecute(Result result) { + if (!shouldCallCallbacks()) { + return; + } + + work.onTaskFinished(); + if (exception == null && ioException == null) { + work.onSuccess(result); + + } else { + // FIXME: this error handling isn't quite right; fix it. + if (exception == null) { + exception = new ParticleCloudException(ioException); + } + log.e("Error calling API: " + exception.getBestMessage(), exception); + work.onFailure(exception); + } + } + + private boolean shouldCallCallbacks() { + if (activity == null) { + return true; + } + boolean shouldCall = !activity.isFinishing(); + if (!shouldCall) { + log.d("Refusing to call callbacks, was told to ignore them if the activity was finishing"); + } + return shouldCall; + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/EZ.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/EZ.java new file mode 100644 index 0000000..51bd057 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/EZ.java @@ -0,0 +1,129 @@ +package io.particle.android.sdk.utils; + +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; + +import java.io.Closeable; +import java.io.IOException; + +import javax.annotation.ParametersAreNonnullByDefault; + +/** + * Analgesic shortcuts for Android dev't. + */ +@ParametersAreNonnullByDefault +public class EZ { + + private static final TLog log = TLog.get(EZ.class); + + + public static void runOnMainThread(Runnable runnable) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(runnable); + } + + public static void runOnMainThreadDelayed(long delayInMillis, Runnable runnable) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.postDelayed(runnable, delayInMillis); + } + + public static boolean isThisTheMainThread() { + return Looper.getMainLooper() == Looper.myLooper(); + } + + public static void runAsync(final Runnable runnable) { + new AsyncTask() { + + @Override + protected Void doInBackground(Void... params) { + runnable.run(); + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + public static void threadSleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + log.e("Thread interrupted: ", e); + } + } + + public static boolean isUsingOlderWifiStack() { + return Build.VERSION.SDK_INT < 20; + } + + /** + * Return the callbacks for a fragment or throw an exception. + *

    + * Inspired by: https://gist.github.com/keyboardr/5455206 + */ + @SuppressWarnings("unchecked") + public static T getCallbacksOrThrow(Fragment frag, Class callbacks) { + Fragment parent = frag.getParentFragment(); + + if (parent != null && callbacks.isInstance(parent)) { + return (T) parent; + + } else { + FragmentActivity activity = frag.getActivity(); + if (activity != null && callbacks.isInstance(activity)) { + return (T) activity; + } + } + + // We haven't actually failed a class cast thanks to the checks above, but that's the + // idiomatic approach for this pattern with fragments. + throw new ClassCastException("This fragment's activity or parent fragment must implement " + + callbacks.getCanonicalName()); + } + + public static Uri buildRawResourceUri(Context ctx, String filename) { + // strip off any file extension from the video, because Android. + return Uri.parse( + String.format("android.resource://%s/raw/%s", + ctx.getPackageName(), + removeExtension(filename))); + } + + /** + * For when you don't care if the Closeable you're closing is null, and + * you don't care that closing a buffer threw an exception, but + * you still care enough that logging might be useful, like in a + * "finally" block, after you've already returned to the caller. + */ + public static void closeThisThingOrMaybeDont(@Nullable Closeable closeable) { + if (closeable == null) { + log.d("Can't close closable, arg was null."); + return; + } + + try { + closeable.close(); + } catch (IOException e) { + log.d("Couldn't close closable, but that's apparently OK. Error was: " + e.getMessage()); + } + } + + @Nullable + private static String removeExtension(@Nullable String filename) { + if (filename == null) { + return null; + } + int indexOfExtension = filename.lastIndexOf("."); + if (indexOfExtension == -1) { + return filename; + } else { + return filename.substring(0, indexOfExtension); + } + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/Funcy.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Funcy.java new file mode 100644 index 0000000..8150b67 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Funcy.java @@ -0,0 +1,197 @@ +package io.particle.android.sdk.utils; + +import android.support.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.annotation.ParametersAreNonnullByDefault; + + +/** + * Some functional-style utilities for processing collections + */ +@ParametersAreNonnullByDefault +public class Funcy { + + + public interface Predicate { + boolean test(T testTarget); + } + + + public interface Func { + Out apply(In in); + } + + // these are available as methods to enable generics handling + public static Predicate alwaysTrue() { + //noinspection unchecked + return (Predicate) alwaysTrue; + } + + public static Predicate alwaysFalse() { + //noinspection unchecked + return (Predicate) alwaysFalse; + } + + public static Predicate notNull() { + //noinspection unchecked + return (Predicate) notNull; + } + + public static List transformList(@Nullable List sourceList, + Func transformFunc) { + return transformList(sourceList, null, transformFunc, null); + } + + public static List transformList(@Nullable List sourceList, + Predicate inTypeInclusionFilter, + Func transformFunc) { + return transformList(sourceList, inTypeInclusionFilter, transformFunc, null); + } + + public static List transformList(@Nullable List sourceList, + Func transformFunc, + Predicate outTypeInclusionFilter) { + return transformList(sourceList, null, transformFunc, outTypeInclusionFilter); + } + + public static List transformList(@Nullable List sourceList, + @Nullable Predicate inTypeInclusionFilter, + Func transformFunc, + @Nullable Predicate outTypeInclusionFilter) { + return (List) transformCollection(sourceList, inTypeInclusionFilter, transformFunc, + outTypeInclusionFilter, listFactory); + } + + public static Set transformSet(@Nullable Set sourceSet, + Func transformFunc) { + return transformSet(sourceSet, null, transformFunc, null); + } + + public static Set transformSet(@Nullable Set sourceSet, + Predicate inTypeInclusionFilter, + Func transformFunc) { + return transformSet(sourceSet, inTypeInclusionFilter, transformFunc, null); + } + + public static Set transformSet(@Nullable Set sourceSet, + Func transformFunc, + Predicate outTypeInclusionFilter) { + return transformSet(sourceSet, null, transformFunc, outTypeInclusionFilter); + } + + public static Set transformSet(@Nullable Set sourceSet, + @Nullable Predicate inTypeInclusionFilter, + Func transformFunc, + @Nullable Predicate outTypeInclusionFilter) { + return (Set) transformCollection(sourceSet, inTypeInclusionFilter, transformFunc, + outTypeInclusionFilter, setFactory); + } + + public static List filter(@Nullable List toFilter, Predicate predicate) { + return (List) filterCollection(toFilter, predicate, listFactory); + } + + public static Set filter(@Nullable Set toFilter, Predicate predicate) { + return (Set) filterCollection(toFilter, predicate, setFactory); + } + + @Nullable + public static T findFirstMatch(Collection items, Predicate predicate) { + for (T item : items) { + if (predicate.test(item)) { + return item; + } + } + return null; + } + + + private static Collection transformCollection( + @Nullable Collection source, @Nullable Predicate inTypeInclusionFilter, + Func transformFunc, @Nullable Predicate outTypeInclusionFilter, + CollectionFactory collectionFactory) { + if (source == null || source.isEmpty()) { + //noinspection unchecked + return collectionFactory.emptyCollection(); + } + + //noinspection unchecked + Collection result = collectionFactory.newWithCapacity(source.size()); + for (In fromItem : source) { + if (inTypeInclusionFilter != null && !inTypeInclusionFilter.test(fromItem)) { + continue; + } + + Out transformed = transformFunc.apply(fromItem); + if (outTypeInclusionFilter == null || outTypeInclusionFilter.test(transformed)) { + result.add(transformed); + } + } + return result; + } + + + private static Collection filterCollection( + @Nullable Collection toFilter, Predicate predicate, CollectionFactory collectionFactory) { + if (toFilter == null || toFilter.isEmpty()) { + //noinspection unchecked + return collectionFactory.emptyCollection(); + } + + //noinspection unchecked + Collection result = collectionFactory.newWithCapacity(toFilter.size()); + for (T item : toFilter) { + if (predicate.test(item)) { + result.add(item); + } + } + + return result; + } + + + private static final Predicate alwaysTrue = (Predicate) testTarget -> true; + private static final Predicate alwaysFalse = (Predicate) testTarget -> false; + private static final Predicate notNull = (Predicate) testTarget -> testTarget != null; + + + private interface CollectionFactory { + C newWithCapacity(int size); + + C emptyCollection(); + } + + + private static final CollectionFactory listFactory = new CollectionFactory() { + @Override + public List newWithCapacity(int size) { + return new ArrayList(size); + } + + @Override + public List emptyCollection() { + return Collections.emptyList(); + } + }; + + + private static final CollectionFactory setFactory = new CollectionFactory() { + @Override + public Set newWithCapacity(int size) { + return new HashSet(size); + } + + @Override + public Set emptyCollection() { + return Collections.emptySet(); + } + }; + +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/Parcelables.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Parcelables.java new file mode 100644 index 0000000..89339a9 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Parcelables.java @@ -0,0 +1,91 @@ +package io.particle.android.sdk.utils; + + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.util.ArrayMap; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.annotation.ParametersAreNonnullByDefault; + + +@ParametersAreNonnullByDefault +public class Parcelables { + + public static boolean readBoolean(Parcel parcel) { + return (parcel.readInt() != 0); + } + + + public static void writeBoolean(Parcel parcel, boolean value) { + parcel.writeInt(value ? 1 : 0); + } + + + public static List readStringList(Parcel parcel) { + List sourceList = new ArrayList<>(); + parcel.readStringList(sourceList); + return sourceList; + } + + + public static Map readStringMap(Parcel parcel) { + Map map = new ArrayMap<>(); + Bundle bundle = parcel.readBundle(Parcelables.class.getClassLoader()); + for (String key : bundle.keySet()) { + map.put(key, bundle.getString(key)); + } + return map; + } + + public static void writeStringMap(Parcel parcel, Map stringMap) { + Bundle b = new Bundle(); + for (Map.Entry entry : stringMap.entrySet()) { + b.putString(entry.getKey(), entry.getValue()); + } + parcel.writeBundle(b); + } + + public static Map readParcelableMap(Parcel parcel) { + Map map = new ArrayMap<>(); + Bundle bundle = parcel.readBundle(Parcelables.class.getClassLoader()); + for (String key : bundle.keySet()) { + T parcelable = bundle.getParcelable(key); + map.put(key, parcelable); + } + return map; + } + + public static void writeParcelableMap(Parcel parcel, Map map) { + Bundle b = new Bundle(); + for (Map.Entry entry : map.entrySet()) { + b.putParcelable(entry.getKey(), entry.getValue()); + } + parcel.writeBundle(b); + } + + public static Map readSerializableMap(Parcel parcel) { + Map map = new ArrayMap<>(); + Bundle bundle = parcel.readBundle(Parcelables.class.getClassLoader()); + for (String key : bundle.keySet()) { + @SuppressWarnings("unchecked") + T serializable = (T) bundle.getSerializable(key); + map.put(key, serializable); + } + return map; + } + + public static void writeSerializableMap(Parcel parcel, Map map) { + Bundle b = new Bundle(); + for (Map.Entry entry : map.entrySet()) { + b.putSerializable(entry.getKey(), entry.getValue()); + } + parcel.writeBundle(b); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/ParticleInternalStringUtils.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/ParticleInternalStringUtils.java new file mode 100644 index 0000000..9cc70b8 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/ParticleInternalStringUtils.java @@ -0,0 +1,74 @@ +// Contents lifted from StringUtils.java in Apache commons-lang + +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.particle.android.sdk.utils; + + +import java.util.Iterator; + + +public class ParticleInternalStringUtils { + + + public static String join(final Iterable iterable, final char separator) { + if (iterable == null) { + return null; + } + return join(iterable.iterator(), separator); + } + + + public static String join(final Iterator iterator, final char separator) { + + // handle null, zero and one elements before building a buffer + if (iterator == null) { + return null; + } + if (!iterator.hasNext()) { + return ""; + } + final Object first = iterator.next(); + if (!iterator.hasNext()) { + @SuppressWarnings("deprecation") + // ObjectUtils.toString(Object) has been deprecated in 3.2 + final String result = objToString(first); + return result; + } + + // two or more elements + final StringBuilder buf = new StringBuilder(256); // Java default is 16, probably too small + if (first != null) { + buf.append(first); + } + + while (iterator.hasNext()) { + buf.append(separator); + final Object obj = iterator.next(); + if (obj != null) { + buf.append(obj); + } + } + + return buf.toString(); + } + + private static String objToString(Object obj) { + return obj == null ? "" : obj.toString(); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/Preconditions.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Preconditions.java new file mode 100644 index 0000000..5031dd7 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Preconditions.java @@ -0,0 +1,31 @@ +package io.particle.android.sdk.utils; + +import android.support.annotation.Nullable; + +/** + * Like Guava's Preconditions, but without the overwhelming method count cost + */ +public class Preconditions { + + public static void checkArgument(boolean condition, String errorMessage) { + if (!condition) { + throw new IllegalArgumentException(String.valueOf(errorMessage)); + } + } + + + public static T checkNotNull(T reference) { + if (reference == null) { + throw new NullPointerException(); + } + return reference; + } + + + public static T checkNotNull(T reference, @Nullable Object errorMessage) { + if (reference == null) { + throw new NullPointerException(String.valueOf(errorMessage)); + } + return reference; + } +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/Py.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Py.java new file mode 100644 index 0000000..b1cc6aa --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Py.java @@ -0,0 +1,409 @@ +package io.particle.android.sdk.utils; + +import android.support.v4.util.ArrayMap; + +import org.json.JSONArray; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + + +/** + * Python-inspired painkillers for Java + *

    + * For extra Pythonic happiness, use these as static imports, and you can + * do things like list(), the way you could in Python! + * + * @author jens.knutson@gmail.com + */ +public class Py { + + /** + * Test the "truthiness" of an object, ala Python - see http://goo.gl/JebVU + *

    + * The following will all return false: + *

      + *
    • null + *
    • Any empty collection/array/iterable + *
    • a empty string, i.e.: "" + *
    • a Number (like Integer or Long, or their primitive equivalents) with a value of 0 + *
    • A Boolean (or primitive equiv.) which evaluates to false + *
    + *

    + * Anything else will return true. + * + * @param obj the target to be evaluated + * @return is obj "truthy" + */ + public static boolean truthy(Object obj) { + if (obj == null) { + return false; + + } else if (obj instanceof Collection) { + return (!((Collection) obj).isEmpty()); + + } else if (obj instanceof Iterable) { + return !((Iterable) obj).iterator().hasNext(); + + } else if (obj instanceof Object[]) { + return (((Object[]) obj).length > 0); + + } else if (obj instanceof Number) { + return (((Number) obj).longValue() != 0); + + } else if (obj instanceof CharSequence) { + return (((CharSequence) obj).length() > 0); + + } else if (obj instanceof JSONArray) { + return (((JSONArray) obj).length() > 0); + + } else if (obj instanceof Boolean) { + return ((Boolean) obj); + + } else if (obj instanceof long[]) { + return (((long[]) obj).length > 0); + + } else if (obj instanceof int[]) { + return (((int[]) obj).length > 0); + + } else if (obj instanceof short[]) { + return (((short[]) obj).length > 0); + + } else if (obj instanceof byte[]) { + return (((byte[]) obj).length > 0); + + } else if (obj instanceof char[]) { + return (((char[]) obj).length > 0); + + } else if (obj instanceof boolean[]) { + return (((boolean[]) obj).length > 0); + + } else if (obj instanceof float[]) { + return (((float[]) obj).length > 0); + + } else if (obj instanceof double[]) { + return (((double[]) obj).length > 0); + } + + return true; + } + + // NOTE: "truthy()" is by far the most popular function in this file, and + // it serves as the basis for many of the other functions below. If you + // don't know how it works, take a second to review it - it's easy and + // really useful in taking away some of the lameness and pain of + // overly verbose code. + + /** + * Return true *only* if *every* object in the varargs array is truthy + *

    + * Call truthy() on each object in the varargs - this is called all() in + * Python, so that's what I'm calling it here. + */ + public static boolean all(Object... objects) { + if (!truthy(objects)) { + // is our varargs list null or empty? + return false; + } + + for (Object obj : objects) { + if (!truthy(obj)) { + return false; + } + } + return true; + } + + /** + * Return true if *any* object in the varargs array is truthy + *

    + * Calls truthy() on each object in the varargs - this is called any() in + * Python, so that's what I'm calling it here. + */ + public static boolean any(Object... objects) { + if (!truthy(objects)) { + // is our varargs list null or empty? + return false; + } + + for (Object obj : objects) { + if (truthy(obj)) { + return true; + } + } + return false; + } + + /** + * A simple function to create lists without all the extra noise. + *

    + * Quick quiz: unless you have special, specific requirements, like + * thread safety or immutability, which List implementation do you use + * every time? It's ArrayList, isn't it? + * The Py class embraces this fact and gives you Py.list(). + * + * @param objects arbitrary number of objects. + * @return a List from the objects param + */ + public static List list(T... objects) { + return new ArrayList<>(Arrays.asList(objects)); + } + + // get around empty constructors complaining about zero-arg varargs calls + // like the one above when the list content + // uses generics + + /** + * (See {@link #list(Object[])} for documentation) + */ + public static List list() { + return new ArrayList<>(); + } + + public static List list(Collection someCollection) { + return new ArrayList<>(someCollection); + } + + public static List list(Iterable things) { + // Why isn't this in Collections somewhere? + return list(things.iterator()); + } + + public static List list(Iterator things) { + // Why isn't this in Collections somewhere? + List result = new ArrayList<>(); + while (things.hasNext()) { + result.add(things.next()); + } + return result; + } + + /** + * Just like Py.list(), but it returns a Set. + * + * @param objects arbitrary number of objects. + * @return a Set from the objects param + */ + public static PySet set(T... objects) { + return set(Arrays.asList(objects)); + } + + public static PySet set(Collection someCollection) { + return new PySet<>(someCollection); + } + + /** + * Like Py.list(), but returns an immutable sequence. + *

    + * Named after the same concept in Python, which in turn was named after the + * pre-existing mathematical concept of a "tuple" + * + * @param objects arbitrary number of objects. + * @return an immutible List + */ + public static List tuple(T... objects) { + return Collections.unmodifiableList(list(objects)); + } + + public static List tuple(List someList) { + return Collections.unmodifiableList(someList); + } + + /** + * Just like Py.set(), but it returns an immutable Set. + *

    + * Called "frozen"set because that's what Python calls it - might as well, + * given the context and purpose of this class. + * + * @param objects arbitrary number of objects. + * @return an immutable Set from the objects param + */ + public static Set frozenset(T... objects) { + return Collections.unmodifiableSet(set(objects)); + } + + public static Set frozenset(Set someSet) { + return Collections.unmodifiableSet(someSet); + } + +// // Python : dict :: Java : Map +// public static Map map(Map otherMap) { +// return new ConcurrentHashMap<>(otherMap); +// } + + public static Map map(List keys, List values) { + if (keys.size() != values.size()) { + throw new IllegalArgumentException("key and value lists MUST be the same size!"); + } + + Map newMap = map(); + for (int i = 0; i < keys.size(); i++) { + newMap.put(keys.get(i), values.get(i)); + } + + return newMap; + } + + // returns an initialized but empty Map. + public static Map map() { + return new ArrayMap<>(); + } + +// public static Map frozenmap(Map otherMap) { +// return Collections.unmodifiableMap(map(otherMap)); +// } + + public static Map frozenmap(List keys, List values) { + return Collections.unmodifiableMap(map(keys, values)); + } + + // returns an initialized but empty Map. + public static Map frozenmap() { + return Collections.unmodifiableMap(new ArrayMap()); + } + + /** + * A Set implementation that offers an interface similar to Python's set + * objects, which offer methods for the simple, standard terms of set theory + * (e.g.: "union", "intersection", etc). + *

    + * These methods all create new sets instead of modifying existing ones in + * place. In keeping with the theme of this class, this is similar to what + * the Python methods of the same name will do. + *

    + * This is different than what Java's sets do, but Java's sets use different + * method names which match their behavior, so I believe this shouldn't + * cause anyone to trip up. + * + * @param + * @author jknutson + */ + public static class PySet extends LinkedHashSet { + + private static final long serialVersionUID = 2423791518942099628L; + + public PySet(Collection other) { + super(other); + } + + /** + * Return a new set with elements from this set and all elements from + * others. + * + * @param others , one or more collections with + * @return (see above) + */ + public PySet getUnion(Collection... others) { + PySet newCopy = set(this); + for (Collection other : others) { + newCopy.addAll(other); + } + + return newCopy; + } + + /** + * Return a new set with elements common to this set and all elements + * from others. + *

    + * This method is separate from {{@link #getIntersection(Collection...)} + * because until Java 1.7's SafeVarargs annotation, there was no way to + * call a varargs method using generic collections without getting a + * (bogus) type safety error. + */ + public PySet getIntersection(Collection other) { + PySet newCopy = set(this); + // NOTE: .retainAll() can be thought of as .retainOnly() or + // "remove all except these". + // Javadoc: http: // goo.gl/HF6vn + newCopy.retainAll(other); + return newCopy; + } + + /** + * Return a new set with elements common to this set and all elements + * from others. + * + * @param others + * @return + */ + public PySet getIntersection(Collection... others) { + PySet newCopy = set(this); + for (Collection other : others) { + // NOTE: .retainAll() can be thought of as .retainOnly() or + // "remove all except these". + // Javadoc: http: // goo.gl/HF6vn + newCopy.retainAll(other); + } + + return newCopy; + } + + /** + * Return a new set with elements in this set which do not exist in + * other. + * + * @param other + * @return + */ + public PySet getDifference(Collection other) { + // Return a new set with elements in the set that are not in the + // others. + PySet newCopy = set(this); + newCopy.removeAll(other); + return newCopy; + } + + /** + * Return a new set with elements in this set which do not exist in any + * of the others. + * + * @param others + * @return + */ + public PySet getDifference(Collection... others) { + // Return a new set with elements in the set that are not in the + // others. + PySet newCopy = set(this); + for (Collection other : others) { + newCopy.removeAll(other); + } + + return newCopy; + } + + /** + * Return a new set with elements in this set which do not exist in any + * of the others. + * + * @param other + * @return + */ + public PySet getSymmetricDifference(Collection... others) { + // Return a new set with elements in either the set or other but not + // both. + PySet union = set(this); + for (Collection other : others) { + union.addAll(other); + } + PySet intersection = set(this); + for (Collection other : others) { + if (intersection.isEmpty()) { + // Don't do any more work if the intersection can't be + // anything other than an empty set. + break; + } + intersection = intersection.getIntersection(other); + } + return union.getDifference(intersection); + } + } + +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/TLog.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/TLog.java new file mode 100644 index 0000000..ecb60e8 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/TLog.java @@ -0,0 +1,77 @@ +package io.particle.android.sdk.utils; + +import android.support.v4.util.ArrayMap; +import android.util.Log; + +/** + * NOTE: this class is likely to be deprecated soon in favor of Jake Wharton's Timber: + * https://goo.gl/xmQYYU + */ +public class TLog { + + private static final ArrayMap, TLog> loggers = new ArrayMap<>(); + + public static TLog get(Class clazz) { + TLog logger = loggers.get(clazz); + if (logger == null) { + logger = new TLog(clazz.getSimpleName()); + loggers.put(clazz, logger); + } + return logger; + } + + private final String tag; + + private TLog(String tag) { + this.tag = tag; + } + + public void e(String msg) { + Log.e(tag, msg); + } + + public void e(String msg, Throwable tr) { + Log.e(tag, msg, tr); + } + + public void w(String msg) { + Log.w(tag, msg); + } + + public void w(String msg, Throwable tr) { + Log.w(tag, msg, tr); + } + + public void i(String msg) { + Log.i(tag, msg); + } + + public void i(String msg, Throwable tr) { + Log.i(tag, msg, tr); + } + + public void d(String msg) { + Log.d(tag, msg); + } + + public void d(String msg, Throwable tr) { + Log.d(tag, msg, tr); + } + + public void v(String msg) { + Log.v(tag, msg); + } + + public void v(String msg, Throwable tr) { + Log.v(tag, msg, tr); + } + + public void wtf(String msg) { + Log.wtf(tag, msg); + } + + public void wtf(String msg, Throwable tr) { + Log.wtf(tag, msg, tr); + } + +} diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/utils/Toaster.java b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Toaster.java new file mode 100644 index 0000000..7e8f8ac --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/utils/Toaster.java @@ -0,0 +1,47 @@ +package io.particle.android.sdk.utils; + +import android.app.Activity; +import android.support.annotation.Nullable; +import android.widget.Toast; + +import javax.annotation.ParametersAreNonnullByDefault; + + +@ParametersAreNonnullByDefault +public class Toaster { + + /** + * Shows a toast message for a short time. + *

    + * This is safe to call from background/worker threads. + */ + public static void s(final Activity activity, @Nullable final String msg) { + showToast(activity, msg, Toast.LENGTH_SHORT); + } + + /** + * Shows a toast message for a longer time than {@link #s(Activity, String)}. + *

    + * This is safe to call from background/worker threads. + */ + public static void l(final Activity activity, @Nullable final String msg) { + showToast(activity, msg, Toast.LENGTH_LONG); + } + + + private static void showToast(final Activity activity, @Nullable final String msg, + final int length) { + Runnable toastRunnable = new Runnable() { + @Override + public void run() { + Toast.makeText(activity, msg, length).show(); + } + }; + + if (EZ.isThisTheMainThread()) { + toastRunnable.run(); + } else { + EZ.runOnMainThread(toastRunnable); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/bridge/ValidateOrigin.java b/cloudsdk/src/main/java/org/kaazing/gateway/bridge/ValidateOrigin.java new file mode 100644 index 0000000..6dbe9e3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/bridge/ValidateOrigin.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.bridge; + +final class ValidateOrigin { + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Channel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Channel.java new file mode 100644 index 0000000..8fb92bf --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Channel.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import java.util.concurrent.atomic.AtomicLong; + +import org.kaazing.net.auth.ChallengeResponse; + +public class Channel { + public static final String HEADER_SEQUENCE = "X-Sequence-No"; + + // TODO: This is an abstration violation - authentication should not be exposed on Channel + /** Authentication data */ + public ChallengeResponse challengeResponse = new ChallengeResponse(null, null); + public boolean authenticationReceived = false; + public boolean preventFallback = false; + + private Channel parent; + private final AtomicLong sequence; + + public Channel() { + this(0); + } + + public Channel(long sequence) { + this.sequence = new AtomicLong(sequence); + } + + public void setParent(Channel parent) { + this.parent = parent; + } + + public Channel getParent() { + return parent; + } + + public long nextSequence() { + return this.sequence.getAndIncrement(); + } + + @Override + public String toString() { + return "[" + this.getClass().getSimpleName() + "]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/CommandMessage.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/CommandMessage.java new file mode 100644 index 0000000..7598366 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/CommandMessage.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +public interface CommandMessage { + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderInput.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderInput.java new file mode 100644 index 0000000..7c30cb3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderInput.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + + +public interface DecoderInput { + + WrappedByteBuffer read(C channel); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderListener.java new file mode 100644 index 0000000..e270774 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderListener.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + + +public interface DecoderListener { + + void messageDecoded(C channel, WrappedByteBuffer message); + void messageDecoded(C channel, String message); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/EncoderOutput.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/EncoderOutput.java new file mode 100644 index 0000000..3350534 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/EncoderOutput.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + + +public interface EncoderOutput { + + void write(C channel, WrappedByteBuffer buf); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Handler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Handler.java new file mode 100644 index 0000000..3ccb4e9 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Handler.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +/** + * Handler marker class + */ +public interface Handler { + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketChannel.java new file mode 100644 index 0000000..091c14e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketChannel.java @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.net.http.HttpRedirectPolicy; + +public class WebSocketChannel extends Channel { + static volatile int nextId = 1; + + public WebSocketHandler transportHandler; + public StringBuilder handshakePayload; + + final int id; + + protected int bufferedAmount = 0; + + private WSURI location; + private String selectedProtocol; + private String negotiatedExtensions; + private String enabledExtensions; + private HttpRedirectPolicy followRedirect; + + public WebSocketChannel(WSURI location) { + this.id = nextId++; + + this.location = location; + this.handshakePayload = new StringBuilder(); + } + + /** + * The number of bytes queued to be sent + */ + public int getBufferedAmount() { + return this.bufferedAmount; + } + + public void setLocation(WSURI location) { + this.location = location; + } + + public WSURI getLocation() { + return location; + } + + public String getEnabledExtensions() { + return enabledExtensions; + } + + public void setEnabledExtensions(String extensions) { + this.enabledExtensions = extensions; + } + + public String getNegotiatedExtensions() { + return negotiatedExtensions; + } + + public void setNegotiatedExtensions(String extensions) { + this.negotiatedExtensions = extensions; + } + + public void setProtocol(String protocol) { + this.selectedProtocol = protocol; + } + + public String getProtocol() { + return selectedProtocol; + } + + public HttpRedirectPolicy getFollowRedirect() { + return followRedirect; + } + + public void setFollowRedirect(HttpRedirectPolicy redirectOption) { + this.followRedirect = redirectOption; + } + + @Override + public String toString() { + String className = getClass().getSimpleName(); + if (className == null) { + className = WebSocketChannel.class.getSimpleName(); + } + return "["+className+" "+id+": "+location + "]"; + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandler.java new file mode 100644 index 0000000..350c291 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandler.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public interface WebSocketHandler extends Handler { + + void processConnect(WebSocketChannel channel, WSURI location, String[] protocols); + void processAuthorize(WebSocketChannel channel, String authorizeToken); + void processTextMessage(WebSocketChannel channel, String text); + void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer buffer); + void processClose(WebSocketChannel channel, int code, String reason); + void setListener(WebSocketHandlerListener listener); + void setIdleTimeout(WebSocketChannel channel, int timeout); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerAdapter.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerAdapter.java new file mode 100644 index 0000000..e818da4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerAdapter.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketHandlerAdapter implements WebSocketHandler { + + protected WebSocketHandler nextHandler; + protected WebSocketHandlerListener listener; + + /** + * Process connect request to uri and protocol specified + */ + @Override + public void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + nextHandler.processConnect(channel, location, protocols); + } + + /** + * Send authorize token to the Gateway + */ + @Override + public synchronized void processAuthorize(WebSocketChannel channel, String authorizeToken) { + nextHandler.processAuthorize(channel, authorizeToken); + } + + @Override + public void processTextMessage(WebSocketChannel channel, String text) { + nextHandler.processTextMessage(channel, text); + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer buffer) { + nextHandler.processBinaryMessage(channel, buffer); + } + + /** + * Disconnect the WebSocket + */ + @Override + public synchronized void processClose(WebSocketChannel channel, int code, String reason) { + nextHandler.processClose(channel, code, reason); + } + @Override + public void setListener(WebSocketHandlerListener listener) { + this.listener = listener; + } + + @Override + public synchronized void setIdleTimeout(WebSocketChannel channel, int timeout) { + nextHandler.setIdleTimeout(channel, timeout); + } + + public void setNextHandler(WebSocketHandler handler) { + this.nextHandler = handler; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerFactory.java new file mode 100644 index 0000000..83f9913 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerFactory.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +public interface WebSocketHandlerFactory { + + WebSocketHandler createWebSocketHandler(); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerListener.java new file mode 100644 index 0000000..e50b1e1 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerListener.java @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + + +/** + * Listener for inbound handler messages (coming from the network) + */ +public interface WebSocketHandlerListener { + + /** + * This method is called when the WebSocket is opened + * @param channel + */ + void connectionOpened(WebSocketChannel channel, String protocol); + + /** + * This method is called when the WebSocket is closed + * @param channel + * @param wasClean TODO + * @param code TODO + * @param reason TODO + */ + void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason); + + void connectionClosed(WebSocketChannel channel, Exception ex); + + /** + * This method is called when a connection fails + * @param channel + */ + void connectionFailed(WebSocketChannel channel, Exception ex); + + /** + * This method is called when a redirect response is + * @param channel + * @param location new location for redirect + */ + void redirected(WebSocketChannel channel, String location); + + /** + * This method is called when authentication is requested + * @param channel + */ + void authenticationRequested(WebSocketChannel channel, String location, String challenge); + + /** + * This method is called when a text message is received on the WebSocket channel + * @param channel + * @param message + */ + void textMessageReceived(WebSocketChannel channel, String message); + + /** + * This method is called when a binary message is received on the WebSocket channel + * @param channel + * @param buf + */ + void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf); + + /** + * This method is called when a command message is received on the WebSocket channel + * @param channel + * @param message + */ + void commandMessageReceived(WebSocketChannel channel, CommandMessage message); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/auth/AuthenticationUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/auth/AuthenticationUtil.java new file mode 100644 index 0000000..8744856 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/auth/AuthenticationUtil.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.auth; + +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.auth.ChallengeRequest; +import org.kaazing.net.auth.ChallengeResponse; + +public final class AuthenticationUtil { + + private static final String CLASS_NAME = AuthenticationUtil.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private AuthenticationUtil() { + + } + + public static ChallengeResponse getChallengeResponse(WebSocketChannel channel, ChallengeRequest challengeRequest, ChallengeResponse challengeResponse) { + LOG.entering(CLASS_NAME, "getChallengeResponse"); + + ChallengeHandler challengeHandler = null; + + if (challengeResponse.getNextChallengeHandler() == null) { + if (((WebSocketCompositeChannel)channel.getParent()) != null) { + challengeHandler = ((WebSocketCompositeChannel)channel.getParent()).getChallengeHandler(); + } + } else { + challengeHandler = challengeResponse.getNextChallengeHandler(); + } + + if (challengeHandler == null) { + throw new IllegalStateException("No challenge handler available for challenge " + challengeRequest); + } + + try { + challengeResponse = challengeHandler.handle(challengeRequest); + } catch (Exception e) { + throw new IllegalStateException("Unexpected error processing challenge: "+challengeRequest, e); + } + + if (challengeResponse == null) { + throw new IllegalStateException("Unsupported challenge " + challengeRequest); + } + return challengeResponse; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/BridgeUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/BridgeUtil.java new file mode 100644 index 0000000..f46fe41 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/BridgeUtil.java @@ -0,0 +1,191 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.net.URI; +import java.net.URL; +import java.security.SecureRandom; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.bridge.XoaEvent.XoaEventKind; +import org.kaazing.gateway.client.util.StringUtils; + +public class BridgeUtil { + private static final String CLASS_NAME = BridgeUtil.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final String SOA_MESSAGE = "soaMessage"; + private static final String XOP_MESSAGE = "xopMessage"; + + private static Map schemeAuthorityToXopMap = new ConcurrentHashMap(); + private static Map handlerIdToHtml5ObjectMap = new ConcurrentHashMap(); + + private static AtomicInteger sHtml5ObjectIdCounter = new AtomicInteger(new SecureRandom().nextInt(10000)); + + // package private static used for authenticating self for same origin code + static Object token = null; + + public static Object getIdentifier() { + LOG.exiting(CLASS_NAME, "getIdentifier", token); + return token; + } + + static void processEvent(XoaEvent event) { + LOG.entering(CLASS_NAME, "dispatchEventToXoa", event); + LOG.log(Level.FINEST, "SOA --> XOA: {1}", event); + + Integer handlerId = event.getHandlerId(); + if (handlerId == null) { + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("Null handlerId"); + } + return; + } + + String eventType = event.getKind().toString(); + Object[] params = event.getParams(); + Object[] args = { handlerId, eventType, params }; + PropertyChangeSupport xop = getCrossOriginProxy(handlerId); + if (xop == null) { + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("Null xop for handler " + handlerId); + } + return; + } + + xop.firePropertyChange(SOA_MESSAGE, null, args); + } + + public static void eventReceived(final Integer handlerId, final String eventType, final Object[] params) { + LOG.entering(CLASS_NAME, "eventReceived", new Object[] { handlerId, eventType, params }); + final Proxy obj = handlerIdToHtml5ObjectMap.get(handlerId); + if (obj == null) { + LOG.fine("Object by id: " + handlerId + " could not be located in the system"); + return; + } + + try { + XoaEventKind name = XoaEventKind.getName(eventType); + obj.eventReceived(handlerId, name, params); + } + finally { + if (eventType.equals(XoaEvent.XoaEventKind.CLOSED) || eventType.equals(XoaEvent.XoaEventKind.ERROR)) { + handlerIdToHtml5ObjectMap.remove(handlerId); + } + } + } + + private static String getSchemeAuthority(URI uri) { + return uri.getScheme() + "_" + uri.getAuthority(); + } + + private static PropertyChangeSupport getCrossOriginProxy(Integer handlerId) { + Proxy proxy = handlerIdToHtml5ObjectMap.get(handlerId); + return getCrossOriginProxy(proxy); + } + + private static PropertyChangeSupport getCrossOriginProxy(Proxy proxy) { + return getCrossOriginProxy(proxy.getUri()); + } + + private static PropertyChangeSupport getCrossOriginProxy(URI uri) { + String schemeAuthority = getSchemeAuthority(uri); + return getCrossOriginProxy(schemeAuthority); + } + + private static PropertyChangeSupport getCrossOriginProxy(String schemeAuthority) { + return schemeAuthorityToXopMap.get(schemeAuthority); + } + + private static void initCrossOriginProxy(URI uri) throws Exception { + LOG.entering(CLASS_NAME, "initCrossOriginProxy", new Object[] { uri }); + + PropertyChangeSupport xop = getCrossOriginProxy(uri); + if (xop == null) { + try { + String scheme = uri.getScheme(); + String jarUrl = scheme + "://" + uri.getAuthority(); + + if (scheme.equals("ws")) { + jarUrl = jarUrl.replace("ws:", "http:"); + } else if (scheme.equals("wss")) { + jarUrl = jarUrl.replace("wss:", "https:"); + } + + ClassLoaderFactory classLoaderFactory = ClassLoaderFactory.getInstance(); + jarUrl += classLoaderFactory.getQueryParameters(); + + final String jarFileUrl = jarUrl; + LOG.finest("jarFileUrl = " + StringUtils.stripControlCharacters(jarFileUrl)); + + ClassLoader loader = classLoaderFactory.createClassLoader(new URL(jarFileUrl), BridgeUtil.class.getClassLoader()); + + LOG.finest("Created remote proxy class loader: " + loader); + + Class remoteProxyClass = loader.loadClass(classLoaderFactory.getCrossOriginProxyClass()); + + xop = (PropertyChangeSupport) remoteProxyClass.newInstance(); + xop.addPropertyChangeListener(XOP_MESSAGE, new PropertyChangeListener() { + @Override + public void propertyChange(PropertyChangeEvent evt) { + Object[] args = (Object[]) evt.getNewValue(); + Integer proxyId = (Integer) args[0]; + String eventType = (String) args[1]; + Object[] params = (Object[]) args[2]; + eventReceived(proxyId, eventType, params); + } + }); + + schemeAuthorityToXopMap.put(getSchemeAuthority(uri), xop); + } catch (Exception e) { + String reason = "Unable to connect: the Gateway may not be running, a network route may be unavailable, or the Gateway may not be configured properly"; + LOG.log(Level.WARNING, reason); + LOG.log(Level.FINEST, reason, e); + throw new Exception(reason); + } + } + + LOG.exiting(CLASS_NAME, "initCrossOriginProxy", xop); + } + + static Proxy createProxy(URI uri, ProxyListener listener) throws Exception { + LOG.entering(CLASS_NAME, "registerProxy", new Object[] { }); + + BridgeUtil.initCrossOriginProxy(uri); + Integer handlerId = sHtml5ObjectIdCounter.getAndIncrement(); + + Proxy proxy = new Proxy(); + proxy.setHandlerId(handlerId); + proxy.setUri(uri); + proxy.setListener(listener); + + handlerIdToHtml5ObjectMap.put(handlerId, proxy); + return proxy; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ClassLoaderFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ClassLoaderFactory.java new file mode 100644 index 0000000..ab955c5 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ClassLoaderFactory.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import java.net.URL; +import java.net.URLClassLoader; + +public abstract class ClassLoaderFactory { + + private static ClassLoaderFactory sharedInstance; + + static { + sharedInstance = new DefaultClassLoaderFactory(); + } + + public abstract ClassLoader createClassLoader(URL url, ClassLoader parent) throws Exception; + + public abstract String getQueryParameters(); + + public abstract String getCrossOriginProxyClass(); + + public static final void setInstance(ClassLoaderFactory factory) { + sharedInstance = factory; + } + + public static ClassLoaderFactory getInstance() { + return sharedInstance; + } + + private static class DefaultClassLoaderFactory extends ClassLoaderFactory{ + + @Override + public ClassLoader createClassLoader(URL url, ClassLoader parent) throws Exception { + URL[] urls = { url }; + return URLClassLoader.newInstance(urls, parent); + } + + @Override + public String getQueryParameters() { + return "?.kr=xsj"; //"?.kv=10.05&.kr=xsj"; + } + + @Override + public String getCrossOriginProxyClass() { + return "org.kaazing.gateway.bridge.CrossOriginProxy"; + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/HttpRequestBridgeHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/HttpRequestBridgeHandler.java new file mode 100644 index 0000000..0831bc4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/HttpRequestBridgeHandler.java @@ -0,0 +1,273 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Copyright (c) 2007-2011, Kaazing Corporation. All rights reserved. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import java.util.Map.Entry; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.bridge.XoaEvent.XoaEventKind; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestUtil; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +/* + * WebSocket Emulated Handler Chain + * EmulateHandler + * |- CreateHandler - HttpRequestAuthenticationHandler - HttpRequestRedirectHandler - {HttpRequestBridgeHandler} + * |- UpstreamHandler - {HttpRequestBridgeHandler} + * |- DownstreamHandler - {HttpRequestBridgeHandler} + * Responsibilities: + * a). pass client actions over bridge as events + * b). fire corresponding event to client when receives events from bridge + * + * TODO: + * a). shall we check http response status code? now this code is checked in bridge side + */ +public class HttpRequestBridgeHandler implements HttpRequestHandler, ProxyListener { + private static final String CLASS_NAME = HttpRequestBridgeHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private HttpRequestListener listener; + + public HttpRequestBridgeHandler() { + LOG.entering(CLASS_NAME, ""); + } + + @Override + public synchronized void processOpen(HttpRequest request) { + LOG.entering(CLASS_NAME, "open", request); + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("processOpen: "+request); + } + + HttpURI uri = request.getUri(); + Method method = request.getMethod(); + + if (request.getProxy() != null) { + throw new IllegalStateException("processOpen previously called with HttpRequest"); + } + + try { + Proxy proxy = BridgeUtil.createProxy(uri.getURI(), this); + proxy.setPeer(request); + request.setProxy(proxy); + + /* Dispatch create event to the bridge */ + String[] params = new String[] { "HTTPREQUEST", uri.toString(), method.toString(), request.isAsync() ? "Y" : "N" }; + proxy.processEvent(XoaEventKind.CREATE, params); + } catch (Exception e) { + LOG.log(Level.FINE, "While initializing HttpRequest proxy: "+e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + + private void handleRequestCreated(HttpRequest request) { + LOG.entering(CLASS_NAME, "handleRequestCreated"); + if (LOG.isLoggable(Level.FINE)) { + LOG.fine("handleRequestCreated: "+request); + } + + request.setReadyState(HttpRequest.ReadyState.READY); + try { + for (Entry entry : request.getHeaders().entrySet()) { + String header = entry.getKey(); + String value = entry.getValue(); + HttpRequestUtil.validateHeader(header); + Proxy proxy = (Proxy)request.getProxy(); + proxy.processEvent(XoaEventKind.SETREQUESTHEADER, new String[] { header, value }); + } + + // Nothing has been sent + if (request.getMethod() == Method.POST) { + listener.requestReady(request); + } + else { + processSend(request, null); + } + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + + @Override + public void processSend(HttpRequest request, WrappedByteBuffer content) { + LOG.entering(CLASS_NAME, "processSend", content); + + if (request.getReadyState() != HttpRequest.ReadyState.READY) { + throw new IllegalStateException("HttpRequest must be in READY state to send"); + } + + request.setReadyState(HttpRequest.ReadyState.SENDING); + + java.nio.ByteBuffer payload; + if (content == null) { + payload = java.nio.ByteBuffer.allocate(0); + } else { + payload = java.nio.ByteBuffer.wrap(content.array(), content.arrayOffset(), content.remaining()); + } + + Proxy proxy = (Proxy)request.getProxy(); + proxy.processEvent(XoaEventKind.SEND, new Object[] { payload }); + request.setReadyState(HttpRequest.ReadyState.SENT); + } + + private void handleRequestProgressed(HttpRequest request, WrappedByteBuffer payload) { + LOG.entering(CLASS_NAME, "handleRequestProgressed", payload); + + request.setReadyState(HttpRequest.ReadyState.LOADING); + try { + listener.requestProgressed(request, payload); + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + + private void handleRequestLoaded(HttpRequest request, WrappedByteBuffer responseBuffer) { + LOG.entering(CLASS_NAME, "handleRequestLoaded", responseBuffer); + + request.setReadyState(HttpRequest.ReadyState.LOADED); + + HttpResponse response = request.getResponse(); + response.setBody(responseBuffer); + + try { + listener.requestLoaded(request, response); + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + + public static void parseResponseHeaders(HttpResponse response, String in) { + LOG.entering(CLASS_NAME, "setResponseHeaders", in); + String headers = in + ""; + int lf = headers.indexOf("\n"); + while (lf != -1) { + String ret = headers.substring(0, lf); + ret.trim(); + int colonAt = ret.indexOf(":"); + String name = ret.substring(0, colonAt); + String value = ret.substring(colonAt + 1); + response.setHeader(name, value); + + if (lf != headers.length()) { + // check if the last char is the \n + headers = headers.substring(lf + 1); + if (headers.length() == 0) { + headers.trim(); + } + } else { + headers = ""; + } + + if (headers.length() == 0) { + break; + } + lf = headers.indexOf("\n"); + } + } + + @Override + public void eventReceived(Proxy proxy, XoaEventKind eventKind, Object[] params) { + LOG.entering(CLASS_NAME, "eventReceived", new Object[] { proxy, this, eventKind, params }); + + HttpRequest request = (HttpRequest)proxy.getPeer(); + + if (LOG.isLoggable(Level.FINE)) { + LOG.log(Level.FINE, "SOA <-- XOA:" + "id = " + proxy.getHandlerId() + " name: " + eventKind + " " + request); + } + + switch (eventKind) { + case OPEN: + handleRequestCreated(request); + break; + case READYSTATECHANGE: + int state = Integer.parseInt((String) params[0]); + if (state == 2) { + HttpResponse response = new HttpResponse(); + request.setResponse(response); + + if (params.length > 1) { + int responseCode = Integer.parseInt((String) params[1]); + if (responseCode != 0) { + response.setStatusCode(responseCode); + response.setMessage(((String) params[2])); + parseResponseHeaders(response, ((String) params[3])); + + } + } + request.setReadyState(HttpRequest.ReadyState.OPENED); + listener.requestOpened(request); + } + break; + case PROGRESS: + WrappedByteBuffer messageBuffer = WrappedByteBuffer.wrap((java.nio.ByteBuffer) params[0]); + handleRequestProgressed(request, messageBuffer); + break; + case LOAD: + WrappedByteBuffer responseBuffer = WrappedByteBuffer.wrap((java.nio.ByteBuffer) params[0]); + handleRequestLoaded(request, responseBuffer); + break; + case CLOSED: + proxy = null; + listener.requestClosed(request); + break; + case ERROR: + proxy = null; + String s = "HTTP Bridge Handler: ERROR event received"; + handleErrorOccurred(request, new IllegalStateException(s)); + break; + default: + throw new IllegalArgumentException("INVALID_STATE_ERR"); + } + } + + private void handleErrorOccurred(HttpRequest request, Exception exception) { + request.setReadyState(HttpRequest.ReadyState.ERROR); + listener.errorOccurred(request, exception); + } + + @Override + public void processAbort(HttpRequest request) { + if (request.getReadyState() == HttpRequest.ReadyState.UNSENT) { + throw new IllegalStateException("INVALID_STATE_ERR"); + } + Proxy proxy = (Proxy)request.getProxy(); + proxy.processEvent(XoaEventKind.ABORT, XoaEvent.EMPTY_ARGS); + } + + @Override + public void setListener(HttpRequestListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/Proxy.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/Proxy.java new file mode 100644 index 0000000..b08583c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/Proxy.java @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import java.net.URI; + +import org.kaazing.gateway.client.impl.bridge.XoaEvent.XoaEventKind; + +/** + * This class manages the handler id and URI associated with each proxy for the bridge. + */ +public class Proxy { + private Integer handlerId; + private URI uri; + private Object peer; + private ProxyListener listener; + + public Proxy() { + } + + public URI getUri() { + return uri; + } + + void setUri(URI uri) { + this.uri = uri; + } + + void setListener(ProxyListener listener) { + this.listener = listener; + } + + public void setHandlerId(Integer handlerId) { + this.handlerId = handlerId; + } + + public Integer getHandlerId() { + return handlerId; + } + + public void setPeer(Object peer) { + this.peer = peer; + } + + public Object getPeer() { + return peer; + } + + void processEvent(XoaEventKind kind, Object[] params) { + BridgeUtil.processEvent(new XoaEvent(handlerId, kind, params)); + } + + void eventReceived(Integer handlerId, XoaEventKind name, Object[] params) { + listener.eventReceived(this, name, params); + } + + public String toString() { + return "[Proxy "+handlerId+"]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ProxyListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ProxyListener.java new file mode 100644 index 0000000..6b4baf4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ProxyListener.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import org.kaazing.gateway.client.impl.bridge.XoaEvent.XoaEventKind; + +public interface ProxyListener { + + void eventReceived(Proxy proxy, XoaEventKind name, Object[] params); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/WebSocketNativeBridgeHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/WebSocketNativeBridgeHandler.java new file mode 100644 index 0000000..3427029 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/WebSocketNativeBridgeHandler.java @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import java.nio.charset.Charset; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.bridge.XoaEvent.XoaEventKind; +import org.kaazing.gateway.client.impl.wsn.WebSocketNativeChannel; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +/* + * WebSocket Native Handler Chain + * NativeHandler - AuthenticationHandler - HandshakeHandler - ControlFrameHandler - BalanceingHandler - Nodec - {BridgeHandler} + * Responsibilities: + * a). pass client actions over the bridge as events + * b). fire events to client when receive events from bridge (see eventReceived function) + * TODO: + * n/a + */ +public class WebSocketNativeBridgeHandler implements WebSocketHandler, ProxyListener { + private static final String CLASS_NAME = WebSocketNativeBridgeHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final Charset UTF8 = Charset.forName("UTF-8"); + + private WebSocketHandlerListener listener; + + /** + * WebSocket + * @throws Exception + */ + public WebSocketNativeBridgeHandler() { + LOG.entering(CLASS_NAME, ""); + } + + /** + * Establishes the websocket connection + */ + @Override + public synchronized void processConnect(WebSocketChannel channel, WSURI uri, String[] protocols) { + LOG.entering(CLASS_NAME, "processConnect", new Object[] { uri, protocols }); + + try { + WebSocketNativeChannel nativeChannel = (WebSocketNativeChannel)channel; + if (nativeChannel.getProxy() != null) { + throw new IllegalStateException("Bridge proxy previously set"); + } + + Proxy proxy = BridgeUtil.createProxy(uri.getURI(), this); + proxy.setPeer(channel); + nativeChannel.setProxy(proxy); + + String[] params; + if (protocols != null) { + String s = ""; + for (int i=0; i0) { + s += ","; + } + s += protocols[i]; + } + params = new String[] { "WEBSOCKET", uri.toString(), s, ""}; + } else { + params = new String[] { "WEBSOCKET", uri.toString() }; + } + proxy.processEvent(XoaEventKind.CREATE, params); + } + catch (Exception e) { + LOG.log(Level.FINE, "While initializing WebSocket proxy: "+e.getMessage(), e); + listener.connectionFailed(channel, e); + } + } + + /** + * Set the authorize token for future requests for "Basic" authentication. + */ + @Override + public void processAuthorize(WebSocketChannel channel, String authorizeToken) { + LOG.entering(CLASS_NAME, "processAuthorize"); + + WebSocketNativeChannel nativeChannel = (WebSocketNativeChannel)channel; + Proxy proxy = nativeChannel.getProxy(); + proxy.processEvent(XoaEventKind.AUTHORIZE, new String[] { authorizeToken }); + } + + @Override + public void processTextMessage(WebSocketChannel channel, String text) { + WebSocketNativeChannel nativeChannel = (WebSocketNativeChannel)channel; + Proxy proxy = (Proxy)nativeChannel.getProxy(); + proxy.processEvent(XoaEventKind.POSTMESSAGE, new Object[] { text }); + // throw new Error("Not implemented: Use binary message for wire traffic"); + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer message) { + java.nio.ByteBuffer buffer = java.nio.ByteBuffer.allocate(message.remaining()); + buffer.put(message.array(), message.arrayOffset(), message.remaining()); + buffer.flip(); + + WebSocketNativeChannel nativeChannel = (WebSocketNativeChannel)channel; + Proxy proxy = (Proxy)nativeChannel.getProxy(); + proxy.processEvent(XoaEventKind.POSTMESSAGE, new Object[] { buffer }); + } + + @Override + public synchronized void processClose(WebSocketChannel channel, int code, String reason) { + LOG.entering(CLASS_NAME, "processDisconnect"); + + WebSocketNativeChannel nativeChannel = (WebSocketNativeChannel)channel; + Proxy proxy = nativeChannel.getProxy(); + proxy.processEvent(XoaEventKind.DISCONNECT, new String[] {}); + } + + @Override + public final void eventReceived(Proxy proxy, XoaEventKind eventKind, Object[] params) { + LOG.entering(CLASS_NAME, "eventReceived", new Object[] { proxy.getHandlerId(), eventKind, params }); + if (LOG.isLoggable(Level.FINEST)) { + LOG.log(Level.FINEST, "SOA <-- XOA:" + "id = " + proxy + " name: " + eventKind); + } + + WebSocketNativeChannel channel = (WebSocketNativeChannel)proxy.getPeer(); + + switch (eventKind) { + case OPEN: + String protocol = (String)params[0]; + listener.connectionOpened(channel, protocol); + break; + case CLOSED: + channel.setProxy(null); + listener.connectionClosed(channel, false, 1006, ""); //pass default close code and reason here + break; + case REDIRECT: + String redirectUrl = (String)params[0]; + listener.redirected(channel, redirectUrl); + break; + case AUTHENTICATE: + String location = channel.getLocation().toString(); + String challenge = (String)params[0]; + listener.authenticationRequested(channel, location, challenge); + break; + case MESSAGE: + WrappedByteBuffer messageBuffer = WrappedByteBuffer.wrap((java.nio.ByteBuffer) params[0]); + String messageType = params.length > 1 ? (String)params[1] : null; + + if (LOG.isLoggable(Level.FINEST)) { + LOG.log(Level.FINEST, messageBuffer.getHexDump()); + } + + if (messageType == null) { + LOG.severe("Incompatible bridge detected"); + listener.connectionFailed(channel, new IllegalStateException("Incompatible bridge detected")); + } + + if ("TEXT".equals(messageType)) { + String text = messageBuffer.getString(UTF8); + listener.textMessageReceived(channel, text); + } + else { + listener.binaryMessageReceived(channel, messageBuffer); + } + break; + + case ERROR: + listener.connectionFailed(channel, new IllegalStateException("ERROR event in the native bridge handler")); + break; + } + } + + public void setListener(WebSocketHandlerListener listener) { + this.listener = listener; + } + + @Override + public void setIdleTimeout(WebSocketChannel channel, int timeout) { + + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/XoaEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/XoaEvent.java new file mode 100644 index 0000000..de004fd --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/XoaEvent.java @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.bridge; + +import java.io.Serializable; +import java.util.logging.Logger; + +public class XoaEvent implements Serializable { + private static final String CLASS_NAME = XoaEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final long serialVersionUID = 1L; + public static final String[] EMPTY_ARGS = new String[] {}; + + private Integer handlerId; + private XoaEventKind kind; + private Object[] params; + + public XoaEvent(Integer handlerId, XoaEventKind event, Object[] params) { + LOG.entering(CLASS_NAME, "", new Object[] { handlerId, event, params }); + this.handlerId = handlerId; + this.kind = event; + this.params = params; + } + + public Integer getHandlerId() { + LOG.exiting(CLASS_NAME, "getHandlerId", handlerId); + return handlerId; + } + + public XoaEventKind getKind() { + LOG.exiting(CLASS_NAME, "getEvent", kind); + return kind; + } + + public Object[] getParams() { + LOG.exiting(CLASS_NAME, "getParams", params); + return params; + } + + public String toString() { + String out = "EventID:" + getHandlerId() + "," + getKind().name() + "["; + for (int i = 0; i < params.length; i++) { + out += params[i] + ","; + } + return out + "]"; + } + + public enum XoaEventKind { + + // Entries for WebSocket and ByteSocket events + OPEN("open"), // handlerid + MESSAGE("message"), // handlerid, message (string) + CLOSED("closed"), // handlerid + REDIRECT("redirect"), // handlerid, location(string) + AUTHENTICATE("authenticate"), // handlerid, challenge(string) + AUTHORIZE("authorize"), // handlerid, authorizeToken(string) + + // Entries for StreamingHttpRequest events + LOAD("load"), // handlerid + PROGRESS("progress"), // handlerid, ??? + READYSTATECHANGE("readystatechange"), // handlerid, state + ERROR("error"), // handlerid + ABORT("abort"), // handlerid + + // Entries for WebSocket and ByteSocket methods + CREATE("create"), // handlerid, type, wsurl, originurl + POSTMESSAGE("postMessage"), // handlerid, message (string) + DISCONNECT("disconnect"), // handlerid + + // OPEN("open"), // handlerid //*** Also an event name + SEND("send"), // handlerid + GETRESPONSEHEADER("getResponseHeader"), // handlerid, header + GEALLRESPONSEHEADERS("getAllResponseHeaders"), // handlerid + SETREQUESTHEADER("setRequestHeader"), // handlerid, header, value + // ABORT("abort"), // handlerid // *** also an event name + UNDEFINED(""); + + String name; + + XoaEventKind(String in) { + name = in; + } + + public static XoaEventKind getName(String in) { + final XoaEventKind[] v = values(); + for (int i = 0; i < v.length; i++) { + if (v[i].name.equals(in)) { + return v[i]; + } + } + return UNDEFINED; + } + + public String toString() { + return name; + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequest.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequest.java new file mode 100644 index 0000000..9b1fef3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequest.java @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.util.HttpURI; + +public class HttpRequest { + + private static final String CLASS_NAME = HttpRequest.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + static volatile int nextId = 1; + final int id; + + public static final HttpRequestFactory HTTP_REQUEST_FACTORY = new HttpRequestFactory() { + @Override + public HttpRequest createHttpRequest(Method method, HttpURI uri, boolean async) { + return new HttpRequest(method, uri, async); + } + }; + + /** Possible ready states for the request */ + public enum ReadyState { + /** Request has not yet been sent */ + UNSENT, + /** Request is ready to be sent. Data can be sent at this time for POST requests */ + READY, + /** Request is in the process of sending. No further data can be written at this time. */ + SENDING, + /** Request has been sent, but no response has been received */ + SENT, + /** Response has been partially received. All headers are available. */ + OPENED, + /** Response has been partially received. Some data is available. */ + LOADING, + /** Response has been completed. All data is available. */ + LOADED, + /** An error occurred during the request or response. */ + ERROR; + } + + /** Current ready state for this request */ + private ReadyState readyState = ReadyState.UNSENT; + + /** Methods available for HttpRequest */ + public enum Method { + GET, POST + } + + /** Method specified for this request */ + private Method method; + + /** URI specified for this request */ + private HttpURI uri; + + /** True if progress events are returned as data is received */ + private boolean async; + + /** Headers associated with request. Headers must be set before or during requestReady event */ + private Map headers = new HashMap(); + + /** Response received for this request */ + private HttpResponse response; + + /** Higher layer managing this request */ + public Channel parent; + + /** Underlying object representing this request */ + private Object proxy; + + /** Handler for this request */ + HttpRequestHandler transportHandler; + + /** Creates an HttpRequest with method and uri specified. Async is true by default. */ + public HttpRequest(Method method, HttpURI uri) { + this(method, uri, true); + } + + /** Creates an HttpRequest with method and uri specified. + * Async true means fire progress events as data is received. */ + public HttpRequest(Method method, HttpURI uri, boolean async) { + this.id = nextId++; + + if (uri == null) { + LOG.severe("HTTP request URL is null"); + throw new IllegalArgumentException("HTTP request URL is null"); + } + + if (method == null) { + LOG.severe("Invalid Method in an HTTP request"); + throw new IllegalArgumentException("Invalid Method in an HTTP request"); + } + + this.method = method; + this.uri = uri; + this.async = async; + } + + /** Get Method specified for this request */ + public Method getMethod() { + return method; + } + + /** Get URI specified for this request */ + public HttpURI getUri() { + return uri; + } + + /** Return true if progress events will fire as data is received */ + public boolean isAsync() { + return async; + } + + /** Set ready state for this request */ + public void setReadyState(ReadyState readyState) { + this.readyState = readyState; + } + + /** Get ready state for this request */ + public ReadyState getReadyState() { + return readyState; + } + + /** Set header for this request */ + public void setHeader(String header, String value) { + headers.put(header, value); + } + + /** Get all headers for this request */ + public Map getHeaders() { + return headers; + } + + /** Get the response associated with this request. + * Returns null if response has not yet been received. */ + public HttpResponse getResponse() { + return response; + } + + /** Sets the response associated with this request. */ + public void setResponse(HttpResponse response) { + this.response = response; + } + + /** Gets the proxy object associated with this request. */ + public Object getProxy() { + return proxy; + } + + /** Sets the proxy object associated with this request */ + public void setProxy(Object proxy) { + this.proxy = proxy; + } + + @Override + public String toString() { + return "[Request "+id+": "+method+" "+uri+" async:"+async+"]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestAuthenticationHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestAuthenticationHandler.java new file mode 100644 index 0000000..e675f18 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestAuthenticationHandler.java @@ -0,0 +1,340 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.net.HttpURLConnection; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map.Entry; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.auth.AuthenticationUtil; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.wseb.WebSocketEmulatedChannel; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.StringUtils; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.auth.ChallengeRequest; +import org.kaazing.net.auth.ChallengeResponse; +import org.kaazing.net.impl.util.ResumableTimer; + +/* + * WebSocket Emulated Handler Chain + * EmulateHandler + * |- CreateHandler - HttpRequestAuthenticationHandler - {HttpRequestRedirectHandler} - HttpRequestBridgeHandler + * |- UpstreamHandler - HttpRequestBridgeHandler + * |- DownstreamHandler - HttpRequestBridgeHandler + * Responsibilities: + * a). handle authentication challenge (HTTP 401) + * + * TODO: + * n/a + */ +public class HttpRequestAuthenticationHandler extends HttpRequestHandlerAdapter { + + private static final String CLASS_NAME = HttpRequestAuthenticationHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String WWW_AUTHENTICATE = "WWW-Authenticate: "; + private static final String APPLICATION_PREFIX = "Application "; + + private static final String HTTP_1_1_START = "HTTP/1.1"; + private static final int HTTP_1_1_START_LEN = HTTP_1_1_START.length(); + private static final byte[] HTTP_1_1_START_BYTES = StringUtils.getUtf8Bytes(HTTP_1_1_START); + + + + + private void handleClearAuthenticationData(HttpRequest request) { + Channel channel = getWebSocketChannel(request); + if(channel == null) + return; + ChallengeHandler nextChallengeHandler = null; + if (channel.challengeResponse != null) { + nextChallengeHandler = channel.challengeResponse.getNextChallengeHandler(); + channel.challengeResponse.clearCredentials(); + channel.challengeResponse = null; + } + channel.challengeResponse = new ChallengeResponse(null, nextChallengeHandler); + } + + private void handleRemoveAuthenticationData(HttpRequest request) { + handleClearAuthenticationData(request); + } + + protected static String[] getLines(WrappedByteBuffer buf) { + List lineList = new ArrayList(); + while (buf.hasRemaining()) { + byte next = buf.get(); + List lineText = new ArrayList(); + while (next != 13) { // CR + lineText.add(next); + if (buf.hasRemaining()) { + next = buf.get(); + } else { + break; + } + } + if (buf.hasRemaining()) { + next = buf.get(); // should be LF + } + byte[] lineTextBytes = new byte[lineText.size()]; + int i = 0; + for (Byte text : lineText) { + lineTextBytes[i] = text; + i++; + } + lineList.add(new String(lineTextBytes, UTF_8)); + } + String[] lines = new String[lineList.size()]; + lineList.toArray(lines); + return lines; + } + + public static boolean isHTTPResponse(WrappedByteBuffer buf) { + if ( buf.remaining() < HTTP_1_1_START_LEN) { + return false; + } + + for (int i = 0; i < HTTP_1_1_START_LEN; i++) { + if (buf.getAt(i) != HTTP_1_1_START_BYTES[i]) { + return false; + } + } + + return true; + } + + private void onLoadWrappedHTTPResponse(HttpRequest request, HttpResponse response) throws Exception { + LOG.entering(CLASS_NAME, "onLoadWrappedHTTPResponse"); + + WrappedByteBuffer responseBody = response.getBody(); + String[] lines = getLines(responseBody); + + int statusCode = Integer.parseInt(lines[0].split(" ")[1]); + if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) { + String wwwAuthenticate = null; + for (int i = 1; i < lines.length; i++) { + if (lines[i].startsWith(WWW_AUTHENTICATE)) { + wwwAuthenticate = lines[i].substring(WWW_AUTHENTICATE.length()); + break; + } + } + if (LOG.isLoggable(Level.FINEST)) { + LOG.finest("connectToWebSocket.onLoadWrappedHTTPResponse: WWW-Authenticate: " + StringUtils.stripControlCharacters(wwwAuthenticate)); + } + + if (wwwAuthenticate == null || "".equals(wwwAuthenticate)) { + throw new IllegalStateException("Missing authentication challenge in wrapped HTTP 401 response"); + } else if (!wwwAuthenticate.startsWith(APPLICATION_PREFIX)) { + throw new IllegalStateException("Only Application challenges are supported by the client"); + } + + String rawChallenge = wwwAuthenticate.substring(APPLICATION_PREFIX.length()); + handle401(request, rawChallenge); + } + else { + throw new IllegalStateException("Unsupported wrapped response with HTTP status code " + statusCode); + } + } + + private void handle401(HttpRequest request, String challenge) throws Exception { + LOG.entering(CLASS_NAME, "handle401"); + + HttpURI uri = request.getUri(); + WebSocketEmulatedChannel channel = (WebSocketEmulatedChannel)getWebSocketChannel(request); + if(channel == null) { + throw new IllegalStateException("There is no WebSocketChannel associated with this request"); + } + if (isWebSocketClosing(request)) { + return; //WebSocket is closing/closed, quit authenticate process + } + + ResumableTimer connectTimer = null; + if (((WebSocketCompositeChannel)channel.getParent()) != null) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + connectTimer = parent.getConnectTimer(); + if (connectTimer != null) { + // Pause the connect timer while the user is providing the credentials. + connectTimer.pause(); + } + } + + channel.authenticationReceived = true; + String challengeUrl = channel.getLocation().toString(); + if (channel.redirectUri != null) { + String path = channel.redirectUri.getPath(); + if ((path != null) && path.contains("/;e/")) { + // path "/;e/cbm" was added in WebSocketEmulatedHandler. It is + // returned by balancer, so we should remove it. + int index = path.indexOf("/;e/"); + path = path.substring(0, index); + } + + challengeUrl = channel.redirectUri.getScheme() + "://" + channel.redirectUri.getURI().getAuthority() + path; + } + ChallengeRequest challengeRequest = new ChallengeRequest(challengeUrl, challenge); + try { + channel.challengeResponse = AuthenticationUtil.getChallengeResponse(channel, challengeRequest, channel.challengeResponse); + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage()); + handleClearAuthenticationData(request); + throw new IllegalStateException("Unexpected error processing challenge "+challenge, e); + } + + if (channel.challengeResponse == null || channel.challengeResponse.getCredentials() == null) { + throw new IllegalStateException("No response possible for challenge "+challenge); + } + + if (LOG.isLoggable(Level.FINEST)) { + LOG.finest("response from challenge handler = " + StringUtils.stripControlCharacters(String.valueOf(channel.challengeResponse.getCredentials()))); + } + + try { + HttpRequest newRequest = new HttpRequest(request.getMethod(), uri, request.isAsync()); + newRequest.parent = request.parent; + +// newRequest.setHeader("Content-Type", "text/plain"); + for (Entry entry : request.getHeaders().entrySet()) { + newRequest.setHeader(entry.getKey(), entry.getValue()); + } + + // Resume the connect timer before invoking processOpen(). + if (connectTimer != null) { + connectTimer.resume(); + } + + processOpen(newRequest); + } + catch (Exception e1) { + LOG.log(Level.FINE, e1.getMessage(), e1); + throw new Exception("Unable to authenticate user", e1); + } + } + + @Override + public void processOpen(HttpRequest request) { + WebSocketEmulatedChannel channel = (WebSocketEmulatedChannel)getWebSocketChannel(request); + if(channel != null) { + if (isWebSocketClosing(request)) { + return; //WebSocket is closing/closed, quit authenticate process + } + if (channel.challengeResponse.getCredentials() != null) { + String credentials = new String(channel.challengeResponse.getCredentials()); + LOG.finest("requestOpened: Authorization: " + StringUtils.stripControlCharacters(credentials)); + request.setHeader(HEADER_AUTHORIZATION, credentials); + handleClearAuthenticationData(request); + } + } + nextHandler.processOpen(request); + } + + @Override + public void setNextHandler(HttpRequestHandler handler) { + super.setNextHandler(handler); + + handler.setListener(new HttpRequestListener() { + + @Override + public void requestReady(HttpRequest request) { + listener.requestReady(request); + } + + @Override + public void requestOpened(HttpRequest request) { + listener.requestOpened(request); + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + listener.requestProgressed(request, payload); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + int responseCode = response.getStatusCode(); + switch (responseCode) { + case 200: + WrappedByteBuffer responseBuffer = response.getBody(); + if (isHTTPResponse(responseBuffer)) { + try { + onLoadWrappedHTTPResponse(request, response); + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + else { + handleRemoveAuthenticationData(request); + listener.requestLoaded(request, response); + } + break; + + case 401: + String challenge = response.getHeader(HEADER_WWW_AUTHENTICATE); + try { + handle401(request, challenge); + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage()); + listener.errorOccurred(request, e); + } + break; + + default: + handleRemoveAuthenticationData(request); + listener.requestLoaded(request, response); + } + } + + @Override + public void requestClosed(HttpRequest request) { + handleRemoveAuthenticationData(request); + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + handleRemoveAuthenticationData(request); + listener.errorOccurred(request, exception); + } + + @Override + public void requestAborted(HttpRequest request) { + handleRemoveAuthenticationData(request); + listener.requestAborted(request); + } + }); + } + + + @Override + public void setListener(HttpRequestListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestDelegateHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestDelegateHandler.java new file mode 100644 index 0000000..86d8fb0 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestDelegateHandler.java @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.net.URL; +import java.util.Map.Entry; + +import org.kaazing.gateway.client.impl.bridge.HttpRequestBridgeHandler; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.wseb.WebSocketEmulatedChannel; +import org.kaazing.gateway.client.impl.wsn.WebSocketNativeDelegateHandler; +import org.kaazing.gateway.client.transport.CloseEvent; +import org.kaazing.gateway.client.transport.ErrorEvent; +import org.kaazing.gateway.client.transport.LoadEvent; +import org.kaazing.gateway.client.transport.OpenEvent; +import org.kaazing.gateway.client.transport.ProgressEvent; +import org.kaazing.gateway.client.transport.ReadyStateChangedEvent; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegate; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegateImpl; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegateListener; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class HttpRequestDelegateHandler implements HttpRequestHandler { + + HttpRequestListener listener; + + @Override + public void processOpen(final HttpRequest request) { + HttpRequestDelegate delegate = new HttpRequestDelegateImpl(); + try { + request.setProxy(delegate); + String origin = "privileged://" + WebSocketNativeDelegateHandler.getCanonicalHostPort(request.getUri().getURI()); + + delegate.setListener(new HttpRequestDelegateListener() { + + @Override + public void readyStateChanged(ReadyStateChangedEvent event) { + Object[] params = (Object[]) event.getParams(); + int state = Integer.parseInt((String) params[0]); + if (state == 2) { + HttpResponse response = new HttpResponse(); + request.setResponse(response); + + if (params.length > 1) { + int responseCode = Integer.parseInt((String) params[1]); + if (responseCode != 0) { + response.setStatusCode(responseCode); + response.setMessage(((String) params[2])); + HttpRequestBridgeHandler.parseResponseHeaders(response, ((String) params[3])); + } + } + request.setReadyState(HttpRequest.ReadyState.OPENED); + listener.requestOpened(request); + } + } + + @Override + public void progressed(ProgressEvent progressEvent) { + java.nio.ByteBuffer payload = progressEvent.getPayload(); + WrappedByteBuffer buffer = WrappedByteBuffer.wrap(payload); + + request.setReadyState(HttpRequest.ReadyState.LOADING); + try { + listener.requestProgressed(request, buffer); + } catch (Exception e) { +// LOG.log(Level.FINE, e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + + @Override + public void opened(OpenEvent event) { + HttpRequestDelegate delegate = (HttpRequestDelegate)request.getProxy(); + + // Allow headers to be set via opened + request.setReadyState(HttpRequest.ReadyState.READY); + listener.requestOpened(request); + + // Then set headers + for (Entry entry : request.getHeaders().entrySet()) { + String header = entry.getKey(); + String value = entry.getValue(); + HttpRequestUtil.validateHeader(header); + delegate.setRequestHeader(header, value); + } + + // Nothing has been sent + if (request.getMethod() == Method.POST) { + listener.requestReady(request); + } + else { + processSend(request, null); + } + } + + @Override + public void loaded(LoadEvent event) { + WrappedByteBuffer responseBuffer = WrappedByteBuffer.wrap(event.getResponseBuffer()); + request.setReadyState(HttpRequest.ReadyState.LOADED); + + HttpResponse response = request.getResponse(); + response.setBody(responseBuffer); + + try { + listener.requestLoaded(request, response); + } catch (Exception e) { +// LOG.log(Level.FINE, e.getMessage(), e); + listener.errorOccurred(request, e); + } + } + + @Override + public void closed(CloseEvent event) { + listener.requestClosed(request); + } + + @Override + public void errorOccurred(ErrorEvent event) { + listener.errorOccurred(request, event.getException()); + } + }); + + String method = request.getMethod().toString(); + URL url = request.getUri().getURI().toURL(); + boolean isAsync = request.isAsync(); + int connectTimeout = (int) getConnectTimeout(request); + delegate.processOpen(method, url, origin, isAsync, connectTimeout); + } catch (Exception e) { + listener.errorOccurred(request, e); + } + } + + @Override + public void processSend(HttpRequest request, WrappedByteBuffer content) { +// LOG.entering(CLASS_NAME, "processSend", content); + + if (request.getReadyState() != HttpRequest.ReadyState.READY) { + throw new IllegalStateException("HttpRequest must be in READY state to send"); + } + + request.setReadyState(HttpRequest.ReadyState.SENDING); + + java.nio.ByteBuffer payload; + if (content == null) { + payload = java.nio.ByteBuffer.allocate(0); + } else { + payload = java.nio.ByteBuffer.wrap(content.array(), content.arrayOffset(), content.remaining()); + } + + HttpRequestDelegate delegate = (HttpRequestDelegate)request.getProxy(); + delegate.processSend(payload); + request.setReadyState(HttpRequest.ReadyState.SENT); + } + + @Override + public void processAbort(HttpRequest request) { + HttpRequestDelegate delegate = (HttpRequestDelegate)request.getProxy(); + delegate.processAbort(); + } + + @Override + public void setListener(HttpRequestListener listener) { + this.listener = listener; + } + + private long getConnectTimeout(HttpRequest request) { + WebSocketCompositeChannel compChannel = getWebSocketCompositeChannel(request); + if (compChannel != null) { + if (compChannel.getConnectTimer() != null) { + return compChannel.getConnectTimer().getDelay(); + } + } + + return 0L; + } + + private WebSocketCompositeChannel getWebSocketCompositeChannel(HttpRequest request) { + if (request.parent != null) { + WebSocketEmulatedChannel emulatedChannel = (WebSocketEmulatedChannel) request.parent.getParent(); + if (emulatedChannel != null) { + return (WebSocketCompositeChannel) emulatedChannel.getParent(); + } + + return null; + } + + return null; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestEvent.java new file mode 100644 index 0000000..ee629a2 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestEvent.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.util.logging.Logger; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class HttpRequestEvent { + private static final String CLASS_NAME = HttpRequestEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final long serialVersionUID = -7922410353957227356L; + + private HttpRequest source; + private final Kind kind; + private final WrappedByteBuffer data; + + /** + * Type of the HttpRequestEvent. + */ + public enum Kind { + OPEN, LOAD, PROGRESS, ERROR, READYSTATECHANGE, ABORT + } + + public HttpRequestEvent(HttpRequest source, Kind kind) { + this(source, kind, null); + } + + public HttpRequestEvent(HttpRequest source, Kind kind, WrappedByteBuffer data) { + this.source = source; + LOG.entering(CLASS_NAME, "", new Object[] { source, kind, data }); + this.kind = kind; + this.data = data; + } + + public HttpRequest getSource() { + return source; + } + + public Kind getKind() { + return kind; + } + + public WrappedByteBuffer getData() { + return data; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestFactory.java new file mode 100644 index 0000000..1963a87 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestFactory.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.util.HttpURI; + +public interface HttpRequestFactory { + + HttpRequest createHttpRequest(Method method, HttpURI uri, boolean async); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandler.java new file mode 100644 index 0000000..bd04c8f --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandler.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public interface HttpRequestHandler { + + void processOpen(HttpRequest request); + void processSend(HttpRequest request, WrappedByteBuffer buffer); + void processAbort(HttpRequest request); + + void setListener(HttpRequestListener listener); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerAdapter.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerAdapter.java new file mode 100644 index 0000000..73c7838 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerAdapter.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.ws.ReadyState; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class HttpRequestHandlerAdapter implements HttpRequestHandler { + + protected HttpRequestHandler nextHandler; + protected HttpRequestListener listener; + + @Override + public void processOpen(HttpRequest request) { + nextHandler.processOpen(request); + } + + @Override + public void processSend(HttpRequest request, WrappedByteBuffer buffer) { + nextHandler.processSend(request, buffer); + } + + @Override + public void processAbort(HttpRequest request) { + nextHandler.processAbort(request); + } + + @Override + public void setListener(HttpRequestListener listener) { + this.listener = listener; + } + + public void setNextHandler(HttpRequestHandler handler) { + this.nextHandler = handler; + } + + + public Channel getWebSocketChannel(HttpRequest request) { + if (request.parent != null) { + return request.parent.getParent(); + } + else { + return null; + } + } + + // return true if WebSocket connection is closing or closed + // parameter: channel - WebSockectEmulatedChannel + public boolean isWebSocketClosing(HttpRequest request) { + Channel channel = getWebSocketChannel(request); + if (channel != null && channel.getParent() != null) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + if (parent != null) { + return parent.getReadyState() == ReadyState.CLOSED || parent.getReadyState() == ReadyState.CLOSING; + } + } + return false; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerFactory.java new file mode 100644 index 0000000..575918d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerFactory.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + + +public interface HttpRequestHandlerFactory { + + HttpRequestHandler createHandler(); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestListener.java new file mode 100644 index 0000000..1447f30 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestListener.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public interface HttpRequestListener { + + /** Invoked when the request is ready to send data upstream */ + void requestReady(HttpRequest request); + + /** Invoked when the response status code and headers are available */ + void requestOpened(HttpRequest request); + + /** Invoked when streamed data is received on the response */ + void requestProgressed(HttpRequest request, WrappedByteBuffer payload); + + /** Invoked when the response has completed and all data is available */ + void requestLoaded(HttpRequest request, HttpResponse response); + + /** Invoked when the request has been aborted */ + void requestAborted(HttpRequest request); + + /** Invoked when the request has closed and no longer valid */ + void requestClosed(HttpRequest request); + + /** Invoked when an error has occurred while processing the request or response */ + void errorOccurred(HttpRequest request, Exception exception); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestLoggingHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestLoggingHandler.java new file mode 100644 index 0000000..30098e9 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestLoggingHandler.java @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.util.logging.Logger; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class HttpRequestLoggingHandler extends HttpRequestHandlerAdapter { + + private static final String CLASS_NAME = HttpRequestLoggingHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + @Override + public void processOpen(HttpRequest request) { + LOG.fine("->OPEN: "+request); + super.processOpen(request); + } + + @Override + public void processSend(HttpRequest request, WrappedByteBuffer buffer) { + LOG.fine("->SEND: "+request+" "+buffer.getHexDump()); + super.processSend(request, buffer); + } + + @Override + public void processAbort(HttpRequest request) { + LOG.fine("->ABORT: "+request); + super.processAbort(request); + } + + @Override + public void setNextHandler(HttpRequestHandler handler) { + this.nextHandler = handler; + + handler.setListener(new HttpRequestListener() { + + @Override + public void requestReady(HttpRequest request) { + LOG.fine("<-READY: "+request); + listener.requestReady(request); + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + LOG.fine("<-PROGRESSED: "+request+" "+payload.getHexDump()); + listener.requestProgressed(request, payload); + } + + @Override + public void requestOpened(HttpRequest request) { + LOG.fine("<-OPENED: "+request); + listener.requestOpened(request); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + LOG.fine("<-LOADED: "+request+" "+response); + listener.requestLoaded(request, response); + } + + @Override + public void requestClosed(HttpRequest request) { + LOG.fine("<-CLOSED: "+request); + listener.requestClosed(request); + } + + @Override + public void requestAborted(HttpRequest request) { + LOG.fine("<-ABORTED: "+request); + listener.requestAborted(request); + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + LOG.fine("<-ERROR: "+request); + listener.errorOccurred(request, exception); + } + }); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestRedirectHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestRedirectHandler.java new file mode 100644 index 0000000..f9df8d6 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestRedirectHandler.java @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import static java.util.Collections.unmodifiableMap; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.wseb.WebSocketEmulatedChannel; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.StringUtils; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.http.HttpRedirectPolicy; +/* + * WebSocket Emulated Handler Chain + * EmulateHandler + * |- CreateHandler - HttpRequestAuthenticationHandler - {HttpRequestRedirectHandler} - HttpRequestBridgeHandler + * |- UpstreamHandler - HttpRequestBridgeHandler + * |- DownstreamHandler - HttpRequestBridgeHandler + * Responsibilities: + * a). handle redirect (HTTP 301) + * + * TODO: + * n/a + */ +public class HttpRequestRedirectHandler extends HttpRequestHandlerAdapter { + + private static final String CLASS_NAME = HttpRequestRedirectHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + @Override + public void setNextHandler(HttpRequestHandler handler) { + super.setNextHandler(handler); + + handler.setListener(new HttpRequestListener() { + + @Override + public void requestReady(HttpRequest request) { + listener.requestReady(request); + } + + @Override + public void requestOpened(HttpRequest request) { + listener.requestOpened(request); + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + listener.requestProgressed(request, payload); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + int responseCode = response.getStatusCode(); + switch (responseCode) { + case 301: + case 302: + case 307: + // handle the redirect (possibly cross-scheme) + String redirectedLocation = response.getHeader("Location"); + if (LOG.isLoggable(Level.FINEST)) { + LOG.finest("redirectedLocation = " + StringUtils.stripControlCharacters(redirectedLocation)); + } + + if (redirectedLocation == null) { + throw new IllegalStateException("Redirect response missing location header: " + responseCode); + } + + try { + HttpURI uri = new HttpURI(redirectedLocation); + + HttpRequest redirectRequest = new HttpRequest(request.getMethod(), uri, request.isAsync()); + redirectRequest.parent = request.parent; + WebSocketEmulatedChannel channel = (WebSocketEmulatedChannel)request.parent.getParent(); + channel.redirectUri = uri; + + WebSocketCompositeChannel compChannel = (WebSocketCompositeChannel)channel.getParent(); + HttpRedirectPolicy policy = compChannel.getFollowRedirect(); + URI currentURI = channel.getLocation().getURI(); + URI redirectURI = uri.getURI(); + + // When redirected while using emulated connection. the schemes of the currentURI and + // redirectURI will be different. So, we should normalize it before enforcing the + // redirect policy. + String normalizedRedirectScheme = redirectURI.getScheme().toLowerCase().replace("http", "ws"); + URI normalizedRedirectURI = new URI(normalizedRedirectScheme, redirectURI.getSchemeSpecificPart(), null); + if ((policy != null) && (policy.compare(currentURI, normalizedRedirectURI) != 0)) { + String s = String.format("%s: Cannot redirect from '%s' to '%s'", + policy, currentURI, normalizedRedirectURI); + channel.preventFallback = true; + throw new IllegalStateException(s); + } + + for (Entry entry : request.getHeaders().entrySet()) { + redirectRequest.setHeader(entry.getKey(), entry.getValue()); + } + nextHandler.processOpen(redirectRequest); + + } catch (Exception e) { + LOG.log(Level.WARNING, e.getMessage(), e); + throw new IllegalStateException("Redirect to a malformed URL: " + redirectedLocation, e); + } + break; + + default: + listener.requestLoaded(request, response); + break; + } + } + + @Override + public void requestClosed(HttpRequest request) { + listener.requestClosed(request); + } + + @Override + public void requestAborted(HttpRequest request) { + listener.requestAborted(request); + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + listener.errorOccurred(request, exception); + } + }); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestTransportHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestTransportHandler.java new file mode 100644 index 0000000..36da8f0 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestTransportHandler.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.bridge.HttpRequestBridgeHandler; +import org.kaazing.gateway.client.impl.ws.WebSocketTransportHandler; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class HttpRequestTransportHandler extends HttpRequestHandlerAdapter { + + private static final String CLASS_NAME = HttpRequestTransportHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + public static HttpRequestHandlerFactory DEFAULT_FACTORY = new HttpRequestHandlerFactory() { + + @Override + public HttpRequestHandler createHandler() { + HttpRequestHandler requestHandler = new HttpRequestTransportHandler(); + + if (LOG.isLoggable(Level.FINE)) { + HttpRequestLoggingHandler loggingHandler = new HttpRequestLoggingHandler(); + loggingHandler.setNextHandler(requestHandler); + requestHandler = loggingHandler; + } + + return requestHandler; + } + }; + + @Override + public void processOpen(HttpRequest request) { + LOG.entering(CLASS_NAME, "processOpen: "+request); + + HttpRequestHandler transportHandler; + if (WebSocketTransportHandler.useBridge(request.getUri().getURI())) { + transportHandler = new HttpRequestBridgeHandler(); + } + else { + transportHandler = new HttpRequestDelegateHandler(); + } + + request.transportHandler = transportHandler; + + transportHandler.setListener(new HttpRequestListener() { + + @Override + public void requestReady(HttpRequest request) { + listener.requestReady(request); + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + listener.requestProgressed(request, payload); + } + + @Override + public void requestOpened(HttpRequest request) { + listener.requestOpened(request); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + listener.requestLoaded(request, response); + } + + @Override + public void requestClosed(HttpRequest request) { + listener.requestClosed(request); + } + + @Override + public void requestAborted(HttpRequest request) { + listener.requestAborted(request); + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + listener.errorOccurred(request, exception); + } + }); + + transportHandler.processOpen(request); + } + + @Override + public void processSend(HttpRequest request, WrappedByteBuffer buffer) { + LOG.entering(CLASS_NAME, "processSend: "+request); + + HttpRequestHandler transportHandler = request.transportHandler; + transportHandler.processSend(request, buffer); + } + + @Override + public void processAbort(HttpRequest request) { + LOG.entering(CLASS_NAME, "processAbort: "+request); + + HttpRequestHandler transportHandler = request.transportHandler; + transportHandler.processAbort(request); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestUtil.java new file mode 100644 index 0000000..ae2e362 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestUtil.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.util.logging.Logger; + +public class HttpRequestUtil { + private static final String CLASS_NAME = HttpRequestUtil.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private HttpRequestUtil() { + LOG.entering(CLASS_NAME, ""); + } + + public static void validateHeader(String header) { + LOG.entering(CLASS_NAME, "validateHeader", header); + /* + * From the XMLHttpRequest spec: http://www.w3.org/TR/XMLHttpRequest/#setrequestheader + * + * For security reasons, these steps should be terminated if the header argument case-insensitively matches one of the + * following headers: + * + * Accept-Charset Accept-Encoding Connection Content-Length Content-Transfer-Encoding Date Expect Host Keep-Alive Referer + * TE Trailer Transfer-Encoding Upgrade Via Proxy-* Sec-* + * + * Also for security reasons, these steps should be terminated if the start of the header argument case-insensitively + * matches Proxy- or Se + */ + if (header == null || (header.length() == 0)) { + throw new IllegalArgumentException("Invalid header in the HTTP request"); + } + String lowerCaseHeader = header.toLowerCase(); + if (lowerCaseHeader.startsWith("proxy-") || lowerCaseHeader.startsWith("sec-")) { + throw new IllegalArgumentException("Headers starting with Proxy-* or Sec-* are prohibited"); + } + for (String prohibited : INVALID_HEADERS) { + if (header.equalsIgnoreCase(prohibited)) { + throw new IllegalArgumentException("Headers starting with Proxy-* or Sec-* are prohibited"); + } + } + } + + private static final String[] INVALID_HEADERS = new String[] { "Accept-Charset", "Accept-Encoding", "Connection", + "Content-Length", "Content-Transfer-Encoding", "Date", "Expect", "Host", "Keep-Alive", "Referer", "TE", "Trailer", + "Transfer-Encoding", "Upgrade", "Via" }; + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpResponse.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpResponse.java new file mode 100644 index 0000000..7da1db3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpResponse.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.http; + +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class HttpResponse { + + private int statusCode = 0; + private String message; + private Map headers = new HashMap(); + private WrappedByteBuffer responseBuffer; + + public int getStatusCode() { + return statusCode; + } + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public String setHeader(String header, String value) { + return headers.put(header, value); + } + + public String getHeader(String header) { + return headers.get(header); + } + + public String getAllHeaders() { + StringBuffer buf = new StringBuffer(); + for (Entry entry : headers.entrySet()) { + buf.append(entry.getKey() + ":" + entry.getValue() + "\n"); + } + return buf.toString(); + } + + public WrappedByteBuffer getBody() { + return responseBuffer.duplicate(); + } + + public void setBody(WrappedByteBuffer responseBuffer) { + this.responseBuffer = responseBuffer; + } + + public String toString() { + String headers = getAllHeaders(); + if (headers != null) { + headers = "\n" + headers; + } + return "[RESPONSE "+getStatusCode()+" "+getMessage()+headers+"]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSCompositeURI.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSCompositeURI.java new file mode 100644 index 0000000..41a23e7 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSCompositeURI.java @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.util; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; + +import org.kaazing.gateway.client.util.GenericURI; + + +/** + * URI with guarantee to be a valid, non-null, ws URI + */ +public class WSCompositeURI extends GenericURI { + + static Map wsEquivalent = new HashMap(); + static { + wsEquivalent.put("ws", "ws"); + wsEquivalent.put("wse", "ws"); + wsEquivalent.put("wsn", "ws"); + + wsEquivalent.put("wss", "wss"); + wsEquivalent.put("wssn", "wss"); + wsEquivalent.put("wse+ssl", "wss"); + + wsEquivalent.put("java:ws", "ws"); + wsEquivalent.put("java:wse", "ws"); + wsEquivalent.put("java:wss", "wss"); + wsEquivalent.put("java:wse+ssl", "wss"); + } + + String scheme = null; + + protected boolean isValidScheme(String scheme) { + return wsEquivalent.get(scheme) != null; + } + + /* + * This class is needed to workaround java URI's inability to handle java:ws style composite prefixes. + */ + public WSCompositeURI(String location) throws URISyntaxException { + this(new URI(location)); + } + + public WSCompositeURI(URI uri) throws URISyntaxException { + super(uri); + } + +// private static String normalize(String location) { +// return location.startsWith("java:") ? location.substring(5) : location; +// } + + protected WSCompositeURI duplicate(URI uri) { + try { + return new WSCompositeURI(uri); + } + catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + public boolean isSecure() { + String scheme = getScheme(); + return "wss".equals(wsEquivalent.get(scheme)); + } + + public WSURI getWSEquivalent() { + try { + String wsEquivScheme = wsEquivalent.get(getScheme()); + return WSURI.replaceScheme(this.uri, wsEquivScheme); + } + catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + @Override + public String getScheme() { + // Workaround URI behavior that returns only "java" instead of "java:ws" + if (scheme == null) { + String location = uri.toString(); + int schemeEndIndex = location.indexOf("://"); + if (schemeEndIndex != -1) { + scheme = location.substring(0, schemeEndIndex); + } + else { + scheme = uri.toString(); + } + } + return scheme; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSURI.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSURI.java new file mode 100644 index 0000000..2f5fc92 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSURI.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.util; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.kaazing.gateway.client.util.GenericURI; +import org.kaazing.gateway.client.util.URIUtils; + + +/** + * URI with guarantee to be a valid, non-null, ws URI + */ +public class WSURI extends GenericURI { + + @Override + protected boolean isValidScheme(String scheme) { + return "ws".equals(scheme) || "wss".equals(scheme); + } + + public WSURI(String location) throws URISyntaxException { + this(new URI(location)); + } + + public WSURI(URI location) throws URISyntaxException { + super(location); + } + + protected WSURI duplicate(URI uri) { + try { + return new WSURI(uri); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + static WSURI replaceScheme(URI uri, String scheme) throws URISyntaxException { + URI wsUri = URIUtils.replaceScheme(uri, scheme); + return new WSURI(wsUri); + } + + public boolean isSecure() { + String scheme = getScheme(); + return "wss".equals(scheme); + } + + public String getHttpEquivalentScheme() { + return (uri.getScheme().equals("ws") ? "http" : "https"); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WebSocketUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WebSocketUtil.java new file mode 100644 index 0000000..e134724 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WebSocketUtil.java @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.util; + +import java.io.ByteArrayOutputStream; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; + + + +// Oh how i wish we had functional programming, then we could pass a function to +// encodeLength that will be called for each encodedByte and not need two versions +// in java this could be done with an interface that had something like Buffer.write(byte) +// and two anonymous implementations of it that would delegate to the appropriate +// type with the correct method +public class WebSocketUtil { + private static final String CLASS_NAME = WebSocketUtil.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + /* + * Length-bytes are written out in order from most to least significant, but are computed most efficiently (using bit shifts) + * from least to most significant. An integer serves as a temporary storage, which is then written out in reversed order. + */ + public static void encodeLength(ByteArrayOutputStream out, int length) { + LOG.entering(CLASS_NAME, "encodeLength", new Object[] { out, length }); + int byteCount = 0; + long encodedLength = 0; + + do { + // left shift one byte to make room for new data + encodedLength <<= 8; + // set 7 bits of length + encodedLength |= (byte) (length & 0x7f); + // right shift out the 7 bits we just set + length >>= 7; + // increment the byte count that we need to encode + byteCount++; + } + // continue if there are remaining set length bits + while (length > 0); + + do { + // get byte from encoded length + byte encodedByte = (byte) (encodedLength & 0xff); + // right shift encoded length past byte we just got + encodedLength >>= 8; + // The last length byte does not have the highest bit set + if (byteCount != 1) { + // set highest bit if this is not the last + encodedByte |= (byte) 0x80; + } + // write encoded byte + out.write(encodedByte); + } + // decrement and continue if we have more bytes left + while (--byteCount > 0); + } + + public static void encodeLength(WrappedByteBuffer buf, int length) { + LOG.entering(CLASS_NAME, "encodeLength", new Object[] { buf, length }); + int byteCount = 0; + int encodedLength = 0; + + do { + // left shift one byte to make room for new data + encodedLength <<= 8; + // set 7 bits of length + encodedLength |= (byte) (length & 0x7f); + // right shift out the 7 bits we just set + length >>= 7; + // increment the byte count that we need to encode + byteCount++; + } + // continue if there are remaining set length bits + while (length > 0); + + do { + // get byte from encoded length + byte encodedByte = (byte) (encodedLength & 0xff); + // right shift encoded length past byte we just got + encodedLength >>= 8; + // The last length byte does not have the highest bit set + if (byteCount != 1) { + // set highest bit if this is not the last + encodedByte |= (byte) 0x80; + } + // write encoded byte + buf.put(encodedByte); + } + // decrement and continue if we have more bytes left + while (--byteCount > 0); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/CloseCommandMessage.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/CloseCommandMessage.java new file mode 100644 index 0000000..ed60228 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/CloseCommandMessage.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.io.UnsupportedEncodingException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; + +public class CloseCommandMessage implements CommandMessage { + + private static final String CLASS_NAME = CloseCommandMessage.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + public static final int CLOSE_NO_STATUS = 1005; + public static final int CLOSE_ABNORMAL = 1006; + + private int code = 0; + private String reason; + + public CloseCommandMessage(int code, String reason) { + if (code == 0) { + code = CLOSE_NO_STATUS; + } + + this.code = code; + this.reason = reason; + } + + public int getCode() { + return code; + } + + public String getReason() { + return reason; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/ReadyState.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/ReadyState.java new file mode 100644 index 0000000..e0ab32f --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/ReadyState.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +/** + * Values are CONNECTING = 0, OPEN = 1, CLOSING = 2, and CLOSED = 3; + */ +public enum ReadyState { + CONNECTING, OPEN, CLOSING, CLOSED +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeChannel.java new file mode 100644 index 0000000..ad91561 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeChannel.java @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.net.URI; +import java.util.LinkedList; +import java.util.List; + + +//import org.kaazing.gateway.client.html5.WebSocket; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.util.WSCompositeURI; +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.impl.util.ResumableTimer; + +public class WebSocketCompositeChannel extends WebSocketChannel { + public WebSocketSelectedChannel selectedChannel; + + String[] requestedProtocols; + + protected List connectionStrategies = new LinkedList(); + protected ReadyState readyState = ReadyState.CLOSED; + protected boolean closing = false; + + private Object webSocket; + private String compositeScheme; + private ChallengeHandler challengeHandler; // This might be temporary till we move off of legacy stuff. + private ResumableTimer connectTimer; + + public WebSocketCompositeChannel(WSCompositeURI location) { + super(location.getWSEquivalent()); + this.compositeScheme = location.getScheme(); + } + + public ChallengeHandler getChallengeHandler() { + return challengeHandler; + } + + public void setChallengeHandler(ChallengeHandler challengeHandler) { + this.challengeHandler = challengeHandler; + } + + public ReadyState getReadyState() { + return readyState; + } + + public Object getWebSocket() { + return webSocket; + } + + public void setWebSocket(Object webSocket) { + this.webSocket = webSocket; + } + + public String getOrigin() { + URI uri = getLocation().getURI(); + return uri.getScheme()+"://"+uri.getHost()+":"+uri.getPort(); + } + + public URI getURL() { + return getLocation().getURI(); + } + + public String getCompositeScheme() { + return compositeScheme; + } + + public String getNextStrategy() { + if (connectionStrategies.isEmpty()) { + return null; + } + else { + return connectionStrategies.remove(0); + } + } + + public synchronized ResumableTimer getConnectTimer() { + return connectTimer; + } + + public synchronized void setConnectTimer(ResumableTimer connectTimer) { + if (this.connectTimer != null) { + this.connectTimer.cancel(); + this.connectTimer = null; + } + + this.connectTimer = connectTimer; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeHandler.java new file mode 100644 index 0000000..4d31803 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeHandler.java @@ -0,0 +1,432 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.ws.WebSocketSelectedHandlerImpl.WebSocketSelectedHandlerFactory; +import org.kaazing.gateway.client.impl.wseb.WebSocketEmulatedChannel; +import org.kaazing.gateway.client.impl.wseb.WebSocketEmulatedHandler; +import org.kaazing.gateway.client.impl.wsn.WebSocketNativeChannel; +import org.kaazing.gateway.client.impl.wsn.WebSocketNativeHandler; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +/* + * WebSocket Handler Chain + * WebSocket - {CompoisteHandler} + * |- SelctedHandler - NativeHandler (see nativeHandler chain) + * |- SelectedHandler - EmulatedHandler (see emulatedHandler chain) + * Responsibilities: + * a). decide connection strategy + * use native first + * b). handle fallback + * if native failed, fallback to emulated + * + * TODO: + * n/a + */ +public class WebSocketCompositeHandler implements WebSocketHandler { + private static final String CLASS_NAME = WebSocketCompositeHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private WebSocketHandlerListener handlerListener = createListener(); + + static WebSocketSelectedHandlerFactory WEBSOCKET_NATIVE_HANDLER_FACTORY = new WebSocketSelectedHandlerFactory() { + @Override + public WebSocketSelectedHandler createSelectedHandler() { + WebSocketSelectedHandler selectedHandler = new WebSocketSelectedHandlerImpl(); + WebSocketNativeHandler nativeHandler = new WebSocketNativeHandler(); + selectedHandler.setNextHandler(nativeHandler); + return selectedHandler; + } + }; + + static WebSocketSelectedHandlerFactory WEBSOCKET_EMULATED_HANDLER_FACTORY = new WebSocketSelectedHandlerFactory() { + @Override + public WebSocketSelectedHandler createSelectedHandler() { + WebSocketSelectedHandler selectedHandler = new WebSocketSelectedHandlerImpl(); + WebSocketEmulatedHandler emulatedHandler = new WebSocketEmulatedHandler(); + selectedHandler.setNextHandler(emulatedHandler); + return selectedHandler; + } + }; + + static interface WebSocketSelectedChannelFactory { + WebSocketSelectedChannel createChannel(WSURI location); + } + + private final WebSocketSelectedChannelFactory WEBSOCKET_NATIVE_CHANNEL_FACTORY = new WebSocketSelectedChannelFactory() { + @Override + public WebSocketSelectedChannel createChannel(WSURI location) { + return new WebSocketNativeChannel(location); + } + }; + + private final WebSocketSelectedChannelFactory WEBSOCKET_EMULATED_CHANNEL_FACTORY = new WebSocketSelectedChannelFactory() { + @Override + public WebSocketSelectedChannel createChannel(WSURI location) { + return new WebSocketEmulatedChannel(location); + } + }; + + static class WebSocketStrategy { + String nativeEquivalent; // e.g. "ws" + WebSocketHandler handler; + WebSocketSelectedChannelFactory channelFactory; + + WebSocketStrategy(String nativeEquivalent, WebSocketHandler handler, WebSocketSelectedChannelFactory channelFactory) { + this.nativeEquivalent = nativeEquivalent; + this.handler = handler; + this.channelFactory = channelFactory; + } + } + + public static final WebSocketCompositeHandler COMPOSITE_HANDLER = new WebSocketCompositeHandler(); + + final Map strategyChoices = new HashMap(); + final Map strategyMap = new HashMap(); + + private WebSocketHandlerListener listener; + + /** + * Creates a WebSocket that opens up a full-duplex connection to the target location on a supported WebSocket provider + * + * @throws Exception + */ + public WebSocketCompositeHandler() { + LOG.entering(CLASS_NAME, ""); + + WebSocketSelectedHandler nativeHandler = WEBSOCKET_NATIVE_HANDLER_FACTORY.createSelectedHandler(); + nativeHandler.setListener(handlerListener); + + WebSocketSelectedHandler emulatedHandler = WEBSOCKET_EMULATED_HANDLER_FACTORY.createSelectedHandler(); + emulatedHandler.setListener(handlerListener); + + strategyChoices.put("ws", new String[] { "java:ws", "java:wse" }); + strategyChoices.put("wss", new String[] { "java:wss", "java:wse+ssl" }); + strategyChoices.put("wsn", new String[] { "java:ws" }); + strategyChoices.put("wssn", new String[] { "java:wsn" }); + strategyChoices.put("wse", new String[] { "java:wse" }); + strategyChoices.put("wse+ssl", new String[] { "java:wse+ssl" }); + + strategyMap.put("java:ws", new WebSocketStrategy("ws", nativeHandler, WEBSOCKET_NATIVE_CHANNEL_FACTORY)); + strategyMap.put("java:wss", new WebSocketStrategy("wss", nativeHandler, WEBSOCKET_NATIVE_CHANNEL_FACTORY)); + strategyMap.put("java:wse", new WebSocketStrategy("ws", emulatedHandler, WEBSOCKET_EMULATED_CHANNEL_FACTORY)); + strategyMap.put("java:wse+ssl", new WebSocketStrategy("wss", emulatedHandler, WEBSOCKET_EMULATED_CHANNEL_FACTORY)); + strategyMap.put("java:wsn", new WebSocketStrategy("wss", nativeHandler, WEBSOCKET_NATIVE_CHANNEL_FACTORY)); + } + + /** + * Connect the WebSocket object to the remote location + * @param channel WebSocket channel + * @param location location of the WebSocket + * @param protocol protocol spoken over the WebSocket + */ + @Override + public void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + LOG.entering(CLASS_NAME, "connect", channel); + WebSocketCompositeChannel compositeChannel = (WebSocketCompositeChannel)channel; + + if (compositeChannel.readyState != ReadyState.CLOSED) { + LOG.warning("Attempt to reconnect an existing open WebSocket to a different location"); + throw new IllegalStateException("Attempt to reconnect an existing open WebSocket to a different location"); + } + + compositeChannel.readyState = ReadyState.CONNECTING; + compositeChannel.requestedProtocols = protocols; + + String scheme = compositeChannel.getCompositeScheme(); + if (scheme.indexOf(":") >= 0) { + // qualified scheme: e.g. "java:wse" + WebSocketStrategy strategy = strategyMap.get(scheme); + if (strategy == null) { + throw new IllegalArgumentException("Invalid connection scheme: "+scheme); + } + + LOG.finest("Turning off fallback since the URL is prefixed with java:"); + compositeChannel.connectionStrategies.add(scheme); + } + else { + String[] connectionStrategies = strategyChoices.get(scheme); + if (connectionStrategies != null) { + for (String each : connectionStrategies) { + compositeChannel.connectionStrategies.add(each); + } + } + else { + throw new IllegalArgumentException("Invalid connection scheme: "+scheme); + } + } + + fallbackNext(compositeChannel, null); + } + + private void fallbackNext(WebSocketCompositeChannel channel, Exception exception) { + LOG.entering(CLASS_NAME, "fallbackNext"); + try { + String strategyName = channel.getNextStrategy(); + if (strategyName == null) { + if (exception == null) { + doClose(channel, false, 1006, null); + } + else { + doClose(channel, exception); + } + } + else { + initDelegate(channel, strategyName); + } + } catch (Exception e) { + LOG.log(Level.INFO, e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private void initDelegate(WebSocketCompositeChannel channel, String strategyName) { + WebSocketStrategy strategy = strategyMap.get(strategyName); + WebSocketSelectedChannelFactory channelFactory = strategy.channelFactory; + + WSURI location = channel.getLocation(); + + WebSocketSelectedChannel selectedChannel = channelFactory.createChannel(location); + channel.selectedChannel = selectedChannel; + selectedChannel.setParent(channel); + selectedChannel.handler = (WebSocketSelectedHandler)strategy.handler; + selectedChannel.requestedProtocols = channel.requestedProtocols; + + selectedChannel.handler.processConnect(channel.selectedChannel, location, channel.requestedProtocols); + } + + /** + * Writes the message to the WebSocket. + * + * @param message + * String to be written + * @throws Exception + * if contents cannot be written successfully + */ + @Override + public void processTextMessage(WebSocketChannel channel, String message) { + LOG.entering(CLASS_NAME, "send", message); + + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel; + if (parent.readyState != ReadyState.OPEN) { + LOG.warning("Attempt to post message on unopened or closed web socket"); + throw new IllegalStateException("Attempt to post message on unopened or closed web socket"); + } + + WebSocketSelectedChannel selectedChannel = parent.selectedChannel; + selectedChannel.handler.processTextMessage(selectedChannel, message); + } + + /** + * Writes the message to the WebSocket. + * + * @param message + * WrappedByteBuffer to be written + * @throws Exception + * if contents cannot be written successfully + */ + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer message) { + LOG.entering(CLASS_NAME, "send", message); + + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel; + if (parent.readyState != ReadyState.OPEN) { + LOG.warning("Attempt to post message on unopened or closed web socket"); + throw new IllegalStateException("Attempt to post message on unopened or closed web socket"); + } + + WebSocketSelectedChannel selectedChannel = parent.selectedChannel; + selectedChannel.handler.processBinaryMessage(selectedChannel, message); + } + + @Override + public void processAuthorize(WebSocketChannel channel, String authorizeToken) { + // Currently not used + } + + @Override + public void setIdleTimeout(WebSocketChannel channel, int timeout) { + // Currently not used + } + + /** + * Disconnect the WebSocket + * + * @throws Exception + * if the disconnect does not succeed + */ + @Override + public void processClose(WebSocketChannel channel, int code, String reason) { + LOG.entering(CLASS_NAME, "close"); + + //2. check current readyState + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel; + + // When the connection timeout expires due to network loss, we first + // invoke doClose() to inform the application immediately. Then, we + // invoke processClose() to close the connection but it may take a + // while to return. When doClose() is invoked, readyState is set to + // CLOSED. However, we do want processClose() to be invoked all the + // all the way down to close the connection. That's why we are no + // longer throwing an exception here if readyState is CLOSED. + + if (!parent.closing) { + parent.closing = true; + parent.readyState = ReadyState.CLOSING; + + try { + WebSocketSelectedChannel selectedChannel = parent.selectedChannel; + selectedChannel.handler.processClose(selectedChannel, code, reason); + } + catch (Exception e) { + doClose(parent, false, CloseCommandMessage.CLOSE_ABNORMAL, e.getMessage()); + } + } + } + + private WebSocketHandlerListener createListener() { + return new WebSocketHandlerListener() { + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + parent.setProtocol(protocol); + doOpen(parent); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + listener.textMessageReceived(parent, message); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + listener.binaryMessageReceived(parent, buf); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + + // TODO: This is an abstration violation - authenticationReceived should not be exposed on Channel + if ((parent.readyState == ReadyState.CONNECTING) && + !channel.authenticationReceived && + !channel.preventFallback) { + fallbackNext(parent, null); + } + else { + doClose(parent, wasClean, code, reason); + } + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + + // TODO: This is an abstration violation - authenticationReceived should not be exposed on Channel + if ((parent.readyState == ReadyState.CONNECTING) && + !channel.authenticationReceived && + !channel.preventFallback) { + fallbackNext(parent, ex); + } + else { + if (ex == null) { + doClose(parent, false, 1006, null); + } + else { + doClose(parent, ex); + } + } + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + + // TODO: This is an abstration violation - authenticationReceived should not be exposed on Channel + if ((parent.readyState == ReadyState.CONNECTING) && + !channel.authenticationReceived && + !channel.preventFallback) { + fallbackNext(parent, ex); + } + else { + if (ex == null) { + doClose(parent, false, 1006, null); + } + else { + doClose(parent, ex); + } + } + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + // authenticate should not reach here + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + // redirect should not reach here + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + // ignore + } + }; + } + + private void doOpen(WebSocketCompositeChannel channel) { + if (channel.readyState == ReadyState.CONNECTING) { + channel.readyState = ReadyState.OPEN; + listener.connectionOpened(channel, channel.getProtocol()); + } + } + + public void doClose(WebSocketCompositeChannel channel, boolean wasClean, int code, String reason) { + if (channel.readyState == ReadyState.CONNECTING || channel.readyState == ReadyState.CLOSING || channel.readyState == ReadyState.OPEN) { + channel.readyState = ReadyState.CLOSED; + listener.connectionClosed(channel, wasClean, code, reason); + } + } + + public void doClose(WebSocketCompositeChannel channel, Exception ex) { + if (channel.readyState == ReadyState.CONNECTING || channel.readyState == ReadyState.CLOSING || channel.readyState == ReadyState.OPEN) { + channel.readyState = ReadyState.CLOSED; + listener.connectionClosed(channel, ex); + } + } + + public void setListener(WebSocketHandlerListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketHandshakeObject.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketHandshakeObject.java new file mode 100644 index 0000000..0ecd053 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketHandshakeObject.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +public class WebSocketHandshakeObject { + + private String name; + private String escape; + private HandshakeStatus status; + + /** + * @return the name + */ + public String getName() { + return name; + } + + /** + * @param name the name to set + */ + public void setName(String name) { + this.name = name; + } + + /** + * @return the escape + */ + public String getEscape() { + return escape; + } + + /** + * @param escape the escape to set + */ + public void setEscape(String escape) { + this.escape = escape; + } + + /** + * @return the status + */ + public HandshakeStatus getStatus() { + return status; + } + + /** + * @param status the status to set + */ + public void setStatus(HandshakeStatus status) { + this.status = status; + } + + public enum HandshakeStatus { + Pending, + Accepted + } + + /* Kaazing default objects */ + public final static String KAAZING_EXTENDED_HANDSHAKE = "x-kaazing-handshake"; + public static final String KAAZING_SEC_EXTENSION_IDLETIMEOUT = "x-kaazing-idle-timeout"; + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketLoggingHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketLoggingHandler.java new file mode 100644 index 0000000..ea8bb8c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketLoggingHandler.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketLoggingHandler extends WebSocketHandlerAdapter { + + private static final String CLASS_NAME = WebSocketLoggingHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + @Override + public synchronized void processAuthorize(WebSocketChannel channel, String authorizeToken) { + LOG.fine("->AUTHORIZE: "+channel+" "+authorizeToken); + super.processAuthorize(channel, authorizeToken); + } + + @Override + public void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + LOG.fine("->CONNECT: "+channel+" "+location+" "+toString(protocols)); + super.processConnect(channel, location, protocols); + } + + @Override + public synchronized void processClose(WebSocketChannel channel, int code, String reason) { + LOG.fine("->CLOSE: "+channel); + super.processClose(channel, code, reason); + } + + @Override + public void processTextMessage(WebSocketChannel channel, String text) { + LOG.fine("->TEXT: "+channel+" "+text); + super.processTextMessage(channel, text); + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer buffer) { + LOG.fine("->BINARY: "+channel+" "+buffer.getHexDump()); + super.processBinaryMessage(channel, buffer); + } + + @Override + public void setNextHandler(WebSocketHandler nextHandler) { + super.setNextHandler(nextHandler); + + nextHandler.setListener(new WebSocketHandlerListener() { + @Override + public void redirected(WebSocketChannel channel, String location) { + LOG.fine("<-REDIRECTED: "+channel+" "+location); + listener.redirected(channel, location); + } + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + LOG.fine("<-OPENED: "+channel+" "+protocol); + listener.connectionOpened(channel, protocol); + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + LOG.fine("<-FAILED: "+channel); + listener.connectionFailed(channel, ex); + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + LOG.fine("<-CLOSED: "+channel); + listener.connectionClosed(channel, ex); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + LOG.fine("<-CLOSED: "+channel+" "+wasClean+" "+code+": "+reason); + listener.connectionClosed(channel, wasClean, code, reason); + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + LOG.fine("<-COMMAND: "+channel+" "+message); + listener.commandMessageReceived(channel, message); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + LOG.fine("<-TEXT: "+channel+" "+message); + listener.textMessageReceived(channel, message); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + LOG.fine("<-BINARY: "+channel+" "+buf.getHexDump()); + listener.binaryMessageReceived(channel, buf); + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + LOG.fine("<-AUTHENTICATION REQUESTED: "+channel+" "+location+" Challenge:"+challenge); + listener.authenticationRequested(channel, location, challenge); + } + }); + } + + private String toString(String[] protocols) { + if (protocols == null) { + return "-"; + } + else if (protocols.length == 1) { + return protocols[0]; + } + else { + StringBuilder builder = new StringBuilder(100); + for (int i=0; i0) { + builder.append(","); + } + builder.append(protocols[i]); + } + return builder.toString(); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketReAuthenticateHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketReAuthenticateHandler.java new file mode 100644 index 0000000..d6e7558 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketReAuthenticateHandler.java @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.nio.charset.Charset; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.Handler; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequestAuthenticationHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestTransportHandler; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketReAuthenticateHandler implements Handler { + + private static final String CLASS_NAME = WebSocketReAuthenticateHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + static final String HEADER_CONTENT_TYPE = "Content-Type"; + static final String HEADER_COOKIE = "Cookie"; + static final String HEADER_SET_COOKIE = "Set-Cookie"; + static final Charset UTF_8 = Charset.forName("UTF-8"); + HttpRequestHandler nextHandler; + //CreateHandlerListener listener; //no listener for this operation. + + HttpRequestAuthenticationHandler authHandler = new HttpRequestAuthenticationHandler(); + HttpRequestHandler transportHandler = HttpRequestTransportHandler.DEFAULT_FACTORY.createHandler(); + + public WebSocketReAuthenticateHandler() { + setNextHandler(authHandler); + authHandler.setNextHandler(transportHandler); + } + + public void processOpen(Channel channel, HttpURI location) { + LOG.entering(CLASS_NAME, "processOpen", location); + + HttpRequest request = HttpRequest.HTTP_REQUEST_FACTORY.createHttpRequest(Method.GET, location, false); + /* + * create a dummy channel in the middle to match emulated Channel structure + * WebSoecktEmulatedChannel->CreateChannel->HttpRequest + */ + request.parent = new Channel(); + request.parent.setParent(channel); + nextHandler.processOpen(request); + } + + public void setNextHandler(HttpRequestHandler handler) { + this.nextHandler = handler; + + handler.setListener(new HttpRequestListener() { + @Override + public void requestReady(HttpRequest request) { + } + + @Override + public void requestOpened(HttpRequest request) { + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer data) { + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + } + + @Override + public void requestAborted(HttpRequest request) { + } + + @Override + public void requestClosed(HttpRequest request) { + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + } + }); + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedChannel.java new file mode 100644 index 0000000..1b296f3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedChannel.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.util.WSURI; + +public abstract class WebSocketSelectedChannel extends WebSocketChannel { + + WebSocketSelectedHandler handler; + + protected ReadyState readyState = ReadyState.CONNECTING; + + protected String[] requestedProtocols; + +// /** The protocol selected upon the completion of the WebSocket handshake */ +// protected String selectedProtocol; + + public WebSocketSelectedChannel(WSURI location) { + super(location); + } + + public ReadyState getReadyState() { + return readyState; + } + + public String[] getRequestedProtocols() { + return requestedProtocols; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandler.java new file mode 100644 index 0000000..327e9d4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandler.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import org.kaazing.gateway.client.impl.WebSocketHandler; + +public interface WebSocketSelectedHandler extends WebSocketHandler { + + public void setNextHandler(WebSocketHandler nextHandler); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandlerImpl.java new file mode 100644 index 0000000..6081187 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandlerImpl.java @@ -0,0 +1,217 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +/* + * WebSocket Handler Chain + * WebSocket - CompoisteHandler + * |- {SelctedHandler} - NativeHandler (see nativeHandler chain) + * |- {SelectedHandler} - EmulatedHandler (see emulatedHandler chain) + * Responsibilities: + * a). change Channel readyState when events are received + * b). fire events only necessary + * + * TODO: + * n/a + */ +public class WebSocketSelectedHandlerImpl extends WebSocketHandlerAdapter implements WebSocketSelectedHandler { + private static final String CLASS_NAME = WebSocketSelectedHandlerImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + public static interface WebSocketSelectedHandlerFactory { + WebSocketSelectedHandler createSelectedHandler(); + } + + static WebSocketSelectedHandlerFactory FACTORY = new WebSocketSelectedHandlerFactory() { + @Override + public WebSocketSelectedHandlerImpl createSelectedHandler() { + return new WebSocketSelectedHandlerImpl(); + } + }; + + protected WebSocketHandlerListener listener; + + public WebSocketSelectedHandlerImpl() { + LOG.entering(CLASS_NAME, ""); + } + + /** + * Establishes the websocket connection + */ + @Override + public void processConnect(WebSocketChannel channel, WSURI uri, String[] protocols) { + LOG.entering(CLASS_NAME, "connect", channel); + + if (((WebSocketSelectedChannel)channel).readyState == ReadyState.CLOSED) { + throw new IllegalStateException("WebSocket is already closed"); + } + + nextHandler.processConnect(channel, uri, protocols); + } + + @Override + public void processClose(WebSocketChannel channel, int code, String reason) { + LOG.entering(CLASS_NAME, "processDisconnect"); + WebSocketSelectedChannel ch = (WebSocketSelectedChannel)channel; + if (ch.readyState == ReadyState.OPEN || ch.readyState == ReadyState.CONNECTING) { + ch.readyState = ReadyState.CLOSING; + nextHandler.processClose(channel, code, reason); + } + } + + public void handleConnectionOpened(WebSocketChannel channel, String protocol) { + LOG.entering(CLASS_NAME, "handleConnectionOpened"); + + WebSocketSelectedChannel selectedChannel = (WebSocketSelectedChannel)channel; + if (selectedChannel.readyState == ReadyState.CONNECTING) { + selectedChannel.readyState = ReadyState.OPEN; + listener.connectionOpened(channel, protocol); + } + } + + + public void handleBinaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer message) { + LOG.entering(CLASS_NAME, "handleMessageReceived", message); + + if (((WebSocketSelectedChannel)channel).readyState != ReadyState.OPEN) { + return; + } + + if (LOG.isLoggable(Level.FINEST)) { + LOG.log(Level.FINEST, message.getHexDump()); + } + + listener.binaryMessageReceived(channel, message); + } + + public void handleTextMessageReceived(WebSocketChannel channel, String message) { + LOG.entering(CLASS_NAME, "handleTextMessageReceived", message); + + if (((WebSocketSelectedChannel)channel).readyState != ReadyState.OPEN) { + return; + } + + if (LOG.isLoggable(Level.FINEST)) { + LOG.log(Level.FINEST, message); + } + + listener.textMessageReceived(channel, message); + } + + protected void handleConnectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + LOG.entering(CLASS_NAME, "handleConnectionClosed"); + + WebSocketSelectedChannel selectedChannel = (WebSocketSelectedChannel)channel; + if (selectedChannel.readyState != ReadyState.CLOSED) { + selectedChannel.readyState = ReadyState.CLOSED; + listener.connectionClosed(channel, wasClean, code, reason); + } + } + + protected void handleConnectionClosed(WebSocketChannel channel, Exception ex) { + LOG.entering(CLASS_NAME, "handleConnectionClosed"); + + WebSocketSelectedChannel selectedChannel = (WebSocketSelectedChannel)channel; + if (selectedChannel.readyState != ReadyState.CLOSED) { + selectedChannel.readyState = ReadyState.CLOSED; + listener.connectionClosed(channel, ex); + } + } + + protected void handleConnectionFailed(WebSocketChannel channel, Exception ex) { + LOG.entering(CLASS_NAME, "connectionFailed"); + + WebSocketSelectedChannel selectedChannel = (WebSocketSelectedChannel)channel; + if (selectedChannel.readyState != ReadyState.CLOSED) { + selectedChannel.readyState = ReadyState.CLOSED; + listener.connectionFailed(channel, ex); + } + } + + @Override + public void setNextHandler(WebSocketHandler nextHandler) { + this.nextHandler = nextHandler; + + nextHandler.setListener(new WebSocketHandlerListener() { + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + handleConnectionOpened(channel, protocol); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer message) { + handleBinaryMessageReceived(channel, message); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + handleTextMessageReceived(channel, message); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + handleConnectionClosed(channel, wasClean, code, reason); + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + handleConnectionClosed(channel, ex); + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + handleConnectionFailed(channel, ex); + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + } + + }); + } + + public void setListener(WebSocketHandlerListener listener) { + this.listener = listener; + } + +} + diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketTransportHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketTransportHandler.java new file mode 100644 index 0000000..57a2ee8 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketTransportHandler.java @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.ws; + +import java.net.URI; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.bridge.WebSocketNativeBridgeHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.impl.wsn.WebSocketNativeDelegateHandler; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketTransportHandler extends WebSocketHandlerAdapter { + + private static final String CLASS_NAME = WebSocketTransportHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + public WebSocketHandler WEB_SOCKET_NATIVE_HANDLER = null; + public HttpRequestHandler HTTP_REQUEST_HANDLER = null; + + public static boolean useBridge(URI uri) { + LOG.fine("Determine whether bridge needs to be used"); + + try { + SecurityManager securityManager = System.getSecurityManager(); + if (securityManager != null) { + String host = uri.getHost(); + int port = uri.getPort(); + securityManager.checkConnect(host, port); + } + + LOG.fine("Bypassing the bridge: "+uri); + return false; + } catch (Exception e) { + LOG.fine("Must use bridge: "+uri+": "+e.getMessage()); + return true; + } + } + + @Override + public void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + + WebSocketHandler transportHandler = channel.transportHandler; + if (useBridge(location.getURI())) { + transportHandler = new WebSocketNativeBridgeHandler(); + } else { + transportHandler = new WebSocketNativeDelegateHandler(); + } + + channel.transportHandler = transportHandler; + + transportHandler.setListener(new WebSocketHandlerListener() { + @Override + public void redirected(WebSocketChannel channel, String location) { + listener.redirected(channel, location); + } + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + listener.connectionOpened(channel, protocol); + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + listener.connectionFailed(channel, ex); + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + listener.connectionClosed(channel, ex); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + listener.connectionClosed(channel, wasClean, code, reason); + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + listener.commandMessageReceived(channel, message); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + listener.textMessageReceived(channel, message); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + listener.binaryMessageReceived(channel, buf); + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + listener.authenticationRequested(channel, location, challenge); + } + }); + + transportHandler.processConnect(channel, location, protocols); + } + + @Override + public void processAuthorize(WebSocketChannel channel, String authorizeToken) { + WebSocketHandler transportHandler = channel.transportHandler; + transportHandler.processAuthorize(channel, authorizeToken); + } + + @Override + public void processClose(WebSocketChannel channel, int code, String reason) { + WebSocketHandler transportHandler = channel.transportHandler; + transportHandler.processClose(channel, code, reason); + } + + @Override + public void processTextMessage(WebSocketChannel channel, String text) { + WebSocketHandler transportHandler = channel.transportHandler; + transportHandler.processTextMessage(channel, text); + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer buffer) { + WebSocketHandler transportHandler = channel.transportHandler; + transportHandler.processBinaryMessage(channel, buffer); + } + + @Override + public void setIdleTimeout(WebSocketChannel channel, int timeout) { + WebSocketHandler transportHandler = channel.transportHandler; + transportHandler.setIdleTimeout(channel, timeout); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateChannel.java new file mode 100644 index 0000000..84c6be6 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateChannel.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.http.HttpRequest; + +import java.util.HashMap; +import java.util.Map; + +class CreateChannel extends Channel { + + protected String cookie = null; + Map controlFrames; + String[] protocols; + private HttpRequest request; + + public CreateChannel() { + super(0); + controlFrames = new HashMap(); + } + + public void setProtocols(String[] protocols) { + this.protocols = protocols; + } + public String[] getProtocols() { + return protocols; + } + + public HttpRequest getRequest() { + return request; + } + + public void setRequest(HttpRequest request) { + this.request = request; + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandler.java new file mode 100644 index 0000000..73581d7 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandler.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.util.HttpURI; + +interface CreateHandler { + + void setListener(CreateHandlerListener createHandlerListener); + void processOpen(CreateChannel createChannel, HttpURI createUri); + void processClose(CreateChannel crateChannel); + void setNextHandler(HttpRequestHandler nextHandler); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerFactory.java new file mode 100644 index 0000000..001153c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerFactory.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +interface CreateHandlerFactory { + CreateHandler createCreateHandler(); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerImpl.java new file mode 100644 index 0000000..66d446d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerImpl.java @@ -0,0 +1,217 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import static org.kaazing.gateway.client.impl.Channel.HEADER_SEQUENCE; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.impl.http.HttpRequestAuthenticationHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestRedirectHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestTransportHandler; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.ws.WebSocketSelectedChannel; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +/* + * WebSocket Emulated Handler Chain + * EmulateHandler + * |- {CreateHandler} - HttpRequestAuthenticationHandler - HttpRequestRedirectHandler - HttpRequestBridgeHandler + * |- UpstreamHandler - HttpRequestBridgeHandler + * |- DownstreamHandler - HttpRequestBridgeHandler + * Responsibilities: + * a). process Connect + * send httpRequest + * if connected, save ControlFrame bytes to channel + * TODO: + * n/a + */ +class CreateHandlerImpl implements CreateHandler { + private static final String CLASS_NAME = CreateHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + static CreateHandlerFactory FACTORY = new CreateHandlerFactory() { + @Override + public CreateHandler createCreateHandler() { + return new CreateHandlerImpl(); + } + }; + + private static final String HEADER_WEBSOCKET_PROTOCOL = "X-WebSocket-Protocol"; + private static final String HEADER_SEC_EXTENSIONS = "X-WebSocket-Extensions"; + private static final String HEADER_WEBSOCKET_VERSION = "X-WebSocket-Version"; + private static final String HEADER_ACCEPT_COMMANDS = "X-Accept-Commands"; + private static final String WEBSOCKET_VERSION = "wseb-1.0"; + + HttpRequestHandler nextHandler; + CreateHandlerListener listener; + + HttpRequestAuthenticationHandler authHandler = new HttpRequestAuthenticationHandler(); + HttpRequestRedirectHandler redirectHandler = new HttpRequestRedirectHandler(); + HttpRequestHandler transportHandler = HttpRequestTransportHandler.DEFAULT_FACTORY.createHandler(); + + public CreateHandlerImpl() { + setNextHandler(authHandler); + authHandler.setNextHandler(redirectHandler); + redirectHandler.setNextHandler(transportHandler); + } + + @Override + public void processOpen(CreateChannel channel, HttpURI location) { + HttpRequest request = HttpRequest.HTTP_REQUEST_FACTORY.createHttpRequest(Method.GET, location, false); + + // request.getHeaders().put(HEADER_SEC_EXTENSIONS, WebSocketHandshakeObject.KAAZING_SEC_EXTENSION_REVALIDATE); + if (channel.getProtocols() != null && channel.getProtocols().length > 0 ) { + StringBuilder sb = new StringBuilder(); + for (String p : channel.getProtocols()) { + sb.append(p); + sb.append(','); + } + + // strip out comma that gets appended at the end + request.getHeaders().put(HEADER_WEBSOCKET_PROTOCOL, sb.substring(0, sb.length() - 1)); + } + request.getHeaders().put(HEADER_SEC_EXTENSIONS, getEnabledExtensions(channel)); + request.getHeaders().put(HEADER_WEBSOCKET_VERSION, WEBSOCKET_VERSION); + request.getHeaders().put(HEADER_ACCEPT_COMMANDS, "ping"); + request.getHeaders().put(HEADER_SEQUENCE, Long.toString(channel.nextSequence())); + request.parent = channel; + channel.setRequest(request); + nextHandler.processOpen(request); + } + + @Override + public void processClose(CreateChannel channel){ + HttpRequest request = channel.getRequest(); + if (request != null) { + nextHandler.processAbort(request); + } + } + + @Override + public void setNextHandler(HttpRequestHandler handler) { + this.nextHandler = handler; + + handler.setListener(new HttpRequestListener() { + @Override + public void requestReady(HttpRequest request) { + } + + @Override + public void requestOpened(HttpRequest request) { + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer data) { + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + WebSocketEmulatedHandler.LOG.entering(WebSocketEmulatedHandler.CLASS_NAME, "requestLoaded"); + + CreateChannel channel = (CreateChannel)request.parent; + try { + channel.cookie = response.getHeader(WebSocketEmulatedHandler.HEADER_SET_COOKIE); + + //get supported extensions escape bytes + String protocol = response.getHeader(HEADER_WEBSOCKET_PROTOCOL); + ((WebSocketChannel)channel.getParent()).setProtocol(protocol); + + //get supported extensions escape bytes + String extensionsHeader = response.getHeader(HEADER_SEC_EXTENSIONS); + ((WebSocketChannel)channel.getParent()).setNegotiatedExtensions(extensionsHeader); + if (extensionsHeader != null && extensionsHeader.length() > 0) { + String[] extensions = extensionsHeader.split(","); + for (String extension : extensions) { + String[] tmp = extension.split(";"); + if (tmp.length > 1) { + //has escape bytes + String escape = tmp[1].trim(); + if (escape.length() == 8) { + try { + int escapeKey = Integer.parseInt(escape, 16); + channel.controlFrames.put(escapeKey, tmp[0].trim()); + } catch(NumberFormatException e) { + // this is not an escape parameter + LOG.log(Level.FINE, e.getMessage(), e); + } + + } + } + } + } + + WrappedByteBuffer responseBody = response.getBody(); + String urls = responseBody.getString(WebSocketEmulatedHandler.UTF_8); + String[] parts = urls.split("\n"); + + HttpURI upstreamUri = new HttpURI(parts[0]); + HttpURI downstreamUri = new HttpURI((parts.length == 2) ? parts[1] : parts[0]); + listener.createCompleted(channel, upstreamUri, downstreamUri, null); + + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + + listener.createFailed(channel, e); + throw new IllegalStateException("WebSocketEmulation failed", e); + } + } + + @Override + public void requestAborted(HttpRequest request) { + CreateChannel channel = (CreateChannel) request.parent; + channel.setRequest(null); + } + + @Override + public void requestClosed(HttpRequest request) { + CreateChannel channel = (CreateChannel) request.parent; + channel.setRequest(null); + } + + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + CreateChannel createChannel = (CreateChannel)request.parent; + listener.createFailed(createChannel, exception); + } + }); + } + + public void setListener(CreateHandlerListener listener) { + this.listener = listener; + } + + private String getEnabledExtensions(CreateChannel channel) { + WebSocketSelectedChannel selChannel = (WebSocketSelectedChannel) channel.getParent(); + WebSocketCompositeChannel compChannel = (WebSocketCompositeChannel) selChannel.getParent(); + return compChannel.getEnabledExtensions(); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerListener.java new file mode 100644 index 0000000..f19cbb3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerListener.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.util.HttpURI; + +public interface CreateHandlerListener { + + void createCompleted(CreateChannel channel, HttpURI upstreamUri, HttpURI downstreamUri, String protocol); + void createFailed(CreateChannel channel, Exception exception); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamChannel.java new file mode 100644 index 0000000..50972df --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamChannel.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Queue; +import java.util.Set; +import java.util.Timer; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +class DownstreamChannel extends Channel { + HttpURI location; + public String protocol; + + final AtomicBoolean reconnecting = new AtomicBoolean(false); + final AtomicBoolean closing = new AtomicBoolean(false); + final AtomicBoolean attemptProxyModeFallback = new AtomicBoolean(false); + Set outstandingRequests = new HashSet(5); + Queue buffersToRead = new LinkedList(); + int nextMessageAt; + + //--------Idle Timeout-------------// + final AtomicInteger idleTimeout = new AtomicInteger(); + final AtomicLong lastMessageTimestamp = new AtomicLong(); + Timer idleTimer = null; + + /** Cookie required for auth credentials */ + String cookie; + + //KG-6984 move decoder into DownstreamChannel - persist state information for each websocket downstream + WebSocketEmulatedDecoder decoder; + + public DownstreamChannel(HttpURI location, String cookie) { + this(location, cookie, 0); + } + + public DownstreamChannel(HttpURI location, String cookie, long sequence) { + super(sequence); + this.cookie = cookie; + this.location = location; + this.decoder = new WebSocketEmulatedDecoderImpl(); + + attemptProxyModeFallback.set(!location.isSecure()); + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandler.java new file mode 100644 index 0000000..b8979a6 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandler.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.util.HttpURI; + +interface DownstreamHandler { + void processConnect(DownstreamChannel downstreamChannel, HttpURI downstreamUri); + public void processClose(DownstreamChannel channel); + void setListener(DownstreamHandlerListener downstreamHandlerListener); + void setNextHandler(HttpRequestHandler nextHandler); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerFactory.java new file mode 100644 index 0000000..4de03bf --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerFactory.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +interface DownstreamHandlerFactory { + DownstreamHandler createDownstreamHandler(); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerImpl.java new file mode 100644 index 0000000..15a72aa --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerImpl.java @@ -0,0 +1,392 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import static org.kaazing.gateway.client.impl.Channel.HEADER_SEQUENCE; + +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.Timer; +import java.util.TimerTask; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.DecoderInput; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestTransportHandler; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.ws.CloseCommandMessage; +import org.kaazing.gateway.client.impl.ws.WebSocketReAuthenticateHandler; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +/* + * WebSocket Emulated Handler Chain + * EmulateHandler + * |- CreateHandler - HttpRequestAuthenticationHandler - HttpRequestRedirectHandler - HttpRequestBridgeHandler + * |- UpstreamHandler - HttpRequestBridgeHandler + * |- {DownstreamHandler} - HttpRequestBridgeHandler + * Responsibilities: + * a). process receiving messages + */ +class DownstreamHandlerImpl implements DownstreamHandler { + + private static final String CLASS_NAME = DownstreamHandlerImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static String IDLE_TIMEOUT_HEADER = "X-Idle-Timeout"; + + static DownstreamHandlerFactory FACTORY = new DownstreamHandlerFactory() { + @Override + public DownstreamHandler createDownstreamHandler() { + return new DownstreamHandlerImpl(); + } + }; + + private static final int PROXY_MODE_TIMEOUT_MILLIS = 5000; + static boolean DISABLE_FALLBACK = false; + + private HttpRequestHandler nextHandler; + private DownstreamHandlerListener listener; + + DownstreamHandlerImpl() { + LOG.entering(CLASS_NAME, ""); + + HttpRequestHandler transportHandler = HttpRequestTransportHandler.DEFAULT_FACTORY.createHandler(); + setNextHandler(transportHandler); + } + + @Override + public void processConnect(final DownstreamChannel channel, final HttpURI uri) { + LOG.entering(CLASS_NAME, "processConnect"); + makeRequest(channel, uri); + } + + private void makeRequest(final DownstreamChannel channel, final HttpURI uri) { + LOG.entering(CLASS_NAME, "makeRequest"); + + try { + // Cancel idle timer if running + stopIdleTimer(channel); + + HttpURI requestUri = HttpURI.replaceScheme(uri.getURI(), uri.getScheme().replaceAll("ws", "http")); + HttpRequest request = HttpRequest.HTTP_REQUEST_FACTORY.createHttpRequest(Method.POST, requestUri, true); + request.parent = channel; + channel.outstandingRequests.add(request); + + if (channel.cookie != null) { + request.setHeader(WebSocketEmulatedHandler.HEADER_COOKIE, channel.cookie); + } + + // Annotate request with sequence number + request.setHeader(HEADER_SEQUENCE, Long.toString(channel.nextSequence())); + + nextHandler.processOpen(request); + + // Note: attemptProxyModeFallback is only set on the channel for http, not https, + // since attempting detection for HTTPS can also lead to problems if SSL handshake + // takes more than 5 seconds to complete + if (!DISABLE_FALLBACK && channel.attemptProxyModeFallback.get()) { + TimerTask timerTask = new TimerTask() { + + @Override + public void run() { + fallbackToProxyMode(channel); + } + }; + Timer t = new Timer("ProxyModeFallback", true); + t.schedule(timerTask, PROXY_MODE_TIMEOUT_MILLIS); + } + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + listener.downstreamFailed(channel, e); + } + } + + private void fallbackToProxyMode(DownstreamChannel channel) { + if (channel.attemptProxyModeFallback.get()) { + LOG.fine("useProxyMode"); + + channel.attemptProxyModeFallback.set(false); + + HttpURI uri = channel.location; + if (uri.getQuery() == null || !uri.getQuery().contains(".ki=p")) { + uri = channel.location.addQueryParameter(".ki=p"); + channel.location = uri; + } + + makeRequest(channel, uri); + } + } + + private void reconnectIfNecessary(DownstreamChannel channel) { + LOG.entering(CLASS_NAME, "reconnectIfNecessary"); + + if (channel.closing.get() == true) { + if (channel.outstandingRequests.size() == 0) { + LOG.fine("Closing: "+channel); + listener.downstreamClosed(channel); + } + + } + else if (channel.reconnecting.compareAndSet(true, false)) { + // reconnect if necessary + LOG.fine("Reconnecting: "+channel); + makeRequest(channel, channel.location); + } else { + LOG.fine("Downstream failed: "+channel); + listener.downstreamFailed(channel, new Exception("Connection closed abruptly")); + } + } + + //------------------------------Idle Timer Start/Stop/Handler---------------------// + + private void startIdleTimer(final DownstreamChannel downstreamChannel, int delayInMilliseconds) { + LOG.fine("Starting idle timer"); + if (downstreamChannel.idleTimer != null) { + downstreamChannel.idleTimer.cancel(); + downstreamChannel.idleTimer = null; + } + + downstreamChannel.idleTimer = new Timer(); + downstreamChannel.idleTimer.schedule(new TimerTask() { + + @Override + public void run() { + idleTimerHandler(downstreamChannel); + } + + }, delayInMilliseconds); + } + + private void idleTimerHandler(DownstreamChannel downstreamChannel) { + LOG.fine("Idle timer scheduled"); + int idleDuration = (int)(System.currentTimeMillis() - downstreamChannel.lastMessageTimestamp.get()); + if (idleDuration > downstreamChannel.idleTimeout.get()) { + String message = "idle duration - " + idleDuration + " exceeded idle timeout - " + downstreamChannel.idleTimeout; + LOG.fine(message); + Exception exception = new Exception(message); + listener.downstreamFailed(downstreamChannel, exception); + } + else { + // Reschedule timer + startIdleTimer(downstreamChannel, downstreamChannel.idleTimeout.get() - idleDuration); + } + } + + private void stopIdleTimer(DownstreamChannel downstreamChannel) { + LOG.fine("Stopping idle timer"); + if (downstreamChannel.idleTimer != null) { + downstreamChannel.idleTimer.cancel(); + downstreamChannel.idleTimer = null; + } + } + //-------------------------------------------------------------------------------// + + DecoderInput in = new DecoderInput() { + + @Override + public WrappedByteBuffer read(DownstreamChannel channel) { + return channel.buffersToRead.poll(); + } + }; + + //KG-6984 move decoder into DownstreamChannel - persist state information for each websocket downstream + //private WebSocketEmulatedDecoder decoder = new WebSocketEmulatedDecoderImpl(); + + private synchronized void processProgressEvent(DownstreamChannel channel, WrappedByteBuffer buffer) { + LOG.entering(CLASS_NAME, "processProgressEvent", buffer); + try { + + // update timestamp that is used to record the timestamp of last received message + channel.lastMessageTimestamp.set(System.currentTimeMillis()); + channel.buffersToRead.add(buffer); + + WebSocketEmulatedDecoderListener decoderListener = new WebSocketEmulatedDecoderListener() { + + @Override + public void messageDecoded(DownstreamChannel channel, WrappedByteBuffer message) { + processMessage(channel, message); + } + + @Override + public void messageDecoded(DownstreamChannel channel, String message) { + processMessage(channel, message); + } + + @Override + public void commandDecoded(DownstreamChannel channel, WrappedByteBuffer command) { + int commandByte = command.array()[0]; + if (commandByte == 0x30 && command.array()[1] == 0x31) { //reconnect + // KG-5615: Set flag - but do not reconnect until request has loaded + LOG.fine("Reconnect command"); + channel.reconnecting.set(true); + } + else if (commandByte == 0x30 && command.array()[1] == 0x32) { + channel.closing.set(true); + + // Cancel the idle timer if running + stopIdleTimer(channel); + + //close frame received + int code = CloseCommandMessage.CLOSE_NO_STATUS; + String reason = null; + command.skip(2); //skip first 2 bytes 0x30, 0x32 + if (command.hasRemaining()) { + code = command.getShort(); + } + if (command.hasRemaining()) { + reason = command.getString(Charset.forName("UTF-8")); + } + CloseCommandMessage message = new CloseCommandMessage(code, reason); + listener.commandMessageReceived(channel, message); + } + } + + @Override + public void pingReceived(DownstreamChannel channel) { + listener.pingReceived(channel); + } + }; + + // Prevent multiple threads from entering + synchronized (channel.decoder) { + channel.decoder.decode(channel, in, decoderListener); + } + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + e.printStackTrace(); + + listener.downstreamFailed(channel, e); + } + } + + private void processMessage(DownstreamChannel channel, String message) { + listener.textMessageReceived(channel, message); + } + + private void processMessage(DownstreamChannel channel, WrappedByteBuffer message) { + listener.binaryMessageReceived(channel, message); + } + + void handleReAuthenticationRequested(DownstreamChannel channel, String location, String challenge) { + LOG.entering(CLASS_NAME, "handleAuthenticationRequested"); + + //handle revalidate event + String url = channel.location.getScheme() + "://" + channel.location.getURI().getAuthority() + location; + WebSocketReAuthenticateHandler reAuthHandler = new WebSocketReAuthenticateHandler(); + try { + WebSocketEmulatedChannel parent = (WebSocketEmulatedChannel)channel.getParent(); + if (parent.redirectUri != null) { + //this connection has been redirected to cluster member + url = parent.redirectUri.getScheme() + "://" + parent.redirectUri.getURI().getAuthority() + location; + } + WebSocketEmulatedChannel revalidateChannel = new WebSocketEmulatedChannel(parent.getLocation()); + revalidateChannel.redirectUri = parent.redirectUri; + revalidateChannel.setParent(parent.getParent()); + reAuthHandler.processOpen(revalidateChannel, new HttpURI(url)); + } catch (URISyntaxException ex) { + LOG.log(Level.SEVERE, null, ex); + } + return; + } + + @Override + public void processClose(DownstreamChannel channel) { + LOG.entering(CLASS_NAME, "stop"); + for (HttpRequest request : channel.outstandingRequests) { + nextHandler.processAbort(request); + } + } + + @Override + public void setNextHandler(HttpRequestHandler handler) { + this.nextHandler = handler; + + handler.setListener(new HttpRequestListener() { + + @Override + public void requestReady(HttpRequest request) { + nextHandler.processSend(request, WrappedByteBuffer.wrap(">|<".getBytes())); + } + + @Override + public void requestOpened(HttpRequest request) { + HttpResponse response = request.getResponse(); + if (response != null) { + DownstreamChannel channel = (DownstreamChannel) request.parent; + channel.attemptProxyModeFallback.set(false); + String idleTimeoutString = response.getHeader(IDLE_TIMEOUT_HEADER); + if (idleTimeoutString != null) { + int idleTimeout = Integer.parseInt(idleTimeoutString); + if (idleTimeout > 0) { + + // save in milliseconds + idleTimeout = idleTimeout * 1000; + channel.idleTimeout.set(idleTimeout); + channel.lastMessageTimestamp.set(System.currentTimeMillis()); + startIdleTimer(channel, idleTimeout); + } + } + listener.downstreamOpened(channel); + } + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + DownstreamChannel channel = (DownstreamChannel) request.parent; + processProgressEvent(channel, payload); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + LOG.entering(CLASS_NAME, "requestLoaded", request); + DownstreamChannel channel = (DownstreamChannel) request.parent; + channel.outstandingRequests.remove(request); + reconnectIfNecessary(channel); + } + + @Override + public void requestClosed(HttpRequest request) { + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + LOG.entering(CLASS_NAME, "errorOccurred", request); + DownstreamChannel channel = (DownstreamChannel) request.parent; + listener.downstreamFailed(channel, exception); + } + + @Override + public void requestAborted(HttpRequest request) { + LOG.entering(CLASS_NAME, "errorOccurred", request); + } + }); + } + + public void setListener(DownstreamHandlerListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerListener.java new file mode 100644 index 0000000..5f0eb07 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerListener.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public interface DownstreamHandlerListener { + + public void downstreamOpened(DownstreamChannel channel); + public void binaryMessageReceived(DownstreamChannel channel, WrappedByteBuffer data); + public void textMessageReceived(DownstreamChannel channel, String text); + public void commandMessageReceived(DownstreamChannel channel, CommandMessage message); + public void downstreamFailed(DownstreamChannel channel, Exception exception); + public void downstreamClosed(DownstreamChannel channel); + public void pingReceived(DownstreamChannel channel); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamChannel.java new file mode 100644 index 0000000..fcae06e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamChannel.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.kaazing.gateway.client.impl.Channel; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +class UpstreamChannel extends Channel { + HttpURI location; + String cookie; + + ConcurrentLinkedQueue sendQueue = new ConcurrentLinkedQueue(); + AtomicBoolean sendInFlight = new AtomicBoolean(false); + HttpRequest request; + + WebSocketEmulatedChannel parent; + + public UpstreamChannel(HttpURI location, String cookie) { + this(location, cookie, 0); + } + + UpstreamChannel(HttpURI location, String cookie, long sequence) { + super(sequence); + this.location = location; + this.cookie = cookie; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandler.java new file mode 100644 index 0000000..8e142ee --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandler.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +interface UpstreamHandler { + + void setListener(UpstreamHandlerListener upstreamHandlerListener); + void processOpen(UpstreamChannel channel); + void processClose(UpstreamChannel upstreamChannel, int code, String reason); + void processTextMessage(UpstreamChannel upstreamChannel, String message); + void processBinaryMessage(UpstreamChannel upstreamChannel, WrappedByteBuffer message); + void setNextHandler(HttpRequestHandler nextHandler); + void processPong(UpstreamChannel upstreamChannel); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerFactory.java new file mode 100644 index 0000000..536375c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerFactory.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +interface UpstreamHandlerFactory { + UpstreamHandler createUpstreamHandler(); +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerImpl.java new file mode 100644 index 0000000..84e33cd --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerImpl.java @@ -0,0 +1,225 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import static org.kaazing.gateway.client.impl.Channel.HEADER_SEQUENCE; + +import java.nio.charset.Charset; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.EncoderOutput; +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestTransportHandler; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.impl.ws.CloseCommandMessage; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +/* + * WebSocket Emulated Handler Chain + * EmulateHandler + * |- CreateHandler - HttpRequestAuthenticationHandler - HttpRequestRedirectHandler - HttpRequestBridgeHandler + * |- {UpstreamHandler} - HttpRequestBridgeHandler + * |- DownstreamHandler - HttpRequestBridgeHandler + * Responsibilities: + * a). process send messages + * + * TODO: + * n/a + */ +class UpstreamHandlerImpl implements UpstreamHandler { + + static final String CLASS_NAME = UpstreamHandlerImpl.class.getName(); + static final Logger LOG = Logger.getLogger(CLASS_NAME); + + static UpstreamHandlerFactory FACTORY = new UpstreamHandlerFactory() { + @Override + public UpstreamHandler createUpstreamHandler() { + return new UpstreamHandlerImpl(); + } + }; + + // command frame with 02 (close) instruction + private static final byte WSF_COMMAND_FRAME_START = (byte) 0x01; + private static final byte WSF_COMMAND_FRAME_END = (byte) 0xff; + private static final byte WSE_PONG_FRAME_CODE = (byte) 0x8A; + private static final byte[] RECONNECT_EVENT_BYTES = { WSF_COMMAND_FRAME_START, 0x30, 0x31, WSF_COMMAND_FRAME_END }; + private static final byte[] CLOSE_EVENT_BYTES = { WSF_COMMAND_FRAME_START, 0x30, 0x32, WSF_COMMAND_FRAME_END }; + + WebSocketEmulatedEncoder encoder = new WebSocketEmulatedEncoderImpl(); + private EncoderOutput out = new EncoderOutput() { + @Override + public void write(UpstreamChannel channel, WrappedByteBuffer buf) { + processMessageWrite(channel, buf); + } + }; + + HttpRequestHandler nextHandler; + UpstreamHandlerListener listener; + + UpstreamHandlerImpl() { + HttpRequestHandler transportHandler = HttpRequestTransportHandler.DEFAULT_FACTORY.createHandler(); + setNextHandler(transportHandler); + } + + @Override + public void setNextHandler(HttpRequestHandler handler) { + nextHandler = handler; + + nextHandler.setListener(new HttpRequestListener() { + + @Override + public void requestReady(HttpRequest request) { + UpstreamChannel channel = (UpstreamChannel)request.parent; + ConcurrentLinkedQueue sendQueue = channel.sendQueue; + + // build up a bigger payload from all queued up payloads + WrappedByteBuffer payload = WrappedByteBuffer.allocate(1024); + while (!sendQueue.isEmpty()) { + payload.putBuffer(sendQueue.poll()); + } + + // reconnect event bytes *required* to terminate upstream + payload.putBytes(RECONNECT_EVENT_BYTES); + payload.flip(); + + nextHandler.processSend(request, payload); + } + + @Override + public void requestOpened(HttpRequest request) { + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + UpstreamChannel channel = (UpstreamChannel)request.parent; + channel.sendInFlight.set(false); + if (!channel.sendQueue.isEmpty()) { + flushIfNecessary(channel); + } + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + UpstreamChannel channel = (UpstreamChannel)request.parent; + channel.sendInFlight.set(false); + listener.upstreamFailed(channel, exception); + } + + @Override + public void requestAborted(HttpRequest request) { + } + + @Override + public void requestClosed(HttpRequest request) { + } + }); + } + + @Override + public void processOpen(UpstreamChannel channel) { + } + + @Override + public void processTextMessage(UpstreamChannel channel, String message) { + LOG.entering(CLASS_NAME, "processsTextMessage", message); + encoder.encodeTextMessage(channel, message, out); + } + + @Override + public void processBinaryMessage(UpstreamChannel channel, WrappedByteBuffer message) { + LOG.entering(CLASS_NAME, "processsBinaryMessage", message); + encoder.encodeBinaryMessage(channel, message, out); + } + + private void processMessageWrite(UpstreamChannel channel, WrappedByteBuffer payload) { + LOG.entering(CLASS_NAME, "processMessageWrite", payload); + // queue the post request, even if another thread is + // in the middle of sending a request - in which case + // this request will piggy back on it + channel.sendQueue.offer(payload); + + flushIfNecessary(channel); + } + + private void flushIfNecessary(final UpstreamChannel channel) { + LOG.entering(CLASS_NAME, "flushIfNecessary"); + if (channel.sendInFlight.compareAndSet(false, true)) { + final HttpRequest request = HttpRequest.HTTP_REQUEST_FACTORY.createHttpRequest(Method.POST, channel.location, false); + request.setHeader(WebSocketEmulatedHandler.HEADER_CONTENT_TYPE, "application/octet-stream"); + if (channel.cookie != null) { + request.setHeader(WebSocketEmulatedHandler.HEADER_COOKIE, channel.cookie); + } + // Annotate request with sequence number + request.setHeader(HEADER_SEQUENCE, Long.toString(channel.nextSequence())); + request.parent = channel; + channel.request = request; + + nextHandler.processOpen(request); + } + } + + @Override + public void processClose(UpstreamChannel channel, int code, String reason) { + // ### TODO: This is temporary till Gateway is ready for the CLOSE frame. + // Till then, we will just set code to zero and NOT send + // the CLOSE frame. + code = 0; + + // send close event + + if (code == 0 || code == CloseCommandMessage.CLOSE_NO_STATUS) { + processMessageWrite(channel, WrappedByteBuffer.wrap(CLOSE_EVENT_BYTES)); + } + else { + WrappedByteBuffer buf = new WrappedByteBuffer(); + buf.put(CLOSE_EVENT_BYTES, 0, 3); + buf.putShort((short)code); //put code - 2 bytes + buf.putString(reason, Charset.forName("UTF-8")); + buf.put(WSF_COMMAND_FRAME_END); + buf.flip(); + processMessageWrite(channel, buf); + } + } + + @Override + public void setListener(UpstreamHandlerListener listener) { + this.listener = listener; + } + + @Override + public void processPong(UpstreamChannel upstreamChannel) { + + // The wire representation of PONG is - 0x8a 0x00 + WrappedByteBuffer pongBuffer = WrappedByteBuffer.allocate(2); + pongBuffer.put(WSE_PONG_FRAME_CODE); + pongBuffer.put((byte)0x00); + pongBuffer.flip(); + processMessageWrite(upstreamChannel, pongBuffer); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerListener.java new file mode 100644 index 0000000..d1c6ce0 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerListener.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +public interface UpstreamHandlerListener { + + void upstreamFailed(UpstreamChannel channel, Exception exception); + void upstreamCompleted(UpstreamChannel channel); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedChannel.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedChannel.java new file mode 100644 index 0000000..973d0e0 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedChannel.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.ws.CloseCommandMessage; +import org.kaazing.gateway.client.impl.ws.WebSocketSelectedChannel; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.HttpURI; + +public class WebSocketEmulatedChannel extends WebSocketSelectedChannel { + + public HttpURI redirectUri; + CreateChannel createChannel; + UpstreamChannel upstreamChannel; + DownstreamChannel downstreamChannel; + + protected String cookie = null; + + /* close event */ + boolean wasCleanClose = false; + int closeCode = CloseCommandMessage.CLOSE_ABNORMAL; + String closeReason = ""; + + public WebSocketEmulatedChannel(WSURI location) { + super(location); + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoder.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoder.java new file mode 100644 index 0000000..7800717 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoder.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.DecoderInput; + +public interface WebSocketEmulatedDecoder { + + void decode(C channel, DecoderInput in, WebSocketEmulatedDecoderListener listener); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderImpl.java new file mode 100644 index 0000000..dc33b9d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderImpl.java @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import java.nio.charset.Charset; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.DecoderInput; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketEmulatedDecoderImpl implements WebSocketEmulatedDecoder { + + private static final String CLASS_NAME = WebSocketEmulatedDecoderImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final Charset UTF8 = Charset.forName("UTF-8"); + + private static final byte WSF_COMMAND_FRAME_START = (byte) 0x01; + private static final byte WS_TEXT_FRAME_END = (byte) 0xff; + private static final byte WS_BINARY_FRAME_START = (byte) 0x80; + private static final byte WS_SPECIFIEDLENGTH_TEXT_FRAME_START = (byte) 0x81; + private static final byte WSE_PING_FRAME_CODE = (byte) 0x89; + + /* + * Processing state machine + */ + enum DecodingState { + START_OF_FRAME, + READING_TEXT_FRAME, + READING_COMMAND_FRAME, + READING_BINARY_FRAME_HEADER, + READING_BINARY_FRAME, + READING_PING_FRAME + }; + + private DecodingState processingState = DecodingState.START_OF_FRAME; + private WrappedByteBuffer readBuffer = null; + private WrappedByteBuffer messageBuffer = null; + private int binaryFrameLength = 0; + private byte opCode = 0; + + @Override + public void decode(C channel, DecoderInput in, WebSocketEmulatedDecoderListener listener) { + LOG.fine("process() START"); + + while (true) { + if (readBuffer == null) { + readBuffer = in.read(channel); + if (readBuffer == null) { + break; + } + } + + if (!readBuffer.hasRemaining()) { + readBuffer = null; + continue; + } + + if (processingState == DecodingState.START_OF_FRAME) { + // handle alignment with start-of-frame boundary (after mark) + + opCode = readBuffer.get(); + + if (opCode == WSF_COMMAND_FRAME_START) { + processingState = DecodingState.READING_COMMAND_FRAME; + messageBuffer = WrappedByteBuffer.allocate(512); + } + else if (opCode == WSE_PING_FRAME_CODE) { + processingState = DecodingState.READING_PING_FRAME; + } + else if (opCode == WS_BINARY_FRAME_START || opCode == WS_SPECIFIEDLENGTH_TEXT_FRAME_START) { + processingState = DecodingState.READING_BINARY_FRAME_HEADER; + binaryFrameLength = 0; + } + } + else if (processingState == DecodingState.READING_COMMAND_FRAME) { + int endOfFrameAt = readBuffer.indexOf(WS_TEXT_FRAME_END); + if (endOfFrameAt == -1) { + int numBytes = readBuffer.remaining(); + //LOG.finest("process() TEXT_FRAME: partial: "+numBytes); + messageBuffer.putBuffer(readBuffer); + + //KG-6984 putBuffer already move position in readBuffer, no skip required + //readBuffer.skip(numBytes); + } + else { + // complete payload + maybe next payload + int dataLength = endOfFrameAt - readBuffer.position(); + messageBuffer.putBytes(readBuffer.getBytes(dataLength)); // Should advance both buffers + readBuffer.skip(1); //skip endOfFrame byte + + boolean isCommandFrame = (processingState == DecodingState.READING_COMMAND_FRAME); + processingState = DecodingState.START_OF_FRAME; + + // is this a command frame + if (isCommandFrame) { + //LOG.finest("process() COMMAND_FRAME"); + messageBuffer.flip(); + if (messageBuffer.array()[0] == 0x30 && messageBuffer.array()[1] == 0x30) { + //NOOP_COMMAND: + // ignore + } + else { + listener.commandDecoded(channel, messageBuffer.duplicate()); + } + } + // otherwise it is a text frame + else { + // deliver the text frame + messageBuffer.flip(); + + String text = messageBuffer.getString(UTF8); + listener.messageDecoded(channel, text); + } + } + } + else if (processingState == DecodingState.READING_BINARY_FRAME_HEADER) { + while (readBuffer.hasRemaining()) { + byte b = readBuffer.get(); + binaryFrameLength <<= 7; + binaryFrameLength |= (b & 0x7f); + if ((b & 0x80) != 0x80) { + //LOG.finest("process() BINARY_FRAME_HEADER: " + binaryFrameLength); + processingState = DecodingState.READING_BINARY_FRAME; + messageBuffer = WrappedByteBuffer.allocate(binaryFrameLength); + break; + } + } + } + else if (processingState == DecodingState.READING_BINARY_FRAME) { + if (readBuffer.remaining() < binaryFrameLength) { + // incomplete payload + int numbytes = readBuffer.remaining(); + messageBuffer.putBuffer(readBuffer); + binaryFrameLength -= numbytes; + //LOG.finest("process() BINARY_FRAME: partial: " + numbytes); + } + else { + //completed payload + maybe next payload + messageBuffer.putBytes(readBuffer.getBytes(binaryFrameLength)); + processingState = DecodingState.START_OF_FRAME; + + // deliver the binary frame + messageBuffer.flip(); + if (opCode == WS_SPECIFIEDLENGTH_TEXT_FRAME_START) { + String text = messageBuffer.getString(UTF8); + listener.messageDecoded(channel, text); + } else if (opCode == WS_BINARY_FRAME_START){ + listener.messageDecoded(channel, messageBuffer); + } + else { + throw new IllegalArgumentException("Invalid frame opcode. opcode = " + opCode); + } + } + } + else if (processingState == DecodingState.READING_PING_FRAME) { + byte byteFollowingPingFrameCode = readBuffer.get(); + processingState = DecodingState.START_OF_FRAME; + + if (byteFollowingPingFrameCode != 0x00) { + throw new IllegalArgumentException("Expected 0x00 after the PING frame code but received - " + byteFollowingPingFrameCode); + } + + listener.pingReceived(channel); + } + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderListener.java new file mode 100644 index 0000000..6bff0a0 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderListener.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.DecoderListener; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +//TODO: Create a Command abstraction instead of passing WrappedByteBuffer +public interface WebSocketEmulatedDecoderListener extends DecoderListener { + + void commandDecoded(C channel, WrappedByteBuffer command); + void pingReceived(C channel); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoder.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoder.java new file mode 100644 index 0000000..c550cc3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoder.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.EncoderOutput; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public interface WebSocketEmulatedEncoder { + + void encodeTextMessage(C channel, String message, EncoderOutput out); + void encodeBinaryMessage(C channel, WrappedByteBuffer message, EncoderOutput out); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoderImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoderImpl.java new file mode 100644 index 0000000..bac1534 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoderImpl.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; + +import org.kaazing.gateway.client.impl.EncoderOutput; +import org.kaazing.gateway.client.impl.util.WebSocketUtil; +import org.kaazing.gateway.client.util.StringUtils; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketEmulatedEncoderImpl implements WebSocketEmulatedEncoder { + + private static final byte WS_BINARY_FRAME_START = (byte) 0x80; + private static final byte WS_SPECIFIED_LENGTH_TEXT_FRAME_START = (byte) 0x81; + + @Override + public void encodeBinaryMessage(C channel, WrappedByteBuffer message, EncoderOutput out) { + int length = message.remaining(); + + // The largest frame that can be received is 5 bytes (encoded 32 bit length header + trailing byte) + WrappedByteBuffer frame = WrappedByteBuffer.allocate(length + 6); + frame.put(WS_BINARY_FRAME_START); // write binary type header + WebSocketUtil.encodeLength(frame, length); // write length prefix + frame.putBuffer(message.duplicate()); // write payload + frame.flip(); + + out.write(channel, frame); + } + + @Override + public void encodeTextMessage(C channel, String message, EncoderOutput out) { + byte[] payload = StringUtils.getUtf8Bytes(message); + int length = payload.length; + // The largest frame that can be received is 5 bytes (encoded 32 bit length header + trailing byte) + WrappedByteBuffer frame = WrappedByteBuffer.allocate(length + 6); + frame.put(WS_SPECIFIED_LENGTH_TEXT_FRAME_START); // write binary type header + WebSocketUtil.encodeLength(frame, length); // write length prefix + frame.putBytes(payload); // write payload + frame.flip(); + + out.write(channel, frame); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedHandler.java new file mode 100644 index 0000000..c29322d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedHandler.java @@ -0,0 +1,341 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wseb; +/* + * WebSocket Emulated Handler Chain + * {EmulateHandler} + * |- CreateHandler - HttpRequestAuthenticationHandler - HttpRequestRedirectHandler - HttpRequestBridgeHandler + * |- UpstreamHandler - HttpRequestBridgeHandler + * |- DownstreamHandler - HttpRequestBridgeHandler + * Responsibilities: + * a). process Connect + * build handler chain + * start createHandler.processOpen + * on connect, build upstreamHandler and downstreamHandler + * b). process close + * send Close frame via upstream + * fire connectionClosed event + * TODO: + * n/a + */ +import java.nio.charset.Charset; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.ws.CloseCommandMessage; +import org.kaazing.gateway.client.impl.ws.ReadyState; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketEmulatedHandler extends WebSocketHandlerAdapter { + + static final String CLASS_NAME = WebSocketEmulatedHandler.class.getName(); + static final Logger LOG = Logger.getLogger(CLASS_NAME); + + static final String HEADER_CONTENT_TYPE = "Content-Type"; + static final String HEADER_COOKIE = "Cookie"; + static final String HEADER_SET_COOKIE = "Set-Cookie"; + + static final Charset UTF_8 = Charset.forName("UTF-8"); + + static CreateHandlerFactory createHandlerFactory = CreateHandlerImpl.FACTORY; + static DownstreamHandlerFactory downstreamHandlerFactory = DownstreamHandlerImpl.FACTORY; + static UpstreamHandlerFactory upstreamHandlerFactory = UpstreamHandlerImpl.FACTORY; + + private final CreateHandler createHandler = createHandlerFactory.createCreateHandler(); + private final UpstreamHandler upstreamHandler = upstreamHandlerFactory.createUpstreamHandler(); + private final DownstreamHandler downstreamHandler = downstreamHandlerFactory.createDownstreamHandler(); + + public WebSocketEmulatedHandler() { + LOG.entering(CLASS_NAME, ""); + initCreateHandler(createHandler); + initUpstreamHandler(upstreamHandler); + initDownstreamHandler(downstreamHandler); + } + + void initCreateHandler(CreateHandler handler) { + + handler.setListener(new CreateHandlerListener() { + + @Override + public void createCompleted(CreateChannel channel, HttpURI upstreamUri, HttpURI downstreamUri, String protocol) { + LOG.entering(CLASS_NAME, "createCompleted"); + + WebSocketEmulatedChannel parent = (WebSocketEmulatedChannel)channel.getParent(); + parent.createChannel = null; + parent.setProtocol(protocol); + + long nextSequence = channel.nextSequence(); + + UpstreamChannel upstreamChannel = new UpstreamChannel(upstreamUri, channel.cookie, nextSequence); + upstreamChannel.setParent(parent); + parent.upstreamChannel = upstreamChannel; + + DownstreamChannel downstreamChannel = new DownstreamChannel(downstreamUri, channel.cookie, nextSequence); + downstreamChannel.setParent(parent); + parent.downstreamChannel = downstreamChannel; + + parent.cookie = channel.cookie; + + downstreamHandler.processConnect(parent.downstreamChannel, downstreamUri); + listener.connectionOpened(parent, protocol); + } + + @Override + public void createFailed(CreateChannel channel, Exception exception) { + LOG.entering(CLASS_NAME, "createFailed"); + + WebSocketEmulatedChannel parent = (WebSocketEmulatedChannel)channel.getParent(); + listener.connectionFailed(parent, exception); + } + }); + + } + + void initUpstreamHandler(UpstreamHandler handler) { + + handler.setListener(new UpstreamHandlerListener() { + + @Override + public void upstreamCompleted(UpstreamChannel channel) { + } + + @Override + public void upstreamFailed(UpstreamChannel channel, Exception exception) { + if (channel != null && channel.parent != null) { + WebSocketEmulatedChannel parent = channel.parent; + parent.upstreamChannel = null; + doError(parent, exception); + } + else { + throw new IllegalStateException("WebSocket upstream channel already closed"); + } + } + + }); + + } + + void initDownstreamHandler(DownstreamHandler handler) { + + handler.setListener(new DownstreamHandlerListener() { + + @Override + public void downstreamOpened(DownstreamChannel channel) { + + } + + @Override + public void binaryMessageReceived(DownstreamChannel channel, WrappedByteBuffer data) { + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel.getParent(); + listener.binaryMessageReceived(wsebChannel, data); + } + + @Override + public void textMessageReceived(DownstreamChannel channel, String text) { + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel.getParent(); + listener.textMessageReceived(wsebChannel, text); + } + + @Override + public void downstreamFailed(DownstreamChannel channel, Exception exception) { + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel.getParent(); + doError(wsebChannel, exception); + } + + @Override + public void downstreamClosed(DownstreamChannel channel) { + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel.getParent(); + doClose(wsebChannel); + } + + @Override + public void commandMessageReceived(DownstreamChannel channel, CommandMessage message) { + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel.getParent(); + if (message instanceof CloseCommandMessage) { + //close frame received, save code and reason + CloseCommandMessage msg = (CloseCommandMessage) message; + wsebChannel.wasCleanClose = true; + wsebChannel.closeCode = msg.getCode(); + wsebChannel.closeReason = msg.getReason(); + + if (wsebChannel.getReadyState() == ReadyState.OPEN) { + //server initiated close, echo close command message + upstreamHandler.processClose(wsebChannel.upstreamChannel, msg.getCode(), msg.getReason()); + } + + } + listener.commandMessageReceived(wsebChannel, message); + } + + @Override + public void pingReceived(DownstreamChannel channel) { + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel.getParent(); + + // Server sent PING, reponse with PONG via upstream handler + upstreamHandler.processPong(wsebChannel.upstreamChannel); + } + }); + + } + + @Override + public synchronized void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + LOG.entering(CLASS_NAME, "connect", channel); + + String path = location.getPath(); + if (path.endsWith("/")) { + // eliminate duplicate slash when appending create suffix + path = path.substring(0, path.length()-1); + } + + try { + CreateChannel createChannel = new CreateChannel(); + createChannel.setParent(channel); + createChannel.setProtocols(protocols); + HttpURI createUri = HttpURI.replaceScheme(location, location.getHttpEquivalentScheme()) + .replacePath(path + "/;e/cbm"); + createHandler.processOpen(createChannel, createUri); + + } catch (Exception e) { + LOG.log(Level.FINE, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + } + + @Override + public synchronized void processClose(WebSocketChannel channel, int code, String reason) { + LOG.entering(CLASS_NAME, "processDisconnect"); + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel; + + // ### TODO: This is temporary till Gateway sends us the CLOSE frame + // with code and reason while closing emulated downstream + // connection. + wsebChannel.closeCode = code; + wsebChannel.closeReason = reason; + + upstreamHandler.processClose(wsebChannel.upstreamChannel, code, reason); + + } + + @Override + public void processTextMessage(WebSocketChannel channel, String message) { + LOG.entering(CLASS_NAME, "processTextMessage", message); + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel; + upstreamHandler.processTextMessage(wsebChannel.upstreamChannel, message); + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer message) { + LOG.entering(CLASS_NAME, "processBinaryMessage", message); + WebSocketEmulatedChannel wsebChannel = (WebSocketEmulatedChannel)channel; + upstreamHandler.processBinaryMessage(wsebChannel.upstreamChannel, message); + } + + private void doError(WebSocketEmulatedChannel channel, Exception exception) + { + LOG.entering(CLASS_NAME, "Error handler. Tearing down WebSocket connection."); + try + { + if (channel.createChannel != null) + { + createHandler.processClose(channel.createChannel); + } + + if (channel.downstreamChannel != null) + { + downstreamHandler.processClose(channel.downstreamChannel); + } + } + catch (Exception e) + { + LOG.entering(CLASS_NAME, "Exception while tearing down the connection: " + e.getMessage()); + } + + LOG.entering(CLASS_NAME, "Firing Close Event"); + try + { + listener.connectionFailed(channel, exception); + } + catch (Exception e) + { + LOG.entering(CLASS_NAME, "Unhandled exception in Close Event: " + e.getMessage()); + } + } + + private void doClose(WebSocketEmulatedChannel channel) + { + LOG.entering(CLASS_NAME, "Close"); + // TODO: the 'SelectedHandler' was already setting the _readyState on the WebSocketEmulatedChannel to CLOSED + // Commenting out the below IF statement, because it is NEVER true (since the channel state was already updated + + //if (channel._readyState == WebSocket.OPEN || channel._readyState == WebSocket.CONNECTING) + //{ + try + { + // TODO: why did we set the _readyState to CLOSE? + // The channel is passed down to the listener (see WebSocketSelectedHandler) + // and in there (like in Java) the state is set to CLOSE _and_ we continue to + // close the close/clean-up the connection. + + // Setting the state here causes that the _listener.HandleConnectionClosed() + // is doing nothing! + + //channel._readyState = WebSocket.CLOSED; + if (channel.createChannel != null) + { + createHandler.processClose(channel.createChannel); + } + if (channel.downstreamChannel != null) + { + downstreamHandler.processClose(channel.downstreamChannel); + } + } + catch (Exception e) + { + LOG.entering(CLASS_NAME, "While closing: " + e.getMessage()); + } + + LOG.entering(CLASS_NAME, "Firing Close Event"); + + try + { + // ### TODO: Till Gateway supports CLOSE frame, we are going to + // workaround with the following hardcoded values. + channel.wasCleanClose = true; + if (channel.closeCode == 0) { + channel.closeCode = CloseCommandMessage.CLOSE_NO_STATUS; + } + + listener.connectionClosed(channel, channel.wasCleanClose, channel.closeCode, channel.closeReason); + } + catch (Exception e) + { + LOG.entering(CLASS_NAME, "Unhandled exception in Close Event: " + e.getMessage()); + } + //} + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeAuthenticationHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeAuthenticationHandler.java new file mode 100644 index 0000000..4299e49 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeAuthenticationHandler.java @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import java.net.URISyntaxException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.auth.AuthenticationUtil; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.ws.WebSocketHandshakeObject; +import org.kaazing.gateway.client.impl.ws.WebSocketReAuthenticateHandler; +import org.kaazing.gateway.client.impl.wseb.WebSocketEmulatedChannel; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.auth.ChallengeRequest; +import org.kaazing.net.auth.ChallengeResponse; +import org.kaazing.net.impl.util.ResumableTimer; + +/* + * WebSocket Native Handler Chain + * NativeHandler - {AuthenticationHandler} - HandshakeHandler - ControlFrameHandler - BalanceingHandler - Nodec - BridgeHandler + * Responsibilities: + * a). handle authenticationRequested event + */ +public class WebSocketNativeAuthenticationHandler extends WebSocketHandlerAdapter { + + private static final String CLASS_NAME = WebSocketNativeAuthenticationHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private void handleAuthenticationRequested(WebSocketChannel channel, String location, String challenge) { + LOG.entering(CLASS_NAME, "handleAuthenticationRequested"); + + channel.authenticationReceived = true; + + WSURI serverURI; + WebSocketNativeChannel ch = (WebSocketNativeChannel)channel; + ResumableTimer connectTimer = null; + + if (((WebSocketCompositeChannel)channel.getParent()) != null) { + WebSocketCompositeChannel parent = (WebSocketCompositeChannel)channel.getParent(); + connectTimer = parent.getConnectTimer(); + if (connectTimer != null) { + // Pause the connect timer while the user is providing the credentials. + connectTimer.pause(); + } + } + + // get server location + if (ch.redirectUri != null) { + //this connection has been redirected + serverURI = ch.redirectUri; + } + else { + serverURI = channel.getLocation(); + } + + //handle handshake 401 - use original url as ChallengeHandler lookup + ChallengeRequest challengeRequest = new ChallengeRequest(serverURI.toString(), challenge); + try { + channel.challengeResponse = AuthenticationUtil.getChallengeResponse(channel, challengeRequest, channel.challengeResponse); + } catch (Exception e) { + clearAuthenticationCredentials(channel); + doError(channel, e); + //throw new IllegalStateException("Unexpected error processing challenge: "+challengeRequest, e); + return; + } + char[] authResponse = channel.challengeResponse.getCredentials(); + if (authResponse == null) { + doError(channel, new IllegalStateException("No response possible for challenge")); + //throw new IllegalStateException("No response possible for challenge"); + return; + } + + // Resume the connect timer before invoking processAuthorize(). + if (connectTimer != null) { + connectTimer.resume(); + } + + processAuthorize(channel, String.valueOf(authResponse)); + clearAuthenticationCredentials(channel); + } + + private void doError(WebSocketChannel channel, Exception exception) { + LOG.entering(CLASS_NAME, "handleConnectionClosed"); + this.nextHandler.processClose(channel, 1000, null); + listener.connectionClosed(channel, exception); + } + + private void clearAuthenticationCredentials(WebSocketChannel channel) { + ChallengeHandler nextChallengeHandler = null; + if (channel.challengeResponse != null) { + nextChallengeHandler = channel.challengeResponse.getNextChallengeHandler(); + channel.challengeResponse.clearCredentials(); + // prevent leak in case challengeResponse below throws an exception + channel.challengeResponse = null; + } + channel.challengeResponse = new ChallengeResponse(null, nextChallengeHandler); + } + + @Override + public void setNextHandler(WebSocketHandler handler) { + super.setNextHandler(handler); + + handler.setListener(new WebSocketHandlerListener() { + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + clearAuthenticationCredentials(channel); + listener.connectionOpened(channel, protocol); + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + clearAuthenticationCredentials(channel); + listener.redirected(channel, location); + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + handleAuthenticationRequested(channel, location, challenge); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + listener.binaryMessageReceived(channel, buf); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + listener.textMessageReceived(channel, message); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + clearAuthenticationCredentials(channel); + listener.connectionClosed(channel, wasClean, code, reason); + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + listener.connectionClosed(channel, ex); + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + clearAuthenticationCredentials(channel); + listener.connectionFailed(channel, ex); + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + } + }); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeBalancingHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeBalancingHandler.java new file mode 100644 index 0000000..280932f --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeBalancingHandler.java @@ -0,0 +1,307 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.ws.WebSocketHandshakeObject; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.StringUtils; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.http.HttpRedirectPolicy; +/* + * WebSocket Native Handler Chain + * NativeHandler - AuthenticationHandler - HandshakeHandler - ControlFrameHandler - {BalancingHandler} - Codec - BridgeHandler + * Responsibilities: + * a). handle balancer messages + * balancer message is the first message after connection is established + * if message is "\uf0ff" + 'N' - fire connectionOpen event + * if message is "\uf0ff" + 'R' + redirectURl - start reConnect process + * TODO: + * a). server will remove balancer message. instead, server will sent a 'HTTP 301' to redirect client + * client needs to change accordingly + */ +public class WebSocketNativeBalancingHandler extends WebSocketHandlerAdapter { + private static final String CLASS_NAME = WebSocketNativeBalancingHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + private static final Charset UTF8 = Charset.forName("UTF-8"); + + /** + * Connect to the WebSocket + * + * @throws Exception + */ + @Override + public void processConnect(WebSocketChannel channel, WSURI uri, String[] protocols) { + LOG.entering(CLASS_NAME, "connect", new Object[] { uri, protocols }); + WebSocketNativeChannel wsChannel = (WebSocketNativeChannel)channel; + wsChannel.balanced.set(0); + nextHandler.processConnect(channel, uri.addQueryParameter(".kl=Y"), protocols); + } + + /** + * Connect to the WebSocket + * + * @throws Exception + */ + private void reconnect(WebSocketChannel channel, WSURI uri, String protocol) { + LOG.entering(CLASS_NAME, "reconnect", new Object[] { uri, protocol }); + + WebSocketNativeChannel wsChannel = (WebSocketNativeChannel)channel; + wsChannel.redirectUri = uri; + + WebSocketCompositeChannel compChannel = (WebSocketCompositeChannel)channel.getParent(); + HttpRedirectPolicy option = compChannel.getFollowRedirect(); + URI currentURI = channel.getLocation().getURI(); + URI redirectURI = uri.getURI(); + + // option will be null only for unit tests. + if ((option != null) && (option.compare(currentURI, redirectURI) != 0)) { + String s = String.format("%s: Cannot redirect from '%s' to '%s'", + option, currentURI, redirectURI); + channel.preventFallback = true; + throw new IllegalStateException(s); + } + + wsChannel.reconnecting.compareAndSet(false, true); + } + + void handleBinaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer message) { + LOG.entering(CLASS_NAME, "handleMessageReceived", message); + WebSocketNativeChannel wsChannel = (WebSocketNativeChannel)channel; + if (wsChannel.balanced.get() <= 1 && message.remaining() >= 4) { + byte[] prefix = new byte[3]; + message.mark(); + message.get(prefix); + String prefixString; + try { + prefixString = new String(prefix, "UTF-8"); + } + catch (UnsupportedEncodingException e1) { + throw new IllegalStateException(e1); + } + if (prefixString.charAt(0) == '\uf0ff') { + int code = message.get(); + LOG.finest("Balancer code = " + code); + if (code == 'N') { + /* Balancer responded, fire open event */ + if (wsChannel.balanced.getAndIncrement() == 0) { + //first balancer message, fire kaazing handshake + listener.connectionOpened(channel, WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE); + } + else { + //second balancer message, fire open + //TODO: how to pass 'real' protocol to client? + listener.connectionOpened(channel,""); + } + return; + } + else if (code == 'R') { + try { + String reconnectLocation = message.getString(UTF8); + LOG.finest("Balancer redirect location = " + StringUtils.stripControlCharacters(reconnectLocation)); + + WSURI uri = new WSURI(reconnectLocation); + reconnect(channel, uri, channel.getProtocol()); + nextHandler.processClose(channel, 0, null); + return; + } + catch (URISyntaxException e) { + LOG.log(Level.WARNING, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + catch (Exception e) { + LOG.log(Level.WARNING, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + } + } + else { + message.reset(); + listener.binaryMessageReceived(wsChannel, message); + } + } + else { + listener.binaryMessageReceived(channel, message); + } + } + + void handleTextMessageReceived(WebSocketChannel channel, String message) { + LOG.entering(CLASS_NAME, "handleTextMessageReceived", message); + WebSocketNativeChannel wsChannel = (WebSocketNativeChannel)channel; + if (wsChannel.balanced.get() <= 1 && + message.length() >= 2 && + message.charAt(0) == '\uf0ff') { + + int code = message.charAt(1); + LOG.finest("Balancer code = " + code); + if (code == 'N') { + /* Balancer responded, fire open event */ + // NOTE: this will cause OPEN to fire twice on the same channel, but it is currently + // required because the Gateway sends a balancer message both before and after the + // Extended Handshake. + if (wsChannel.balanced.incrementAndGet() == 1) { + listener.connectionOpened(channel, WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE); + } + else { +// listener.connectionOpened(channel, WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE); + listener.connectionOpened(channel, ""); + } + } + else if (code == 'R') { + try { + String reconnectLocation = message.substring(2); + LOG.finest("Balancer redirect location = " + StringUtils.stripControlCharacters(reconnectLocation)); + + WSURI uri = new WSURI(reconnectLocation); + reconnect(channel, uri, channel.getProtocol()); + nextHandler.processClose(channel, 0, null); + } + catch (URISyntaxException e) { + LOG.log(Level.WARNING, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + catch (Exception e) { + LOG.log(Level.WARNING, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + } + else { + listener.textMessageReceived(channel, message); + } + } + else { + listener.textMessageReceived(channel, message); + } + } + + public void setNextHandler(WebSocketHandler handler) { + this.nextHandler = handler; + + handler.setListener(new WebSocketHandlerListener() { + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + /* We have to wait until the balancer responds for kaazing gateway */ + if (!WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE.equals(protocol)) { + //Non-kaazing gateway, fire open event + WebSocketNativeChannel wsChannel = (WebSocketNativeChannel)channel; + wsChannel.balanced.set(2); //turn off balancer message check + listener.connectionOpened(channel, protocol); + } + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + try { + LOG.finest("Balancer redirect location = " + StringUtils.stripControlCharacters(location)); + + WSURI uri = new WSURI(location); + reconnect(channel, uri, channel.getProtocol()); + nextHandler.processClose(channel, 0, null); + } + catch (URISyntaxException e) { + LOG.log(Level.WARNING, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + catch (Exception e) { + LOG.log(Level.WARNING, e.getMessage(), e); + listener.connectionFailed(channel, e); + } + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + listener.authenticationRequested(channel, location, challenge); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + handleBinaryMessageReceived(channel, buf); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + handleTextMessageReceived(channel, message); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + WebSocketNativeChannel wsChannel = (WebSocketNativeChannel)channel; + if (wsChannel.reconnecting.compareAndSet(true, false)) { + //balancer redirect, open a new connection to redirectUri + wsChannel.reconnected.set(true); + + // add kaazing protocol header + String[] nextProtocols; + String[] requestedProtocols = wsChannel.getRequestedProtocols(); + if (requestedProtocols == null || requestedProtocols.length == 0) { + nextProtocols = new String[] { WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE }; + } + else { + nextProtocols = new String[requestedProtocols.length+1]; + nextProtocols[0] = WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE; + for (int i=0; i out; + + public WebSocketNativeCodec() { + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer message) { + encoder.encodeBinaryMessage(channel, message, out); + } + + @Override + public void processTextMessage(WebSocketChannel channel, String message) { + encoder.encodeTextMessage(channel, message, out); + } + + @Override + public void setNextHandler(final WebSocketHandler handler) { + super.setNextHandler(handler); + + out = new EncoderOutput() { + @Override + public void write(WebSocketChannel channel, WrappedByteBuffer buffer) { + handler.processBinaryMessage(channel, buffer); + } + }; + + // TODO: use WebSocketHandlerListenerAdapter + nextHandler.setListener(new WebSocketHandlerListener() { + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + listener.connectionOpened(channel, protocol); + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + listener.redirected(channel, location); + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + listener.authenticationRequested(channel, location, challenge); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + listener.binaryMessageReceived(channel, buf); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + listener.textMessageReceived(channel, message); + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + listener.commandMessageReceived(channel, message); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + listener.connectionClosed(channel, wasClean, code, reason); + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + listener.connectionClosed(channel, ex); + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + listener.connectionFailed(channel, ex); + } + }); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeDelegateHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeDelegateHandler.java new file mode 100644 index 0000000..0b6e8e1 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeDelegateHandler.java @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.transport.AuthenticateEvent; +import org.kaazing.gateway.client.transport.CloseEvent; +import org.kaazing.gateway.client.transport.ErrorEvent; +import org.kaazing.gateway.client.transport.MessageEvent; +import org.kaazing.gateway.client.transport.OpenEvent; +import org.kaazing.gateway.client.transport.RedirectEvent; +import org.kaazing.gateway.client.transport.ws.WebSocketDelegate; +import org.kaazing.gateway.client.transport.ws.WebSocketDelegateImpl; +import org.kaazing.gateway.client.transport.ws.WebSocketDelegateListener; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public class WebSocketNativeDelegateHandler implements WebSocketHandler { + + private static final String CLASS_NAME = WebSocketNativeDelegateHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final Charset UTF8 = Charset.forName("UTF-8"); + + WebSocketHandlerListener listener; + + @Override + public void setIdleTimeout(WebSocketChannel channel, int timeout) { + WebSocketNativeChannel wsnChannel = (WebSocketNativeChannel)channel; + WebSocketDelegate delegate = wsnChannel.getDelegate(); + delegate.setIdleTimeout(timeout); + } + + @Override + public void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + final WebSocketNativeChannel wsnChannel = (WebSocketNativeChannel)channel; + final WebSocketCompositeChannel parentChannel = (WebSocketCompositeChannel) wsnChannel.getParent(); + long connectTimeout = 0; + + if (parentChannel.getConnectTimer() != null) { + connectTimeout = parentChannel.getConnectTimer().getDelay(); + } + + URI origin; + try { + origin = new URI("privileged://" + getCanonicalHostPort(location.getURI())); + + WebSocketDelegate delegate = new WebSocketDelegateImpl(location.getURI(), origin, protocols, connectTimeout); + wsnChannel.setDelegate(delegate); + delegate.setListener(new WebSocketDelegateListener() { + + @Override + public void opened(OpenEvent event) { + LOG.entering(CLASS_NAME, "opened"); + String protocol = event.getProtocol(); + listener.connectionOpened(wsnChannel, protocol); + } + + @Override + public void closed(CloseEvent event) { + LOG.entering(CLASS_NAME, "closed"); + wsnChannel.setDelegate(null); + + Exception ex = event.getException(); + if (ex == null) { + listener.connectionClosed(wsnChannel, event.wasClean(), event.getCode(), event.getReason()); + } + else { + listener.connectionClosed(wsnChannel, ex); + } + } + + @Override + public void redirected(RedirectEvent redirectEvent) { + LOG.entering(CLASS_NAME, "redirected"); + String redirectUrl = redirectEvent.getLocation(); + listener.redirected(wsnChannel, redirectUrl); + } + + @Override + public void authenticationRequested(AuthenticateEvent authenticateEvent) { + LOG.entering(CLASS_NAME, "authenticationRequested"); + String location = wsnChannel.getLocation().toString(); + String challenge = authenticateEvent.getChallenge(); + listener.authenticationRequested(wsnChannel, location, challenge); + } + + @Override + public void messageReceived(MessageEvent messageEvent) { + LOG.entering(CLASS_NAME, "messageReceived"); + WrappedByteBuffer messageBuffer = WrappedByteBuffer.wrap(messageEvent.getData()); + String messageType = messageEvent.getMessageType(); + + if (LOG.isLoggable(Level.FINEST)) { + LOG.log(Level.FINEST, messageBuffer.getHexDump()); + } + + if (messageType == null) { + throw new NullPointerException("Message type is null"); + } + + if ("TEXT".equals(messageType)) { + String text = messageBuffer.getString(UTF8); + listener.textMessageReceived(wsnChannel, text); + } + else { + listener.binaryMessageReceived(wsnChannel, messageBuffer); + } + } + + @Override + public void errorOccurred(ErrorEvent event) { + listener.connectionFailed(wsnChannel, event.getException()); + } + }); + + delegate.processOpen(); + } catch (URISyntaxException e) { + LOG.log(Level.FINE, "During connect processing: "+e.getMessage(), e); + listener.connectionFailed(wsnChannel, e); + } + } + + @Override + public void processClose(WebSocketChannel channel, int code, String reason) { + WebSocketNativeChannel wsnChannel = (WebSocketNativeChannel)channel; + try { + WebSocketDelegate delegate = wsnChannel.getDelegate(); + delegate.processDisconnect((short)code, reason); + } catch (IOException e) { + LOG.log(Level.FINE, "During close processing: "+e.getMessage(), e); + listener.connectionFailed(wsnChannel, e); + } + } + + @Override + public void processAuthorize(WebSocketChannel channel, String authorizeToken) { + WebSocketNativeChannel wsnChannel = (WebSocketNativeChannel)channel; + WebSocketDelegate delegate = wsnChannel.getDelegate(); + delegate.processAuthorize(authorizeToken); + } + + @Override + public void processBinaryMessage(WebSocketChannel channel, WrappedByteBuffer buffer) { + WebSocketNativeChannel wsnChannel = (WebSocketNativeChannel)channel; + WebSocketDelegate delegate = wsnChannel.getDelegate(); + java.nio.ByteBuffer nioBuffer = java.nio.ByteBuffer.wrap(buffer.array(), buffer.arrayOffset() + buffer.position(), buffer.remaining()); + delegate.processSend(nioBuffer); + } + + @Override + public void processTextMessage(WebSocketChannel channel, String text) { + WebSocketNativeChannel wsnChannel = (WebSocketNativeChannel)channel; + WebSocketDelegate delegate = wsnChannel.getDelegate(); + delegate.processSend(text); + // throw new IllegalStateException("Not implemented"); + } + + @Override + public void setListener(WebSocketHandlerListener listener) { + this.listener = listener; + } + + public static String getCanonicalHostPort(URI uri) { + int port = uri.getPort(); + if (port == -1) { + String scheme = uri.getScheme(); + if (scheme.equals("https") || scheme.equals("wss") || scheme.equals("wse+ssl")) { + port = 443; + } + else { + port = 80; + } + } + return uri.getHost()+":"+port; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoder.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoder.java new file mode 100644 index 0000000..144c0aa --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoder.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import org.kaazing.gateway.client.impl.EncoderOutput; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +public interface WebSocketNativeEncoder { + + void encodeTextMessage(WebSocketChannel channel, String message, EncoderOutput out); + void encodeBinaryMessage(WebSocketChannel channel, WrappedByteBuffer message, EncoderOutput out); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoderImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoderImpl.java new file mode 100644 index 0000000..863e7a2 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoderImpl.java @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import java.nio.charset.Charset; +import java.util.Random; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.EncoderOutput; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + + +public class WebSocketNativeEncoderImpl implements WebSocketNativeEncoder { + private static final String CLASS_NAME = WebSocketNativeEncoderImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + private static final Charset UTF8 = Charset.forName("UTF-8"); + + protected WebSocketNativeEncoderImpl() { + } + + @Override + public void encodeTextMessage(WebSocketChannel channel, String message, EncoderOutput out) { + LOG.entering(CLASS_NAME, "processTextMessage", message); + + WrappedByteBuffer buf = new WrappedByteBuffer(); + buf.putString(message, UTF8); + buf.flip(); + + WrappedByteBuffer buffer = encodeRFC6455(buf, false); + + out.write(channel, buffer); + } + + @Override + public void encodeBinaryMessage(WebSocketChannel channel, WrappedByteBuffer message, EncoderOutput out) { + LOG.entering(CLASS_NAME, "processBinaryMessage", message); + + WrappedByteBuffer buffer = encodeRFC6455(message, true); + + out.write(channel, buffer); + } + + private WrappedByteBuffer encodeRFC6455(WrappedByteBuffer buf, boolean isBinary) { + + final boolean mask = true; + int maskValue = new Random().nextInt(); + + boolean fin = true; // TODO continued frames? + + int remaining = buf.remaining(); + + int offset = 2 + (mask ? 4 : 0) + calculateLengthSize(remaining); + + WrappedByteBuffer b = WrappedByteBuffer.allocate(offset + remaining); + + int start = b.position(); + + byte b1 = (byte) (fin ? 0x80 : 0x00); + byte b2 = (byte) (mask ? 0x80 : 0x00); + + b1 = doEncodeOpcode(b1, isBinary); + b2 |= lenBits(remaining); + + b.put(b1).put(b2); + + doEncodeLength(b, remaining); + + if (mask) { + b.putInt(maskValue); + } + //put message data + b.putBuffer(buf); + + if ( mask ) { + b.position(offset); + mask(b, maskValue); + } + + b.limit(b.position()); + b.position(start); + return b; + + } + + private static byte doEncodeOpcode(byte b, boolean isBinary) { + return (byte) (b | (isBinary ? 0x02 : 0x01)); + } + + private static byte lenBits(int length) { + if (length < 126) { + return (byte) length; + } else if (length < 65535) { + return (byte) 126; + } else { + return (byte) 127; + } + } + + private static int calculateLengthSize(int length) { + if (length < 126) { + return 0; + } else if (length < 65535) { + return 2; + } else { + return 8; + } + } + + private static void doEncodeLength(WrappedByteBuffer buf, int length) { + if (length < 126) { + return; + } else if (length < 65535) { + buf.putShort((short) length); + } else { + // Unsigned long (should never have a message that large! really!) + buf.putLong((long) length); + } + } + + /** + * Performs an in-situ masking of the readable buf bytes. + * Preserves the position of the buffer whilst masking all the readable bytes, + * such that the masked bytes will be readable after this invocation. + * + * @param buf the buffer containing readable bytes to be masked. + * @param mask the mask to apply against the readable bytes of buffer. + */ + public static void mask(WrappedByteBuffer buf, int mask) { + // masking is the same as unmasking due to the use of bitwise XOR. + unmask(buf, mask); + } + + + /** + * Performs an in-situ unmasking of the readable buf bytes. + * Preserves the position of the buffer whilst unmasking all the readable bytes, + * such that the unmasked bytes will be readable after this invocation. + * + * @param buf the buffer containing readable bytes to be unmasked. + * @param mask the mask to apply against the readable bytes of buffer. + */ + public static void unmask(WrappedByteBuffer buf, int mask) { + byte b; + int remainder = buf.remaining() % 4; + int remaining = buf.remaining() - remainder; + int end = remaining + buf.position(); + + // xor a 32bit word at a time as long as possible + while (buf.position() < end) { + int plaintext = buf.getIntAt(buf.position()) ^ mask; + buf.putInt(plaintext); + } + + // xor the remaining 3, 2, or 1 bytes + switch (remainder) { + case 3: + b = (byte) (buf.getAt(buf.position()) ^ ((mask >> 24) & 0xff)); + buf.put(b); + b = (byte) (buf.getAt(buf.position()) ^ ((mask >> 16) & 0xff)); + buf.put(b); + b = (byte) (buf.getAt(buf.position()) ^ ((mask >> 8) & 0xff)); + buf.put(b); + break; + case 2: + b = (byte) (buf.getAt(buf.position()) ^ ((mask >> 24) & 0xff)); + buf.put(b); + b = (byte) (buf.getAt(buf.position()) ^ ((mask >> 16) & 0xff)); + buf.put(b); + break; + case 1: + b = (byte) (buf.getAt(buf.position()) ^ (mask >> 24)); + buf.put(b); + break; + case 0: + default: + break; + } + //buf.position(start); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandler.java new file mode 100644 index 0000000..4dbc1ac --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandler.java @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerFactory; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.ws.WebSocketLoggingHandler; +import org.kaazing.gateway.client.impl.ws.WebSocketTransportHandler; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +/* + * WebSocket Native Handler Chain + * {NativeHandler} - AuthenticationHandler - HandshakeHandler - ControlFrameHandler - BalanceingHandler - Nodec - BridgeHandler + * Responsibilities: + * a). build up native handler chain + */ +public class WebSocketNativeHandler extends WebSocketHandlerAdapter { + private static final String CLASS_NAME = WebSocketNativeHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + public static WebSocketHandlerFactory TRANSPORT_HANDLER_FACTORY = new WebSocketHandlerFactory() { + @Override + public WebSocketHandler createWebSocketHandler() { + return new WebSocketTransportHandler(); + } + }; + + private WebSocketNativeAuthenticationHandler authHandler = new WebSocketNativeAuthenticationHandler(); + private WebSocketNativeHandshakeHandler handshakeHandler = new WebSocketNativeHandshakeHandler(); + private WebSocketNativeBalancingHandler balancingHandler = new WebSocketNativeBalancingHandler(); + // private WebSocketNativeCodec codec = new WebSocketNativeCodec(); + + /** + * WebSocket + * @throws Exception + */ + public WebSocketNativeHandler() { + LOG.entering(CLASS_NAME, ""); + + authHandler.setNextHandler(handshakeHandler); + handshakeHandler.setNextHandler(balancingHandler); + + WebSocketHandler transportHandler = TRANSPORT_HANDLER_FACTORY.createWebSocketHandler(); + if (LOG.isLoggable(Level.FINE)) { + WebSocketLoggingHandler loggingHandler = new WebSocketLoggingHandler(); + loggingHandler.setNextHandler(transportHandler); + transportHandler = loggingHandler; + } + + balancingHandler.setNextHandler(transportHandler); + + nextHandler = authHandler; + + // TODO: Use WebSocketHandlerListenerAdapter + nextHandler.setListener(new WebSocketHandlerListener() { + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + listener.connectionOpened(channel, protocol); + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + listener.binaryMessageReceived(channel, buf); + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String message) { + listener.textMessageReceived(channel, message); + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, CommandMessage message) { + listener.commandMessageReceived(channel, message); + } + + @Override + public void connectionClosed(WebSocketChannel channel, boolean wasClean, int code, String reason) { + listener.connectionClosed(channel, wasClean, code, reason); + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + listener.connectionClosed(channel, ex); + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + listener.connectionFailed(channel, ex); + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + } + + @Override + public void authenticationRequested(WebSocketChannel channel, String location, String challenge) { + } + }); + } + + /** + * Connect to the WebSocket + * + * @throws Exception + */ + @Override + public void processConnect(WebSocketChannel channel, WSURI location, String[] protocols) { + LOG.entering(CLASS_NAME, "connect", channel); + + nextHandler.processConnect(channel, location, protocols); + } + + /** + * The number of bytes queued to be sent + */ + public int getBufferedAmount() { + // Payloads are sent immediately in native-protocol mode, and we + // do not have visibility of any buffering at the TCP layer. + return 0; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandshakeHandler.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandshakeHandler.java new file mode 100644 index 0000000..703d2f4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandshakeHandler.java @@ -0,0 +1,388 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.impl.wsn; + +import java.net.URI; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandler; +import org.kaazing.gateway.client.impl.WebSocketHandlerAdapter; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.impl.ws.ReadyState; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.ws.WebSocketHandshakeObject; +import org.kaazing.gateway.client.impl.ws.WebSocketSelectedChannel; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +/* + * WebSocket Native Handler Chain + * NativeHandler - AuthenticationHandler - {HandshakeHandler} - ControlFrameHandler - BalanceingHandler - Nodec - BridgeHandler + * Responsibilities: + * a). handle kaazing handshake + * if response protocol is "x-kaazing-handshake", start handshake process + * otherwise, fire connectionOpened event + * b). process 401 + * if response is enveloped 401 challenge, fire a authenticationRequested event + * TODO: + * a). add more hand shake objects in the future + */ +public class WebSocketNativeHandshakeHandler extends WebSocketHandlerAdapter { + + private static final String CLASS_NAME = WebSocketNativeHandshakeHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final byte[] GET_BYTES = "GET".getBytes(); + private static final byte[] HTTP_1_1_BYTES = "HTTP/1.1".getBytes(); + private static final byte[] COLON_BYTES = ":".getBytes(); + private static final byte[] SPACE_BYTES = " ".getBytes(); + private static final byte[] CRLF_BYTES = "\r\n".getBytes(); + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String HEADER_PROTOCOL = "WebSocket-Protocol"; + private static final String HEADER_SEC_PROTOCOL = "Sec-WebSocket-Protocol"; + private static final String HEADER_SEC_EXTENSIONS = "Sec-WebSocket-Extensions"; + + + public WebSocketNativeHandshakeHandler() { + } + + @Override + public void processConnect(WebSocketChannel channel, WSURI uri, String[] protocols) { + LOG.entering(CLASS_NAME, "connect", new Object[]{uri, protocols}); + // add kaazing protocol header + String[] nextProtocols; + if (protocols == null || protocols.length == 0) { + nextProtocols = new String[] { WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE }; + } + else { + nextProtocols = new String[protocols.length+1]; + nextProtocols[0] = WebSocketHandshakeObject.KAAZING_EXTENDED_HANDSHAKE; + for (int i=0; i lineList = new ArrayList(); + int i=0; + + while (i < payload.length()) { + int endOfLine = payload.indexOf(13, i); + String nextLine; + if (endOfLine >= 0) { + if (payload.charAt(endOfLine+1) != (char)10) { + throw new IllegalArgumentException("Invalid payload"); + } + } + else { + endOfLine = payload.length(); + } + + nextLine = payload.substring(i, endOfLine); + lineList.add(nextLine); + i = endOfLine+2; + } + + String[] lines = new String[lineList.size()]; + lineList.toArray(lines); + return lines; + } + + private void handleHandshakeMessage(WebSocketChannel channel, WrappedByteBuffer buf) { + String s = buf.getString(Charset.forName("UTF-8")); + handleHandshakeMessage(channel, s); + } + + private void handleHandshakeMessage(WebSocketChannel channel, String message) { + + channel.handshakePayload.append(message); + + if (message.length() > 0) { + // Continue reading until an empty message is received. + // wait for more messages + return; + } + + String[] lines = getLines(channel.handshakePayload.toString()); + channel.handshakePayload.setLength(0); + + String httpCode = ""; + //parse the message for embedded http response, should read last one if there are more than one HTTP header + for (int i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith("HTTP/1.1")) { //"HTTP/1.1 101 ..." + String[] temp = lines[i].split(" "); + httpCode = temp[1]; + break; + } + } + + if ("101".equals(httpCode)) { + //handshake completed, websocket Open + + String extensionsHeader = ""; + String negotiatedextensions = ""; + for (String line :lines) { + if (line != null && line.startsWith(HEADER_SEC_PROTOCOL)) { + String protocol = line.substring(HEADER_SEC_PROTOCOL.length() + 1).trim(); + channel.setProtocol(protocol); + } + + if (line != null && line.startsWith(HEADER_SEC_EXTENSIONS)) { + // Get Protocol and extensions - note: extensions may contains multiple entries + // concatenate extensions to one line, separated by "," + extensionsHeader += (extensionsHeader == "" ? "" : ",") + line.substring(HEADER_SEC_EXTENSIONS.length() + 1).trim(); + } + } + + // Parse extensions header + if (extensionsHeader.length() > 0) { + String[] extensions = extensionsHeader.split(","); + for (String extension : extensions) { + String[] tmp = extension.split(";"); + String extName = tmp[0].trim(); + if (extName.equals(WebSocketHandshakeObject.KAAZING_SEC_EXTENSION_IDLETIMEOUT)) { + //idle timeout extension supported by server + try { + //x-kaazing-idle-timeout extension, parameter = "timeout=10000" + int timeout = Integer.parseInt(tmp[1].trim().substring(8)); + if (timeout > 0) { + nextHandler.setIdleTimeout(channel, timeout); + } + } catch (Exception e) { + throw new IllegalArgumentException("Cannot find timeout parameter in x-kaazing-idle-timeout extension: " + extension); + } + // x-kaazing-idle-timeout extension is internal extension, do not add to negotiated extensions + continue; + } + + negotiatedextensions += (negotiatedextensions == "" ? extension : ("," + extension)); + } + if (negotiatedextensions.length() > 0) { + channel.setNegotiatedExtensions(negotiatedextensions); + } + } + } + else if ("401".equals(httpCode)) { + //receive HTTP/1.1 401 from server, pass event to Authentication handler + String challenge = ""; + for (String line : lines) { + if (line.startsWith(HEADER_WWW_AUTHENTICATE)) { + challenge = line.substring(HEADER_WWW_AUTHENTICATE.length()+ 1).trim(); + break; + } + } + listener.authenticationRequested(channel, channel.getLocation().toString(), challenge); + } + else { + // Error during handshake, close connect, report connectionFailed + listener.connectionFailed(channel, + new IllegalStateException("Error during handshake. HTTP Status Code: " + httpCode)); + } + } + + private void sendHandshakePayload(WebSocketChannel channel, String authToken) { + + String[] headerNames = new String[4]; + String[] headerValues = new String[4]; + headerNames[0] = HEADER_PROTOCOL; + headerValues[0] = null; //for now use Sec-Websockect-Protocol header instead + headerNames[1] = HEADER_SEC_PROTOCOL; + headerValues[1] = channel.getProtocol(); //now send the Websockect-Protocol + + //KG-9978 add x-kaazing-idle-timeout extension + headerNames[2] = HEADER_SEC_EXTENSIONS; + headerValues[2] = WebSocketHandshakeObject.KAAZING_SEC_EXTENSION_IDLETIMEOUT; + + String enabledExtensions = ((WebSocketCompositeChannel)channel.getParent()).getEnabledExtensions(); + if ((enabledExtensions != null) && (enabledExtensions.trim().length() != 0)) { + headerValues[2] += "," + enabledExtensions; + } + + headerNames[3] = HEADER_AUTHORIZATION; + headerValues[3] = authToken; //send authorization token + + byte[] payload = encodeGetRequest(channel.getLocation().getURI(), headerNames, headerValues); + nextHandler.processBinaryMessage(channel, new WrappedByteBuffer(payload)); + } + + private byte[] encodeGetRequest(URI requestURI, String[] names, String[] values) { + + // Any changes to this method should result in the getEncodeRequestSize method below + // to get accurate length of the buffer that needs to be allocated. + + LOG.entering(CLASS_NAME, "encodeGetRequest", new Object[]{requestURI, names, values}); + int requestSize = getEncodeRequestSize(requestURI, names, values); + java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(requestSize); + + // Encode Request line + buf.put(GET_BYTES); + buf.put(SPACE_BYTES); + String path = requestURI.getPath(); // + "?.kl=Y&.kv=10.05"; + if (path.length() == 0) { + path = "/"; + } + if (requestURI.getQuery() != null) { + path += "?" + requestURI.getQuery(); + } + buf.put(path.getBytes()); + buf.put(SPACE_BYTES); + buf.put(HTTP_1_1_BYTES); + buf.put(CRLF_BYTES); + + // Encode headers + for (int i = 0; i < names.length; i++) { + String headerName = names[i]; + String headerValue = values[i]; + if (headerName != null && headerValue != null) { + buf.put(headerName.getBytes()); + buf.put(COLON_BYTES); + buf.put(SPACE_BYTES); + buf.put(headerValue.getBytes()); + buf.put(CRLF_BYTES); + } + } + + // Encoding cookies, content length and content not done here as we + // don't have it in the initial GET request. + + buf.put(CRLF_BYTES); + buf.flip(); + return buf.array(); + } + + private int getEncodeRequestSize(URI requestURI, String[] names, String[] values) { + int size = 0; + + // Encode Request line + size += GET_BYTES.length; + size += SPACE_BYTES.length; + String path = requestURI.getPath(); // + "?.kl=Y&.kv=10.05"; + if (path.length() == 0) { + path = "/"; + } + if (requestURI.getQuery() != null) { + path += "?" + requestURI.getQuery(); + } + size += path.getBytes().length; + size += SPACE_BYTES.length; + size += HTTP_1_1_BYTES.length; + size += CRLF_BYTES.length; + + // Encode headers + for (int i = 0; i < names.length; i++) { + String headerName = names[i]; + String headerValue = values[i]; + if (headerName != null && headerValue != null) { + size += headerName.getBytes().length; + size += COLON_BYTES.length; + size += SPACE_BYTES.length; + size += headerValue.getBytes().length; + size += CRLF_BYTES.length; + } + } + + size += CRLF_BYTES.length; + + LOG.fine("Returning a request size of " + size); + return size; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/AuthenticateEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/AuthenticateEvent.java new file mode 100644 index 0000000..987efe4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/AuthenticateEvent.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.util.logging.Logger; + +public class AuthenticateEvent extends Event { + private static final String CLASS_NAME = AuthenticateEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private String challenge; + + /** + * Authenticate Event + * + * @param challenge + */ + public AuthenticateEvent(String challenge) { + super(Event.AUTHENTICATE); + LOG.entering(CLASS_NAME, "", new Object[] { type, challenge }); + this.challenge = challenge; + } + + public String getChallenge() { + LOG.exiting(CLASS_NAME, "getLocation", challenge); + return challenge; + } + + public String toString() { + String ret = "AuthenticateEvent [type=" + type + " challenge=" + challenge + "{"; + for (Object a : params) { + ret += a + " "; + } + return ret + "}]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/BridgeDelegate.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/BridgeDelegate.java new file mode 100644 index 0000000..11ec170 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/BridgeDelegate.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +public interface BridgeDelegate { + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/CloseEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/CloseEvent.java new file mode 100644 index 0000000..31e55ff --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/CloseEvent.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +public class CloseEvent extends Event { + + private int code; + private String reason; + private boolean wasClean; + private Exception exception; + + public CloseEvent(int code, boolean wasClean, String reason) { + super(Event.CLOSED); + this.code = code; + this.wasClean = wasClean; + this.reason = reason; + } + + public CloseEvent(Exception exception) { + super(Event.CLOSED); + this.exception = exception; + } + + public int getCode() { + return code; + } + + public boolean wasClean() { + return wasClean; + } + + public String getReason() { + return reason; + } + + public Exception getException() { + return exception; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ErrorEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ErrorEvent.java new file mode 100644 index 0000000..90fd7cf --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ErrorEvent.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +public class ErrorEvent extends Event { + private Exception exception; + + public ErrorEvent() { + super(Event.ERROR); + } + + public ErrorEvent(Exception exception) { + super(Event.ERROR); + this.exception = exception; + } + + public void setException(Exception exception) { + this.exception = exception; + } + + public Exception getException() { + return exception; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/Event.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/Event.java new file mode 100644 index 0000000..07f2136 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/Event.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +/** + * Class representing the HTML 5 DOM event + */ +public class Event { + /** + * WebSocket and ByteSocket Events + */ + public static final String MESSAGE = "message"; + public static final String OPEN = "open"; + public static final String CLOSED = "closed"; + public static final String REDIRECT = "redirect"; + public static final String AUTHENTICATE = "authenticate"; + + /** + * EventSource Events use MESSAGE, OPEN from the list above + */ + public static final String ERROR = "error"; + + /** + * HttpRequest Events use OPEN and ERROR from the list above + */ + public static final String READY_STATE_CHANGE = "readystatechange"; + public static final String LOAD = "load"; + public static final String ABORT = "abort"; + public static final String PROGRESS = "progress"; + + private static final String[] EMPTY_PARAMS = {}; + Object[] params; + String type; + + public Event(String type) { + this(type, EMPTY_PARAMS); + } + + public Event(String type, Object[] params) { + this.type = type; + this.params = params; + } + + public String getType() { + return type; + } + + public Object[] getParams() { + return params; + } + + public String toString() { + String ret = "Event[type:" + type + "{"; + for (Object a : params) { + ret += a + " "; + } + return ret + "}]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/IoBufferUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/IoBufferUtil.java new file mode 100644 index 0000000..7600187 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/IoBufferUtil.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.nio.ByteBuffer; + +public class IoBufferUtil { + + public static ByteBuffer expandBuffer(ByteBuffer existingBuffer, int additionalRequired) { + int pos = existingBuffer.position(); + if ((pos + additionalRequired) > existingBuffer.limit()) { + if ((pos + additionalRequired) < existingBuffer.capacity()) { + existingBuffer.limit(pos + additionalRequired); + } else { + // reallocate the underlying byte buffer and keep the original buffer + // intact. The resetting of the position is required because, one + // could be in the middle of a read of an existing buffer, when they + // decide to over write only few bytes but still keep the remaining + // part of the buffer unchanged. + int newCapacity = existingBuffer.capacity() + additionalRequired ; + java.nio.ByteBuffer newBuffer = java.nio.ByteBuffer.allocate(newCapacity); + existingBuffer.flip(); + newBuffer.put(existingBuffer); + return newBuffer; + } + } + return existingBuffer; + } + + public static boolean canAccomodate(ByteBuffer existingBuffer, int additionalLength) { + return ((existingBuffer.position() + additionalLength) <= existingBuffer.limit()); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/LoadEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/LoadEvent.java new file mode 100644 index 0000000..be17acf --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/LoadEvent.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.nio.ByteBuffer; +import java.util.logging.Logger; + +public class LoadEvent extends Event { + private static final String CLASS_NAME = LoadEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private ByteBuffer responseBuffer; + + public LoadEvent(ByteBuffer responseBuffer) { + super(Event.LOAD); + LOG.entering(CLASS_NAME, "", new Object[]{responseBuffer}); + this.responseBuffer = responseBuffer; + } + + public ByteBuffer getResponseBuffer() { + return responseBuffer; + } + + public String toString() { + String ret = "LoadEvent [type=" + type + " responseBuffer=" + responseBuffer + "{"; + for(Object a: params) { + ret += a + " "; + } + return ret + "}]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/MessageEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/MessageEvent.java new file mode 100644 index 0000000..7065e44 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/MessageEvent.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.nio.ByteBuffer; +import java.util.logging.Logger; + +public class MessageEvent extends Event { + private static final String CLASS_NAME = MessageEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private ByteBuffer data; + private String origin; + private String lastEventId; + private String messageType; // "TEXT" or "BINARY" + + /** + * Message Event + * + * @param data + * @param origin + * @param lastEventId + * @param type + */ + public MessageEvent(ByteBuffer data, String origin, String lastEventId, String messageType) { + super(Event.MESSAGE); + LOG.entering(CLASS_NAME, "", new Object[]{type, data, origin, lastEventId}); + this.data = data; + this.origin = origin; + this.lastEventId = lastEventId; + this.messageType = messageType; + } + + public ByteBuffer getData() { + LOG.exiting(CLASS_NAME, "getData", data); + return data; + } + + public String getOrigin() { + LOG.exiting(CLASS_NAME, "getOrigin", origin); + return origin; + } + + public String getLastEventId() { + LOG.exiting(CLASS_NAME, "getLastEventId", lastEventId); + return lastEventId; + } + + /** + * Return "TEXT" or "BINARY" depending on the message type + * @return + */ + public String getMessageType() { + LOG.exiting(CLASS_NAME, "getMessageType", messageType); + return messageType; + } + + public String toString() { + String ret = "MessageEvent [type=" + type + " messageType="+messageType+" data=" + data + " origin " + origin + " lastEventId=" + lastEventId + "{"; + for (Object a : params) { + ret += a + " "; + } + return ret + "}]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/OpenEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/OpenEvent.java new file mode 100644 index 0000000..8e321eb --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/OpenEvent.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.util.logging.Logger; + +public class OpenEvent extends Event { + private static final String CLASS_NAME = OpenEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private String protocol; + + public OpenEvent() { + super(Event.OPEN); + LOG.entering(CLASS_NAME, ""); + } + + public OpenEvent(String protocol) { + super(Event.OPEN); + this.protocol = protocol; + LOG.entering(CLASS_NAME, ""); + } + + public String toString() { + String ret = "OpenEvent [type=" + type + " + {"; + for(Object a: params) { + ret += a + " "; + } + return ret + "}]"; + } + + /** + * @return the protocol, if more than one protocols are defined, separated by comma + */ + public String getProtocol() { + return protocol; + } + + /** + * @param protocol the protocol to set + */ + public void setProtocol(String protocol) { + this.protocol = protocol; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ProgressEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ProgressEvent.java new file mode 100644 index 0000000..4cb12ff --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ProgressEvent.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.nio.ByteBuffer; +import java.util.logging.Logger; + + +public class ProgressEvent extends Event { + private static final String CLASS_NAME = ProgressEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private int bytesTotal; + private int bytesLoaded; + private ByteBuffer payload; + + public ProgressEvent(ByteBuffer payload, int bytesLoaded, int bytesTotal) { + super("progress"); + LOG.entering(CLASS_NAME, "", new Object[]{payload, bytesLoaded, bytesTotal}); + this.payload = payload; + this.bytesLoaded = bytesLoaded; + this.bytesTotal = bytesTotal; + } + + public int getBytesTotal() { + LOG.exiting(CLASS_NAME, "getBytesTotal", bytesTotal); + return bytesTotal; + } + + public int getBytesLoaded() { + LOG.exiting(CLASS_NAME, "getBytesLoaded", bytesLoaded); + return bytesLoaded; + } + + public ByteBuffer getPayload() { + LOG.exiting(CLASS_NAME, "getPayload", payload); + return payload; + } + + public String toString() { + String ret = "ProgressEvent [type=" + type + " payload=" + payload + "{"; + for(Object a: params) { + ret += a + " "; + } + return ret + "}]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ReadyStateChangedEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ReadyStateChangedEvent.java new file mode 100644 index 0000000..195a898 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ReadyStateChangedEvent.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +public class ReadyStateChangedEvent extends Event { + + public ReadyStateChangedEvent(String[] params) { + super(Event.READY_STATE_CHANGE, params); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/RedirectEvent.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/RedirectEvent.java new file mode 100644 index 0000000..8e9c18b --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/RedirectEvent.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport; + +import java.util.logging.Logger; + +public class RedirectEvent extends Event { + private static final String CLASS_NAME = RedirectEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private String location; + + /** + * Redirect Event + * + * @param location + */ + public RedirectEvent(String location) { + super(Event.REDIRECT); + LOG.entering(CLASS_NAME, "", new Object[]{type, location}); + this.location = location; + } + + public String getLocation() { + LOG.exiting(CLASS_NAME, "getLocation", location); + return location; + } + + public String toString() { + String ret = "RedirectEvent [type=" + type + " location=" + location + "{"; + for (Object a : params) { + ret += a + " "; + } + return ret + "}]"; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegate.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegate.java new file mode 100644 index 0000000..f335c3c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegate.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.http; + +import java.net.URL; +import java.nio.ByteBuffer; + +import org.kaazing.gateway.client.transport.BridgeDelegate; + +public interface HttpRequestDelegate extends BridgeDelegate { + + void setRequestHeader(String header, String value); + + int getStatusCode(); + String getResponseHeader(String headerLocation); + ByteBuffer getResponseText(); + + void processOpen(String method, URL url, String origin, boolean async, long connectTimeout) throws Exception; + void processSend(ByteBuffer content); + void processAbort(); + + void setListener(HttpRequestDelegateListener listener); + +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateFactory.java new file mode 100644 index 0000000..e0adcfc --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateFactory.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.http; + +public interface HttpRequestDelegateFactory { + + HttpRequestDelegate createHttpRequestDelegate(); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateImpl.java new file mode 100644 index 0000000..b09d9a6 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateImpl.java @@ -0,0 +1,350 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + *

    + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.http; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.transport.CloseEvent; +import org.kaazing.gateway.client.transport.ErrorEvent; +import org.kaazing.gateway.client.transport.IoBufferUtil; +import org.kaazing.gateway.client.transport.LoadEvent; +import org.kaazing.gateway.client.transport.OpenEvent; +import org.kaazing.gateway.client.transport.ProgressEvent; +import org.kaazing.gateway.client.transport.ReadyStateChangedEvent; + +public class HttpRequestDelegateImpl implements HttpRequestDelegate { + private static final String CLASS_NAME = HttpRequestDelegateImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + /* + * XmlHTTPReqeuest states const unsigned short UNSENT = 0; const unsigned short OPENED = 1; const unsigned short HEADERS_RECEIVED = 2; const unsigned short + * LOADING = 3; const unsigned short DONE = 4; + */ + private enum State { + UNSENT, OPENED, HEADERS_RECEIVED, LOADING, DONE + } + + private State readyState = State.UNSENT; + + private ByteBuffer responseBuffer = ByteBuffer.allocate(5000); + private ByteBuffer completedResponseBuffer; + private HttpURLConnection connection = null; + private HttpRequestDelegateListener listener; + + private int httpResponseCode; + + private StreamReader reader; + + private boolean async; + + public HttpRequestDelegateImpl() { + LOG.entering(CLASS_NAME, ""); + } + + public final State getReadyState() { + LOG.exiting(CLASS_NAME, "getReadyState", readyState); + return readyState; + } + + public ByteBuffer getResponseText() { + LOG.entering(CLASS_NAME, "getResponseText"); + switch (readyState) { + case LOADING: + case OPENED: + return responseBuffer.duplicate(); + case DONE: + return completedResponseBuffer; + } + return null; + } + + public int getStatusCode() { + LOG.exiting(CLASS_NAME, "getStatusCode", httpResponseCode); + return httpResponseCode; + } + + /* (non-Javadoc) + * @see org.kaazing.gateway.client.transport.http.HttpRequestDelegate#abort() + */ + public void processAbort() { + LOG.entering(CLASS_NAME, "abort"); + if (reader != null) { + reader.stop(); + reader = null; + } + } + + public String getAllResponseHeaders() { + LOG.entering(CLASS_NAME, "getAllResponseHeaders"); + if (readyState == State.LOADING || readyState == State.DONE) { + // return all headers from the HttpUrlConnection; + String headerText = connection.getHeaderFields().toString(); + LOG.exiting(CLASS_NAME, "getAllResponseHeaders", headerText); + return headerText; + } else { + LOG.exiting(CLASS_NAME, "getAllResponseHeaders"); + return null; + } + } + + public String getResponseHeader(String header) { + LOG.entering(CLASS_NAME, "getResponseHeader", header); + if (readyState == State.LOADING || readyState == State.DONE || readyState == State.HEADERS_RECEIVED) { + String headerText = connection.getHeaderField(header); + LOG.exiting(CLASS_NAME, "getResponseHeader", headerText); + return headerText; + } else { + LOG.exiting(CLASS_NAME, "getResponseHeader"); + return null; + } + } + + /* (non-Javadoc) + * @see org.kaazing.gateway.client.transport.http.HttpRequestDelegate#open(java.lang.String, java.net.URL, java.lang.String, boolean) + */ + public void processOpen(String method, URL url, String origin, boolean async, long connectTimeout) throws Exception { + LOG.entering(CLASS_NAME, "processOpen", new Object[]{method, url, origin, async}); + + this.async = async; + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod(method); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout((int) connectTimeout); + + if (!origin.equalsIgnoreCase("null") && !origin.startsWith("privileged")) { + URL originUrl = new URL(origin); + origin = originUrl.getProtocol() + "://" + originUrl.getAuthority(); + } + + // connection.addRequestProperty("X-Origin", origin); + connection.addRequestProperty("Origin", origin); + setReadyState(State.OPENED); + + listener.opened(new OpenEvent()); + } + + /* (non-Javadoc) + * @see org.kaazing.gateway.client.transport.http.HttpRequestDelegate#send(java.nio.ByteBuffer) + */ + public void processSend(ByteBuffer content) { + LOG.entering(CLASS_NAME, "processSend", content); + if (readyState != State.OPENED && readyState != State.HEADERS_RECEIVED) { + throw new IllegalStateException(readyState + " HttpRequest must be in an OPEN state before " + "invocation of the send() method"); + } + try { + if (!async && content != null && content.hasRemaining()) { + connection.setDoOutput(true); + connection.setDoInput(true); + + OutputStream out = connection.getOutputStream(); + out.write(content.array(), content.arrayOffset(), content.remaining()); + out.flush(); + } + + connection.connect(); + reader = new StreamReader(); + Thread t = new Thread(reader, "HttpRequestDelegate stream reader"); + t.setDaemon(true); + t.start(); + } catch (Exception e) { + LOG.log(Level.FINE, "While processing http request", e); + // e.printStackTrace(); + listener.errorOccurred(new ErrorEvent(e)); + } + } + + /* (non-Javadoc) + * @see org.kaazing.gateway.bridge.HttpRequestDelegate#setRequestHeader(java.lang.String, java.lang.String) + */ + public void setRequestHeader(String header, String value) { + LOG.entering(CLASS_NAME, "setRequestHeader", new Object[]{header, value}); + HttpRequestUtil.validateHeader(header); + connection.addRequestProperty(header, value); + } + + protected void reset() { + LOG.entering(CLASS_NAME, "reset"); + responseBuffer = null; + completedResponseBuffer = null; + setStatus(-1); + setReadyState(State.UNSENT); + } + + private void setReadyState(State state) { + LOG.entering(CLASS_NAME, "setReadyState", state); + this.readyState = state; + } + + private void setStatus(int status) { + LOG.entering(CLASS_NAME, "setStatus", status); + this.httpResponseCode = status; + } + + private final class StreamReader implements Runnable { + private final String CLASS_NAME = StreamReader.class.getName(); + + private AtomicBoolean stopped = new AtomicBoolean(false); + private AtomicBoolean requestCompleted = new AtomicBoolean(false); + + public void run() { + try { + run2(); + } catch (Exception e) { + LOG.log(Level.INFO, e.getMessage(), e); + } + } + + void run2() { + LOG.entering(CLASS_NAME, "run"); + InputStream in; + try { + httpResponseCode = connection.getResponseCode(); + + // For streaming responses Java returns -1 as the response code + if (httpResponseCode != -1 && (httpResponseCode < 200 || httpResponseCode == 400 || httpResponseCode == 402 || httpResponseCode == 403 || httpResponseCode == 404)) { + Exception ex = new Exception("Unexpected HTTP response code received: code = " + httpResponseCode); + listener.errorOccurred(new ErrorEvent(ex)); + throw ex; + } + Map> headers = connection.getHeaderFields(); + StringBuilder allHeadersBuffer = new StringBuilder(); + int numHeaders = headers.size(); + for (int i = 0; i < numHeaders; i++) { + allHeadersBuffer.append(connection.getHeaderFieldKey(i)); + allHeadersBuffer.append(":"); + allHeadersBuffer.append(connection.getHeaderField(i)); + allHeadersBuffer.append("\n"); + } + String allHeaders = allHeadersBuffer.toString(); + String[] params = new String[]{Integer.toString(State.HEADERS_RECEIVED.ordinal()), Integer.toString(httpResponseCode), connection.getResponseMessage() + "", + allHeaders}; + + setReadyState(State.HEADERS_RECEIVED); + listener.readyStateChanged(new ReadyStateChangedEvent(params)); + + if (httpResponseCode == 401) { + listener.loaded(new LoadEvent(ByteBuffer.allocate(0))); + requestCompleted.compareAndSet(false, true); + connection.disconnect(); + return; + } + in = connection.getInputStream(); + + } catch (IOException e) { + LOG.severe(e.toString()); + listener.errorOccurred(new ErrorEvent(e)); + return; + } catch (Exception ex) { + LOG.log(Level.FINE, ex.getMessage(), ex); + listener.errorOccurred(new ErrorEvent(ex)); + return; + } + + try { + if (in == null) { + in = connection.getInputStream(); + if (in == null) { + String s = "Input stream not ready"; + Exception ex = new RuntimeException(s); + listener.errorOccurred(new ErrorEvent(ex)); + LOG.severe(s); + throw ex; + } + } + + + byte[] payloadBuffer = new byte[4096]; // read in chunks + while (!stopped.get()) { + int numberOfBytesRead = in.read(payloadBuffer, 0, payloadBuffer.length); + if (numberOfBytesRead == -1) { + // end of stream, break from loop + break; + } + ByteBuffer payload = ByteBuffer.wrap(payloadBuffer, 0, numberOfBytesRead); + if (!async) { + // build up the buffer for completed response only for + // synchronous requests + // expand the buffer if required + int payloadSize = payload.remaining(); + if (!IoBufferUtil.canAccomodate(responseBuffer, payloadSize)) { + responseBuffer = IoBufferUtil.expandBuffer(responseBuffer, payloadSize); + } + responseBuffer.put(payload); + // the put above resets the position of payload, flip it so we can reuse it + payload.flip(); + } + listener.progressed(new ProgressEvent(payload, 0, 0)); + } + + if (!stopped.get()) { + // We want to fire the load event for complete responses + // only that are + // regular HTTP requests. For streaming request, we expect + // the caller + // to call abort + responseBuffer.flip(); + completedResponseBuffer = responseBuffer.duplicate(); + setReadyState(State.DONE); + + try { + listener.loaded(new LoadEvent(completedResponseBuffer)); + } finally { + requestCompleted.compareAndSet(false, true); + try { + connection.disconnect(); + } finally { + listener.closed(new CloseEvent(1000, true, "")); + } + } + } + } catch (IOException e) { + LOG.severe(e.toString()); + if (!requestCompleted.get()) { + listener.errorOccurred(new ErrorEvent(e)); + } + } catch (Exception ex) { + LOG.log(Level.FINE, ex.getMessage(), ex); + listener.errorOccurred(new ErrorEvent(ex)); + } + } + + public void stop() { + LOG.entering(CLASS_NAME, "stop"); + this.stopped.set(true); + } + } + + @Override + public void setListener(HttpRequestDelegateListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateListener.java new file mode 100644 index 0000000..850b6be --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateListener.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.http; + +import org.kaazing.gateway.client.transport.CloseEvent; +import org.kaazing.gateway.client.transport.ErrorEvent; +import org.kaazing.gateway.client.transport.LoadEvent; +import org.kaazing.gateway.client.transport.OpenEvent; +import org.kaazing.gateway.client.transport.ProgressEvent; +import org.kaazing.gateway.client.transport.ReadyStateChangedEvent; + +public interface HttpRequestDelegateListener { + + void opened(OpenEvent event); + void errorOccurred(ErrorEvent event); + void readyStateChanged(ReadyStateChangedEvent event); + void loaded(LoadEvent event); + void progressed(ProgressEvent progressEvent); + void closed(CloseEvent event); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestUtil.java new file mode 100644 index 0000000..f18af3e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestUtil.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.http; + +import java.util.logging.Logger; + +public class HttpRequestUtil { + private static final String CLASS_NAME = HttpRequestUtil.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + public static void validateHeader(String header) { + LOG.entering(CLASS_NAME, "validateHeader", header); + /* + * From the XMLHttpRequest spec: + * http://www.w3.org/TR/XMLHttpRequest/#setrequestheader + * + * For security reasons, these steps should be terminated if the header + * argument case-insensitively matches one of the following headers: + * + * Accept-Charset Accept-Encoding Connection Content-Length + * Content-Transfer-Encoding Date Expect Host Keep-Alive Referer TE + * Trailer Transfer-Encoding Upgrade Via Proxy-* Sec-* + * + * Also for security reasons, these steps should be terminated if the + * start of the header argument case-insensitively matches Proxy- or Se + */ + if (header == null || (header.length() == 0)) { + LOG.severe("Invalid header in the HTTP request"); + throw new IllegalArgumentException("Invalid header in the HTTP request"); + } + String lowerCaseHeader = header.toLowerCase(); + if (lowerCaseHeader.startsWith("proxy-") || lowerCaseHeader.startsWith("sec-")) { + LOG.severe("Headers starting with Proxy-* or Sec-* are prohibited"); + throw new IllegalArgumentException( + "Headers starting with Proxy-* or Sec-* are prohibited"); + } + for (String prohibited : INVALID_HEADERS) { + if (header.equalsIgnoreCase(prohibited)) { + LOG.severe("Prohibited header"); + throw new IllegalArgumentException( + "Headers starting with Proxy-* or Sec-* are prohibited"); + } + } + } + + private static final String[] INVALID_HEADERS = new String[] { "Accept-Charset", + "Accept-Encoding", "Connection", "Content-Length", "Content-Transfer-Encoding", "Date", + "Expect", "Host", "Keep-Alive", "Referer", "TE", "Trailer", "Transfer-Encoding", + "Upgrade", "Via" }; + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/Base64Util.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/Base64Util.java new file mode 100644 index 0000000..03da7d3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/Base64Util.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.nio.ByteBuffer; +import java.util.logging.Logger; + +/** + * Internal class. This class manages the Base64 encoding and decoding + */ +public class Base64Util { + +// @FlashNative +//import mx.utils.Base64Encoder; +// public String encodeBytes(ByteArray bytes) { +// Base64Encoder encoder=new Base64Encoder(); +// encoder.insertNewLines = false; +// encoder.encodeBytes(bytes); +// return encoder.drain(); +// } + + private static final String CLASS_NAME = Base64Util.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final byte[] INDEXED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".getBytes(); + private static final byte PADDING_BYTE = (byte) '='; + + @Deprecated + private Base64Util() { + LOG.entering(CLASS_NAME, ""); + } + + public static String encode(ByteBuffer decoded) { + LOG.entering(CLASS_NAME, "encode", decoded); + + int decodedSize = decoded.remaining(); + int effectiveDecodedSize = ((decodedSize+2) / 3) * 3; + int decodedFragmentSize = decodedSize % 3; + + int encodedArraySize = effectiveDecodedSize / 3 * 4; + byte[] encodedArray = new byte[encodedArraySize]; + int encodedArrayPosition = 0; + + byte[] decodedArray = decoded.array(); + int decodedArrayOffset = decoded.arrayOffset(); + int decodedArrayPosition = decodedArrayOffset + decoded.position(); + int decodedArrayLimit = decodedArrayOffset + decoded.limit() - decodedFragmentSize; + + while (decodedArrayPosition < decodedArrayLimit) { + int byte0 = decodedArray[decodedArrayPosition++] & 0xff; + int byte1 = decodedArray[decodedArrayPosition++] & 0xff; + int byte2 = decodedArray[decodedArrayPosition++] & 0xff; + + encodedArray[encodedArrayPosition++] = INDEXED[(byte0 >> 2) & 0x3f]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte0 << 4) & 0x30) | ((byte1 >> 4) & 0x0f)]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte1 << 2) & 0x3c) | ((byte2 >> 6) & 0x03)]; + encodedArray[encodedArrayPosition++] = INDEXED[byte2 & 0x3f]; + } + + if (decodedFragmentSize == 1) { + int byte0 = decodedArray[decodedArrayPosition++] & 0xff; + + encodedArray[encodedArrayPosition++] = INDEXED[(byte0 >> 2) & 0x3f]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte0 << 4) & 0x30)]; + encodedArray[encodedArrayPosition++] = PADDING_BYTE; + encodedArray[encodedArrayPosition++] = PADDING_BYTE; + } + else if (decodedFragmentSize == 2) { + int byte0 = decodedArray[decodedArrayPosition++] & 0xff; + int byte1 = decodedArray[decodedArrayPosition++] & 0xff; + + encodedArray[encodedArrayPosition++] = INDEXED[(byte0 >> 2) & 0x3f]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte0 << 4) & 0x30) | ((byte1 >> 4) & 0x0f)]; + encodedArray[encodedArrayPosition++] = INDEXED[(byte1 << 2) & 0x3c]; + encodedArray[encodedArrayPosition++] = PADDING_BYTE; + } + + return new String(encodedArray); + } + + public static ByteBuffer decode(String encoded) { + LOG.entering(CLASS_NAME, "decode", encoded); + + if (encoded == null) { + return null; + } + + int length = encoded.length(); + if (length % 4 != 0) { + throw new IllegalArgumentException("Invalid Base64 Encoded String"); + } + + byte[] encodedArray = encoded.getBytes(); + byte[] decodedArray = new byte[(length / 4 * 3)]; + int decodedArrayOffset = 0; + int i = 0; + while (i < length) { + int char0 = encodedArray[i++]; + int char1 = encodedArray[i++]; + int char2 = encodedArray[i++]; + int char3 = encodedArray[i++]; + + int byte0 = mapped(char0); + int byte1 = mapped(char1); + int byte2 = mapped(char2); + int byte3 = mapped(char3); + + decodedArray[decodedArrayOffset++] = (byte) (((byte0 << 2) & 0xfc) | ((byte1 >> 4) & 0x03)); + if (char2 != PADDING_BYTE) { + decodedArray[decodedArrayOffset++] = (byte) (((byte1 << 4) & 0xf0) | ((byte2 >> 2) & 0x0f)); + if (char3 != PADDING_BYTE) { + decodedArray[decodedArrayOffset++] = (byte) (((byte2 << 6) & 0xc0) | (byte3 & 0x3f)); + } + } + } + return ByteBuffer.wrap(decodedArray, 0, decodedArrayOffset); + } + + private static int mapped(int ch) { + if ((ch & 0x40) != 0) { + if ((ch & 0x20) != 0) { + // a(01100001)-z(01111010) -> 26-51 + assert (ch >= 'a'); + assert (ch <= 'z'); + return (ch - 71); + } else { + // A(01000001)-Z(01011010) -> 0-25 + assert (ch >= 'A'); + assert (ch <= 'Z'); + return (ch - 65); + } + } else if ((ch & 0x20) != 0) { + if ((ch & 0x10) != 0) { + if ((ch & 0x08) != 0 && (ch & 0x04) != 0) { + // =(00111101) -> 0 + assert (ch == '='); + return 0; + } else { + // 0(00110000)-9(00111001) -> 52-61 + assert (ch >= '0'); + assert (ch <= '9'); + return (ch + 4); + } + } else { + if ((ch & 0x04) != 0) { + // /(00101111) -> 63 + assert (ch == '/'); + return 63; + } else { + // +(00101011) -> 62 + assert (ch == '+'); + return 62; + } + } + } else { + LOG.warning("Invalid BASE64 string"); + throw new IllegalArgumentException("Invalid BASE64 string"); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocket.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocket.java new file mode 100644 index 0000000..e6ba62b --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocket.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.SocketException; + +interface BridgeSocket { + + void connect(InetSocketAddress inetSocketAddress, long timeout) throws IOException; + InputStream getInputStream() throws IOException; + OutputStream getOutputStream() throws IOException; + void close() throws IOException; + + void setSoTimeout(int i) throws SocketException; + void setKeepAlive(boolean b) throws SocketException; + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketFactory.java new file mode 100644 index 0000000..25c1a9a --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketFactory.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.io.IOException; + +interface BridgeSocketFactory { + + BridgeSocket createSocket(boolean secure) throws IOException; + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketImpl.java new file mode 100644 index 0000000..06508c3 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketImpl.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketException; + +import javax.net.ssl.SSLSocketFactory; + +class BridgeSocketImpl implements BridgeSocket { + + boolean secure; + Socket socket; + + BridgeSocketImpl(boolean secure) { + this.secure = secure; + } + + @Override + public void connect(InetSocketAddress inetSocketAddress, long timeout) throws IOException { + if (secure) { + socket = SSLSocketFactory.getDefault().createSocket(); + } + else { + socket = new Socket(); + } + + + assert(timeout >= 0); + socket.connect(inetSocketAddress, (int)timeout); + } + + @Override + public void close() throws IOException { + socket.close(); + } + + @Override + public InputStream getInputStream() throws IOException { + return socket.getInputStream(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return socket.getOutputStream(); + } + + @Override + public void setKeepAlive(boolean val) throws SocketException { + socket.setKeepAlive(val); + } + + @Override + public void setSoTimeout(int timeout) throws SocketException { + socket.setSoTimeout(timeout); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessor.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessor.java new file mode 100644 index 0000000..82cb805 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessor.java @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.logging.Logger; + +public class FrameProcessor { + + private static final String CLASS_NAME = FrameProcessor.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + FrameProcessorListener listener; + DecodingState state; + WsFrameEncodingSupport.Opcode opcode; + int dataLength; + Boolean masked; + int maskkey; + ByteBuffer payLoadLengthBuf; + ByteBuffer maskkeyBuf; + Boolean fin; + ByteBuffer data; + + FrameProcessor(FrameProcessorListener listener) { + this.listener = listener; + this.state = DecodingState.START_OF_FRAME; + } + + /* + * Processing state machine + */ + static enum DecodingState { + START_OF_FRAME, + READING_PAYLOADLENGTH, + READING_PAYLOADLENGTH_EXT, + READING_MASK_KEY, + READING_PAYLOAD, + END_OF_FRAME, + }; + + /** + * Process frames from inbound messages + * @param byteBuffer + */ + boolean process(InputStream inputStream) throws IOException { + LOG.entering(CLASS_NAME, "process"); + int b; + for (;;) { + + switch (state) { + // handle alignment with start-of-frame boundary (after mark) + case START_OF_FRAME: + b = inputStream.read(); + if (b == -1) { + return false; //end of stream + } + fin = (b & 0x80) != 0; + opcode = WsFrameEncodingSupport.Opcode.getById((b & 0x0f)); + state = DecodingState.READING_PAYLOADLENGTH; //start read mask & payload length byte + break; + + case READING_PAYLOADLENGTH: + + //reading mask bit and payload length (7) + b = inputStream.read(); + if (b == -1) { + return false; //end of stream + } + + masked = (b & 0x80) != 0; + if (masked) { + maskkeyBuf = ByteBuffer.allocate(4); //4 byte mask key + } + dataLength = b & 0x7f; + + if (dataLength==126) { + //length is 16 bit long unsigned int - must fill first two bytes with 0,0 to handle unsigned + state = DecodingState.READING_PAYLOADLENGTH_EXT; + payLoadLengthBuf = ByteBuffer.allocate(4); + payLoadLengthBuf.put(new byte[] {0x00, 0x00}); //fill 2 bytes with 0 + } else if (dataLength==127) { + //length is 64 bit long + state = DecodingState.READING_PAYLOADLENGTH_EXT; + payLoadLengthBuf = ByteBuffer.allocate(8); + } + else { + state = DecodingState.READING_MASK_KEY; + + } + break; + + case READING_PAYLOADLENGTH_EXT: + byte[] bytes = new byte[payLoadLengthBuf.remaining()]; + int num = inputStream.read(bytes); + if (num == -1) { + return false; //end of stream + } + payLoadLengthBuf.put(bytes, 0, num); + if (!payLoadLengthBuf.hasRemaining()) { + //payload length bytes has been read + payLoadLengthBuf.flip(); + if (payLoadLengthBuf.capacity() == 4) { + // 16 bit length + dataLength = payLoadLengthBuf.getInt(); + } + else { + dataLength = (int) payLoadLengthBuf.getLong(); + } + state = DecodingState.READING_MASK_KEY; + break; + } + break; + + case READING_MASK_KEY: + if (!masked) { + //unmasked, skip READ_MASK_KEY + data = ByteBuffer.allocate(dataLength); + state = DecodingState.READING_PAYLOAD; + break; + } + bytes = new byte[maskkeyBuf.remaining()]; + num = inputStream.read(bytes); + if (num == -1) { + return false; //end of stream + } + maskkeyBuf.put(bytes, 0, num); + if (!maskkeyBuf.hasRemaining()) { + //4 bytes has been read, done with mask key + maskkeyBuf.flip(); //move postion to 0 + maskkey = maskkeyBuf.getInt(); + data = ByteBuffer.allocate(dataLength); + state = DecodingState.READING_PAYLOAD; + } + break; + + case READING_PAYLOAD: + if (dataLength == 0) { + // empty message + state = DecodingState.END_OF_FRAME; + break; + } + bytes = new byte[data.remaining()]; + num = inputStream.read(bytes); + if (num == -1) { + return false; //end of stream + } + data.put(bytes, 0, num); + if (!data.hasRemaining()) { + //all payload has been read + data.flip(); + state = DecodingState.END_OF_FRAME; + break; + } + break; + + case END_OF_FRAME: + //finished load payload + switch (opcode) { + case BINARY: + if (masked) { + WsFrameEncodingSupport.unmask(data, maskkey); + } + listener.messageReceived(data, "BINARY"); + break; + case TEXT: + if (masked) { + WsFrameEncodingSupport.unmask(data, maskkey); + } + listener.messageReceived(data, "TEXT"); + break; + case PING: + listener.messageReceived(data, "PING"); + break; + case PONG: + //do nothing + break; + case CLOSE: + listener.messageReceived(data, "CLOSE"); + break; + } + state = DecodingState.START_OF_FRAME; + + break; + } + } + + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessorListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessorListener.java new file mode 100644 index 0000000..19b8c19 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessorListener.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.nio.ByteBuffer; + +interface FrameProcessorListener { + + void messageReceived(ByteBuffer buffer, String type); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegate.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegate.java new file mode 100644 index 0000000..69656d6 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegate.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.kaazing.gateway.client.transport.BridgeDelegate; + +public interface WebSocketDelegate extends BridgeDelegate { + + void processOpen(); + void processAuthorize(String string); + void processDisconnect() throws IOException; + void processDisconnect(short code, String reason) throws IOException; //add code and reason + void processSend(ByteBuffer byteBuffer); + void processSend(String text); //add this method to send text frame message + + void setListener(WebSocketDelegateListener listener); + void setIdleTimeout(int timeout); //set WebSocket idle timeout in miliseconds +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateFactory.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateFactory.java new file mode 100644 index 0000000..fd47a4e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateFactory.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.net.URI; + +public interface WebSocketDelegateFactory { + + WebSocketDelegate createWebSocketDelegate(URI xoaUrl, URI originUrl, String wsProtocol); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateImpl.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateImpl.java new file mode 100644 index 0000000..31b18bc --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateImpl.java @@ -0,0 +1,1066 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.transport.AuthenticateEvent; +import org.kaazing.gateway.client.transport.CloseEvent; +import org.kaazing.gateway.client.transport.ErrorEvent; +import org.kaazing.gateway.client.transport.IoBufferUtil; +import org.kaazing.gateway.client.transport.LoadEvent; +import org.kaazing.gateway.client.transport.MessageEvent; +import org.kaazing.gateway.client.transport.OpenEvent; +import org.kaazing.gateway.client.transport.ProgressEvent; +import org.kaazing.gateway.client.transport.ReadyStateChangedEvent; +import org.kaazing.gateway.client.transport.RedirectEvent; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegate; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegateFactory; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegateImpl; +import org.kaazing.gateway.client.transport.http.HttpRequestDelegateListener; +import org.kaazing.gateway.client.transport.ws.WsMessage.Kind; + +public class WebSocketDelegateImpl implements WebSocketDelegate { + private static final String CLASS_NAME = WebSocketDelegateImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final byte[] GET_BYTES = "GET".getBytes(); + private static final String APPLICATION_PREFIX = "Application "; + private static final String WWW_AUTHENTICATE = "WWW-Authenticate: "; + private static final String HTTP_1_1_START = "HTTP/1.1"; + private static final int HTTP_1_1_START_LEN = HTTP_1_1_START.length(); + private static final byte[] HTTP_1_1_START_BYTES = HTTP_1_1_START.getBytes(); + private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + public static final int CLOSE_NO_STATUS = 1005; + public static final int CLOSE_ABNORMAL = 1006; + + static enum ConnectionStatus { + START, STATUS_101_READ, CONNECTION_UPGRADE_READ, COMPLETED, ERRORED + } + + public static enum ReadyState { + CONNECTING, OPEN, CLOSING, CLOSED; + } + + private static final byte[] HTTP_1_1_BYTES = "HTTP/1.1".getBytes(); + private static final byte[] COLON_BYTES = ":".getBytes(); + private static final byte[] SPACE_BYTES = " ".getBytes(); + private static final byte[] CRLF_BYTES = "\r\n".getBytes(); + private static final String HEADER_ORIGIN = "Origin"; + private static final String HEADER_CONNECTION = "Connection"; + private static final String HEADER_HOST = "Host"; + private static final String HEADER_UPGRADE = "Upgrade"; + private static final String HEADER_PROTOCOL = "Sec-WebSocket-Protocol"; + private static final String HEADER_WEBSOCKET_KEY = "Sec-WebSocket-Key"; + private static final String HEADER_WEBSOCKET_VERSION = "Sec-WebSocket-Version"; + private static final String HEADER_VERSION = "13"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String HEADER_LOCATION = "Location"; + private static final String HEADER_WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String WEB_SOCKET_LOWERCASE = "websocket"; + private static final String HEADER_COOKIE = "Cookie"; + private static final Charset UTF8 = Charset.forName("UTF-8"); + + private BridgeSocket socket; + private boolean stopReaderThread; + private boolean connectionUpgraded = false; + private URI url; + private String origin; + private URI originUri; + private String[] requestedProtocols; + private boolean secure; + private WebSocketDelegateListener listener; + protected String cookies = null; + private String authorize = null; + private AtomicBoolean closed = new AtomicBoolean(false); + String websocketKey; + + private final long connectTimeout; + + //--------Idle Timeout-------------// + private final AtomicInteger idleTimeout = new AtomicInteger(); + private final AtomicLong lastMessageTimestamp = new AtomicLong(); + private Timer idleTimer = null; + + //WebSocket rfc6455 properties + private ReadyState readyState = ReadyState.CONNECTING; + public ReadyState getReadyState(){ + return readyState; + } + + int bufferedAmount; + public int getBufferedAmount() { + return bufferedAmount; + } + + private String secProtocol; + public String getSecProtocol() { + return secProtocol; + } + + private String extensions; + public String getExtensions() { + return extensions; + } + // close event data + private boolean wasClean = false; + private int code = CLOSE_ABNORMAL; + private String reason = ""; + + HttpRequestDelegateFactory HTTP_REQUEST_DELEGATE_FACTORY = new HttpRequestDelegateFactory() { + @Override + public HttpRequestDelegate createHttpRequestDelegate() { + return new HttpRequestDelegateImpl(); + } + }; + + BridgeSocketFactory BRIDGE_SOCKET_FACTORY = new BridgeSocketFactory() { + @Override + public BridgeSocket createSocket(boolean secure) throws IOException { + return new BridgeSocketImpl(secure); + } + }; + + /** + * WebSocket Java API for use in Java Web Start applications + * + * @param url + * WebSocket URL location + * @param origin + * the codebase+hostname from the JNLP of the Java Web Start app + * @param protocol + * WebSocket protocol + * @throws Exception + */ + public WebSocketDelegateImpl(URI url, URI origin, String[] protocols, long connectTimeout) { + LOG.entering(CLASS_NAME, "", new Object[] {url, origin, protocols}); + if (origin == null) { + throw new IllegalArgumentException("Please specify the origin for the WebSocket connection"); + } + + if (url == null) { + throw new IllegalArgumentException("Please specify the target for the WebSocket connection"); + } + this.url = url; + + if ((origin.getScheme() == null) || (origin.getHost() == null)) { + this.origin = "null"; + } + else { + String originScheme = origin.getScheme(); + String originHost = origin.getHost(); + int originPort = origin.getPort(); + if (originPort == -1) { + originPort = (originScheme.equals("https")) ? 443 : 80; + } + this.origin = originScheme + "://" + originHost + ":" + originPort; + } + + this.requestedProtocols = protocols; + secure = url.getScheme().equalsIgnoreCase("wss"); + this.connectTimeout = connectTimeout; + } + + + //------------------------------Idle Timer Start/Stop/Handler---------------------// + + private void startIdleTimer(long delayInMilliseconds) { + LOG.fine("Starting idle timer"); + if (this.idleTimer != null) { + idleTimer.cancel(); + idleTimer = null; + } + + idleTimer = new Timer("IdleTimer", true); + idleTimer.schedule(new TimerTask() { + + @Override + public void run() { + idleTimerHandler(); + } + + }, delayInMilliseconds); + } + + private void idleTimerHandler() { + LOG.fine("Idle timer scheduled"); + long idleDuration = System.currentTimeMillis() - lastMessageTimestamp.get(); + if (idleDuration > idleTimeout.get()) { + String message = "idle duration - " + idleDuration + " exceeded idle timeout - " + idleTimeout; + LOG.fine(message); + handleClose(null); + } + else { + // Reschedule timer + startIdleTimer(idleTimeout.get() - idleDuration); + } + } + + private void stopIdleTimer() { + LOG.fine("Stopping idle timer"); + if (idleTimer != null) { + idleTimer.cancel(); + idleTimer = null; + } + } + + @Override + public void setIdleTimeout(int milliSecond) { + idleTimeout.set(milliSecond); + if (milliSecond > 0) { + // start monitor websocket traffic + lastMessageTimestamp.set(System.currentTimeMillis()); + startIdleTimer(milliSecond); + } + else { + stopIdleTimer(); + } + } + + //-------------------------------------------------------------------------------// + + public void processOpen() { + LOG.entering(CLASS_NAME, "processOpen"); + // pre-flight cookies request + // Lookup the session cookie + String scheme = this.url.getScheme(); + String host = this.url.getHost(); + int port = this.url.getPort(); + String path = this.url.getPath(); + if (port == -1) { + port = (scheme.equals("wss")) ? 443 : 80; + } + LOG.fine("processOpen: Connecting to "+host+":"+port); + + String cookiesUri = scheme.replace("ws", "http") + "://" + host + ":" + port + path + "/;e/cookies?.krn=" + Double.toString(Math.random()).substring(2); + String query = this.url.getQuery(); + if (query != null && query.length() > 0) { + // No need to check to append "?" since a query parameter is added above + cookiesUri += "&" + query; + } + final HttpRequestDelegate cookiesRequest = HTTP_REQUEST_DELEGATE_FACTORY.createHttpRequestDelegate(); + + cookiesRequest.setListener(new HttpRequestDelegateListener() { + + @Override + public void opened(OpenEvent event) { + } + + @Override + public void readyStateChanged(ReadyStateChangedEvent event) { + } + + @Override + public void progressed(ProgressEvent progressEvent) { + } + + @Override + public void loaded(LoadEvent event) { + switch (cookiesRequest.getStatusCode()) { + case 200: + case 201: + ByteBuffer responseBuf = cookiesRequest.getResponseText(); + if (responseBuf != null && responseBuf.hasRemaining()) { + if (isHTTPResponse(responseBuf)) { + try { + handleWrappedHTTPResponse(responseBuf); + return; + } + catch (Exception e1) { + WebSocketDelegateImpl.this.handleClose(e1); + throw new IllegalStateException("Handling wrapped HTTP response failed", e1); + } + } + else { + cookies = new String(responseBuf.array(), responseBuf.position(), responseBuf.remaining()); + } + } + break; + case 301: + case 302: + case 307: + String location = cookiesRequest.getResponseHeader(HEADER_LOCATION); + LOG.finest("Redirect to " + location); + + URI uri; + try { + uri = new URI(location); + String query = uri.getQuery(); + String newQuery = (query != null ? query + "&" : "") + ".kl=Y"; + String redirectLocation = uri.getScheme().replace("http", "ws") + "://" + uri.getHost() + ":" + uri.getPort() + uri.getPath() + "?" + newQuery; + LOG.finest("Redirect as " + redirectLocation); + listener.redirected(new RedirectEvent(redirectLocation)); + } catch (URISyntaxException e) { + LOG.severe("Redirect location invalid: "+location); + } + return; + case 401: + String wwwAuthenticate = cookiesRequest.getResponseHeader(HEADER_WWW_AUTHENTICATE); + listener.authenticationRequested(new AuthenticateEvent(wwwAuthenticate)); + return; + default: + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSED; + String s = "Cookies request: Invalid status code: " + cookiesRequest.getStatusCode(); + listener.errorOccurred(new ErrorEvent(new IllegalStateException(s))); + return; + } + nativeConnect(); + } + + private void handleWrappedHTTPResponse(ByteBuffer responseBody) throws Exception { + LOG.entering(CLASS_NAME, "cookiesRequest.handleWrappedHTTPResponse"); + String[] lines = getLines(responseBody); + int statusCode = Integer.parseInt(lines[0].split(" ")[1]); + switch (statusCode) { + case HttpURLConnection.HTTP_UNAUTHORIZED: // 401 + String wwwAuthenticate = null; + for (int i = 1; i < lines.length; i++) { + if (lines[i].startsWith(WWW_AUTHENTICATE)) { + wwwAuthenticate = lines[i].substring(WWW_AUTHENTICATE.length()); + break; + } + } + LOG.finest("cookiesRequest.handleWrappedHTTPResponse: WWW-Authenticate: " + wwwAuthenticate); + if (wwwAuthenticate == null || "".equals(wwwAuthenticate)) { + LOG.severe("Missing authentication challenge in wrapped HTTP 401 response"); + throw new IllegalStateException("Missing authentication challenge in wrapped HTTP 401 response"); + } + else if (!wwwAuthenticate.startsWith(APPLICATION_PREFIX)) { + LOG.severe("Only Application challenges are supported by the client"); + throw new IllegalStateException("Only Application challenges are supported by the client"); + } + else { + listener.authenticationRequested(new AuthenticateEvent(wwwAuthenticate)); + } + break; + default: + throw new IllegalStateException("Unsupported wrapped response with HTTP status code " + statusCode); + } + } + + @Override + public void closed(CloseEvent event) { + } + + @Override + public void errorOccurred(ErrorEvent event) { + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSED; + listener.errorOccurred(new ErrorEvent(event.getException())); + } + }); + + URL cookiesUrl; + try { + cookiesUrl = new URL(cookiesUri); + cookiesRequest.processOpen("GET", cookiesUrl, this.origin, false, connectTimeout); + if (authorize != null) { + cookiesRequest.setRequestHeader(HEADER_AUTHORIZATION, authorize); + } + postProcessOpen(cookiesRequest); + cookiesRequest.processSend(null); + } + catch (Exception e1) { + LOG.severe(e1.toString()); + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSED; + listener.errorOccurred(new ErrorEvent(e1)); + } + } + + // Hook for subclasses for testing cookies requests. + protected void postProcessOpen(HttpRequestDelegate cookiesRequest) { + } + + private static boolean isHTTPResponse(ByteBuffer buf) { + boolean isHttpResponse = true; + if (buf.remaining() >= HTTP_1_1_START_LEN) { + for (int i = 0; i < HTTP_1_1_START_LEN; i++) { + if (buf.get(i) != HTTP_1_1_START_BYTES[i]) { + isHttpResponse = false; + break; + } + } + } + return isHttpResponse; + } + + private static String[] getLines(ByteBuffer buf) { + List lineList = new ArrayList(); + while (buf.hasRemaining()) { + byte next = buf.get(); + List lineText = new ArrayList(); + while (next != 13) { // CR + lineText.add(next); + if (buf.hasRemaining()) { + next = buf.get(); + } + else { + break; + } + } + if (buf.hasRemaining()) { + next = buf.get(); // should be LF + } + byte[] lineTextBytes = new byte[lineText.size()]; + int i = 0; + for (Byte text : lineText) { + lineTextBytes[i] = text; + i++; + } + try { + lineList.add(new String(lineTextBytes, "UTF-8")); + } + catch (UnsupportedEncodingException e) { + throw new IllegalStateException("Unrecognized Encoding from the server", e); + } + } + String[] lines = new String[lineList.size()]; + lineList.toArray(lines); + return lines; + } + + protected void nativeConnect() { + LOG.entering(CLASS_NAME, "nativeConnect"); + String host = this.url.getHost(); + int port = this.url.getPort(); + String scheme = this.url.getScheme(); + + if (port == -1) { + port = (scheme.equals("wss")) ? 443 : 80; + } + try { + LOG.fine("WebSocketDelegate.nativeConnect(): Connecting to "+host+":"+port); + socket = BRIDGE_SOCKET_FACTORY.createSocket(secure); + socket.connect(new InetSocketAddress(host, port), connectTimeout); + socket.setKeepAlive(true); + socket.setSoTimeout(0); // continuously read from the socket + } + catch (Exception e) { + LOG.log(Level.FINE, "WebSocketDelegateImpl nativeConnect(): "+e.getMessage(), e); + // Fire error listener to request to try to emulate it + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSED; + listener.errorOccurred(new ErrorEvent(e)); + return; + } + negotiateWebSocketConnection(socket); + } + + private void negotiateWebSocketConnection(BridgeSocket socket) { + LOG.entering(CLASS_NAME, "negotiateWebSocketConnection", socket); + try { + int headerCount = 9 + ((cookies == null) ? 0 : 1); + String[] headerNames = new String[headerCount]; + String[] headerValues = new String[headerCount]; + int headerIndex = 0; + headerNames[headerIndex] = HEADER_UPGRADE; + headerValues[headerIndex++] = WEB_SOCKET_LOWERCASE; + headerNames[headerIndex] = HEADER_CONNECTION; + headerValues[headerIndex++] = HEADER_UPGRADE; + headerNames[headerIndex] = HEADER_HOST; + headerValues[headerIndex++] = this.url.getAuthority(); + headerNames[headerIndex] = HEADER_ORIGIN; + headerValues[headerIndex++] = origin; + + headerNames[headerIndex] = HEADER_WEBSOCKET_VERSION; + headerValues[headerIndex++] = HEADER_VERSION; + headerNames[headerIndex] = HEADER_WEBSOCKET_KEY; + if (websocketKey == null) { + websocketKey = base64Encode(randomBytes(16)); + } + headerValues[headerIndex++] = websocketKey; + + if (requestedProtocols != null && requestedProtocols.length > 0) { + headerNames[headerIndex] = HEADER_PROTOCOL; + + String value; + if (requestedProtocols.length == 1) { + value = requestedProtocols[0]; + } + else { + value = ""; + for (int i=0; i0) { + value += ","; + } + value += requestedProtocols[i]; + } + } + + headerValues[headerIndex++] = value; + } + + if (cookies != null) { + headerNames[headerIndex] = HEADER_COOKIE; + headerValues[headerIndex++] = cookies; + } + + if (authorize != null) { + headerNames[headerIndex] = HEADER_AUTHORIZATION; + headerValues[headerIndex] = authorize; + } + + LOG.finer("Origin: " + origin); + + byte[] request = encodeGetRequest(this.url, headerNames, headerValues); + + // SOCKET DEBUGGING: OutputStream out = new LoggingOutputStream(socket.getOutputStream()); + OutputStream out = socket.getOutputStream(); + out.write(request); + out.flush(); + + InputStream in = socket.getInputStream(); + Thread readerThread = new Thread(new SocketReader(in), "WebSocketDelegate socket reader"); + readerThread.setDaemon(true); + readerThread.start(); + + } catch (Exception e) { + LOG.severe(e.toString()); + handleError(e); + } + } + + public byte[] encodeGetRequest(URI requestURI, String[] names, String[] values) { + + // Any changes to this method should result in the getEncodeRequestSize method below + // to get accurate length of the buffer that needs to be allocated. + + LOG.entering(CLASS_NAME, "encodeGetRequest", new Object[] {requestURI, names, values}); + int requestSize = getEncodeRequestSize(requestURI, names, values); + ByteBuffer buf = ByteBuffer.allocate(requestSize); + + // Encode Request line + buf.put(GET_BYTES); + buf.put(SPACE_BYTES); + String path = requestURI.getPath(); + if (requestURI.getQuery() != null) { + path += "?" + requestURI.getQuery(); + } + buf.put(path.getBytes()); + buf.put(SPACE_BYTES); + buf.put(HTTP_1_1_BYTES); + buf.put(CRLF_BYTES); + + // Encode headers + for (int i = 0; i < names.length; i++) { + String headerName = names[i]; + String headerValue = values[i]; + if (headerName != null && headerValue != null) { + buf.put(headerName.getBytes()); + buf.put(COLON_BYTES); + buf.put(SPACE_BYTES); + buf.put(headerValue.getBytes()); + buf.put(CRLF_BYTES); + } + } + + // Encoding cookies, content length and content not done here as we + // don't have it in the initial GET request. + + buf.put(CRLF_BYTES); + buf.flip(); + return buf.array(); + } + + private int getEncodeRequestSize(URI requestURI, String[] names, String[] values) { + int size = 0; + + // Encode Request line + size += GET_BYTES.length; + size += SPACE_BYTES.length; + String path = requestURI.getPath(); + if (requestURI.getQuery() != null) { + path += "?" + requestURI.getQuery(); + } + size += path.getBytes().length; + size += SPACE_BYTES.length; + size += HTTP_1_1_BYTES.length; + size += CRLF_BYTES.length; + + // Encode headers + for (int i = 0; i < names.length; i++) { + String headerName = names[i]; + String headerValue = values[i]; + if (headerName != null && headerValue != null) { + size += headerName.getBytes().length; + size += COLON_BYTES.length; + size += SPACE_BYTES.length; + size += headerValue.getBytes().length; + size += CRLF_BYTES.length; + } + } + + size += CRLF_BYTES.length; + + LOG.fine("Returning a request size of " + size); + return size; + } + + public void processDisconnect() throws IOException { + processDisconnect((short) 0, null); + } + + public void processDisconnect(short code, String reason) throws IOException { + LOG.entering(CLASS_NAME, "disconnect"); + //stopReaderThread = true; --- rfc 6455 donot stop SockectReader, wait for CloseFrame + + //send close frame if webSocket is open + if (this.readyState == ReadyState.OPEN) { + this.readyState = ReadyState.CLOSING; + ByteBuffer data; + if (code == 0) { + data = ByteBuffer.allocate(0); + } + else { + //if code is present, it must equal to 1000 or in range 3000 to 4999 + if (code != 1000 && (code < 3000 || code > 4999)) { + throw new IllegalArgumentException("code must equal to 1000 or in range 3000 to 4999"); + } + ByteBuffer reasonBuf = null; + if (reason != null && reason.length() > 0) { + //UTF 8 encode reason + Charset cs = Charset.forName("UTF-8"); + reasonBuf = cs.encode(reason); + if (reasonBuf.limit() > 123) { + throw new IllegalArgumentException("Reason is longer than 123 bytes"); + } + } + data = ByteBuffer.allocate(2 + (reasonBuf == null ? 0 : reasonBuf.remaining())); + data.putShort(code); + + if (reasonBuf != null) { + data.put(reasonBuf); + } + + data.flip(); + } + this.send(WsFrameEncodingSupport.rfc6455Encode(new WsMessage(data, Kind.CLOSE), new Random().nextInt())); + } + else if (readyState == ReadyState.CONNECTING) { + //websocket not open yet, fire close event + stopReaderThread = true; + handleClose(null); + } + + // Do not close the underlying socket connection immediately. As per the RFC spec - + // After both sending and receiving a Close message, an endpoint considers the WebSocket + // connection closed and MUST close the underlying TCP connection. + // Schedule a timer to close the underlying Socket connection if the CLOSE frame is not + // received from the Gateway within 5 seconds. + Timer t = new Timer("SocketCloseTimer", true); + t.schedule(new TimerTask() { + + @Override + public void run() { + try { + if (WebSocketDelegateImpl.this.readyState != ReadyState.CLOSED) { + stopIdleTimer(); + closeSocket(); + } + } + finally { + cancel(); + } + } + }, 5000); + + //else do nothing for CLOSING and CLOSED + } + + public void processAuthorize(String authorize) { + LOG.entering(CLASS_NAME, "processAuthorize", authorize); + this.authorize = authorize; + processOpen(); + } + + @Override + public void processSend(ByteBuffer data) { + LOG.entering(CLASS_NAME, "processSend", data); + + //move encoder code to core.java.client.internal, here just send data + ByteBuffer frame = WsFrameEncodingSupport.rfc6455Encode(new WsMessage(data, Kind.BINARY), new Random().nextInt()); + send(frame); + // send(data); + } + + @Override + public void processSend(String data) { + LOG.entering(CLASS_NAME, "processSend", data); + ByteBuffer buf = null; + try { + buf = ByteBuffer.wrap(data.getBytes("UTF-8")); + } + catch (UnsupportedEncodingException e) { + // this should not be reached + String s = "The platform should have already been checked to see if UTF-8 encoding is supported"; + throw new IllegalStateException(s); + } + + ByteBuffer frame = WsFrameEncodingSupport.rfc6455Encode(new WsMessage(buf, Kind.TEXT), new Random().nextInt()); + send(frame); + // send(data); + } + + private void send(ByteBuffer frame) { + LOG.entering(CLASS_NAME, "send", frame); + if (socket == null) { + handleError(new IllegalStateException("Socket is null")); + } + + try { + // consolidate the frame complete here and then flush + OutputStream outputStream = socket.getOutputStream(); + int offset = frame.position(); + int len = frame.remaining(); + outputStream.write(frame.array(), offset, len); + outputStream.flush(); + } + catch (Exception e) { + LOG.log(Level.FINE, "While sending: "+e.getMessage(), e); + handleError(e); + } + } + + protected URI getUrl() { + LOG.exiting(CLASS_NAME, "getUrl", url); + return url; + } + + protected URI getOrigin() { + LOG.exiting(CLASS_NAME, "getOrigin", originUri); + return originUri; + } + + private void closeSocket() { + try { + LOG.log(Level.FINE, "Closing socket"); + + // Sleep for a tenth-of-second before closing the socket. + Thread.sleep(100); + + if ((socket != null) && (readyState != ReadyState.CLOSED)) { + socket.close(); + } + } + catch (IOException e) { + LOG.log(Level.FINE, "While closing socket: "+e.getMessage(), e); + } + catch (InterruptedException e) { + LOG.log(Level.FINE, "While closing socket: "+e.getMessage(), e); + } + finally { + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSED; + socket = null; + } + } + + private void handleClose(Exception ex) { + if (closed.compareAndSet(false, true)) { + try { + stopIdleTimer(); + closeSocket(); + } + finally { + if (ex == null) { + listener.closed(new CloseEvent(this.code, this.wasClean, this.reason)); + } + else { + listener.closed(new CloseEvent(ex)); + } + } + } + } + + private void handleError(Exception ex) { + if (closed.compareAndSet(false, true)) { + try { + closeSocket(); + } + finally { + listener.errorOccurred(new ErrorEvent(ex)); + } + } + } + + private byte[] randomBytes(int size) { + byte[] bytes = new byte[size]; + Random r = new Random(); + r.nextBytes(bytes); + return bytes; + } + + private String base64Encode(byte[] bytes) { + return Base64Util.encode(ByteBuffer.wrap(bytes)); + + } + + @Override + public void setListener(WebSocketDelegateListener listener) { + this.listener = listener; + } + + + class SocketReader implements Runnable { + private final String CLASS_NAME = SocketReader.class.getName(); + + private static final String HTTP_101_MESSAGE = "HTTP/1.1 101 Web Socket Protocol Handshake"; + private static final String UPGRADE_HEADER = "Upgrade: "; + private static final int UPGRADE_HEADER_LENGTH = 9; // "Upgrade: ".length(); + private static final String UPGRADE_VALUE = "websocket"; + private static final String CONNECTION_MESSAGE = "Connection: Upgrade"; + private static final String WEBSOCKET_PROTOCOL = "Sec-WebSocket-Protocol"; + private static final String WEBSOCKET_EXTENSIONS = "Sec-WebSocket-Extensions"; + private static final String WEBSOCKET_ACCEPT = "Sec-WebSocket-Accept"; + + ConnectionStatus state = ConnectionStatus.START; + Boolean upgradeReceived = false; + Boolean connectionReceived = false; + Boolean websocketAcceptReceived = false; + + InputStream inputStream = null; + + public SocketReader(InputStream inputStream) throws IOException { + LOG.entering(CLASS_NAME, ""); + this.inputStream = inputStream; + } + + public void run() { + LOG.entering(CLASS_NAME, "run"); + // TODO: to check for the first 85 bytes of the response instead. + try { + while (!stopReaderThread && !connectionUpgraded) { + if (state == ConnectionStatus.ERRORED) { + throw new IllegalArgumentException("WebSocket Connection upgrade unsuccessful"); + } + String line = readLine(inputStream); + + line = line.trim(); + //get WebSocket-Protocol: header + if (line.startsWith(WEBSOCKET_EXTENSIONS)) { + extensions = line.substring(WEBSOCKET_EXTENSIONS.length() + 1).trim(); + continue; + } + if (line.startsWith(WEBSOCKET_PROTOCOL)) { + secProtocol = line.substring(WEBSOCKET_PROTOCOL.length() + 1).trim(); + continue; + } + + if (state != ConnectionStatus.COMPLETED) { + processLine(line); + } + if (state == ConnectionStatus.COMPLETED) { + //all headers processed, check all required headers for WebSocket handshake + connectionUpgraded = websocketAcceptReceived && upgradeReceived && connectionReceived; + if (connectionUpgraded) { + // Completely read the WebSocket upgraded response. Now + // start doing the WebSocket protocol + readyState = ReadyState.OPEN; + listener.opened(new OpenEvent(secProtocol)); + lastMessageTimestamp.set(System.currentTimeMillis()); + } + else { + throw new IllegalArgumentException("WebSocket Connection upgrade unsuccessful"); + } + break; + } + } //end of while loop + + if (!connectionUpgraded && !stopReaderThread) { + throw new IllegalArgumentException("WebSocket Connection upgrade unsuccessful"); + } + + FrameProcessor frameProcessor = new FrameProcessor(new FrameProcessorListener() { + @Override + public void messageReceived(ByteBuffer buffer, String messageType) { + // update timestamp that is used to record the timestamp of last received message + lastMessageTimestamp.set(System.currentTimeMillis()); + if (messageType == "TEXT" || messageType == "BINARY") { + // fire message event if readyState == OPEN + if (WebSocketDelegateImpl.this.readyState == ReadyState.OPEN) { + listener.messageReceived(new MessageEvent(buffer, null, null, messageType)); + } + } + else if (messageType == "PING") { + //PING received, send PONG + ByteBuffer frame = WsFrameEncodingSupport.rfc6455Encode(new WsMessage(buffer, Kind.PONG), new Random().nextInt()); + WebSocketDelegateImpl.this.send(frame); + + } + else if (messageType == "CLOSE") { + WebSocketDelegateImpl.this.wasClean = true; + if (buffer.remaining() < 2) { + WebSocketDelegateImpl.this.code = CLOSE_NO_STATUS; //no status code was actually present + } + else { + WebSocketDelegateImpl.this.code = buffer.getShort(); + + if (buffer.hasRemaining()) { + WebSocketDelegateImpl.this.reason = UTF8.decode(buffer).toString(); + } + } + if (WebSocketDelegateImpl.this.readyState == ReadyState.OPEN) { + //close frame received, echo close frame + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSING; + buffer.flip(); + WsMessage message = new WsMessage(buffer, Kind.CLOSE); + ByteBuffer frame = WsFrameEncodingSupport.rfc6455Encode(message, new Random().nextInt()); + WebSocketDelegateImpl.this.send(frame); + } + if (WebSocketDelegateImpl.this.readyState == ReadyState.CONNECTING) { + WebSocketDelegateImpl.this.readyState = ReadyState.CLOSING; + } + + } + else { + //unknown type + throw new IllegalArgumentException("Unknown message type: " + messageType); + } + } + }); + + Exception exception = null; + + try { + for (;;) { + if (stopReaderThread) { + LOG.fine("SocketReader: Stopping reader thread; closing socket"); + break; + } + + if (!frameProcessor.process(inputStream)) { + LOG.fine("SocketReader: end of stream"); + break; + } + } + } + catch (Exception ex) { + // ex.printStackTrace(); + exception = ex; + } + finally { + handleClose(exception); + } + } + catch (Exception e) { + LOG.log(Level.FINE, "SocketReader: " + e.getMessage(), e); + listener.errorOccurred(new ErrorEvent(e)); + } + } + + private void handleClose(Exception ex) { + WebSocketDelegateImpl.this.handleClose(ex); + } + + private String readLine(InputStream reader) throws Exception { + ByteBuffer input = ByteBuffer.allocate(512); + int ch; + while ((ch = reader.read()) != -1) { + if (!IoBufferUtil.canAccomodate(input, 1)) { + // expand in 512 bytes chunks + input = IoBufferUtil.expandBuffer(input, 512); + } + if (ch == '\n') { + input.put((byte)0x00); + input.flip(); + return new String(input.array()); + } + input.put((byte)ch); + } + return ""; + } + + private void processLine(String line) throws Exception { + LOG.entering(CLASS_NAME, "processLine", line); + + switch (state) { + case START: + if (line.equals(HTTP_101_MESSAGE)) { + state = ConnectionStatus.STATUS_101_READ; + } + else { + String s = "WebSocket upgrade failed: " + line; + LOG.severe(s); + state = ConnectionStatus.ERRORED; + listener.errorOccurred(new ErrorEvent(new IllegalStateException(s))); + } + break; + case STATUS_101_READ: + if (line == null || (line.length() == 0)) { + //end of header, set to Completed + state = ConnectionStatus.COMPLETED; + } + else if (line.indexOf(UPGRADE_HEADER) == 0) { + upgradeReceived = UPGRADE_VALUE.equalsIgnoreCase(line.substring(UPGRADE_HEADER_LENGTH)); + } + else if (line.equals(CONNECTION_MESSAGE)) { + connectionReceived = true; + } + else if (line.indexOf(WEBSOCKET_ACCEPT) == 0) { + String hashedKey = AcceptHash(websocketKey); + websocketAcceptReceived = hashedKey.equals(line.substring(WEBSOCKET_ACCEPT.length() + 1).trim()); + } + break; + case COMPLETED: + break; + } + } + + /** + * Compute the Sec-WebSocket-Accept key (RFC-6455) + * + * @param key + * @return + * @throws NoSuchAlgorithmException + * @throws Exception + */ + public String AcceptHash(String key) throws NoSuchAlgorithmException { + String input = key + WEBSOCKET_GUID; + + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + + byte[] hash = sha1.digest(input.getBytes());; + return Base64Util.encode(ByteBuffer.wrap(hash)); + } + } +} + diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateListener.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateListener.java new file mode 100644 index 0000000..fdfdfa1 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateListener.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import org.kaazing.gateway.client.transport.AuthenticateEvent; +import org.kaazing.gateway.client.transport.CloseEvent; +import org.kaazing.gateway.client.transport.ErrorEvent; +import org.kaazing.gateway.client.transport.MessageEvent; +import org.kaazing.gateway.client.transport.OpenEvent; +import org.kaazing.gateway.client.transport.RedirectEvent; + +public interface WebSocketDelegateListener { + + void authenticationRequested(AuthenticateEvent authenticateEvent); + void opened(OpenEvent event); + void redirected(RedirectEvent redirectEvent); + void messageReceived(MessageEvent messageEvent); + void closed(CloseEvent event); + void errorOccurred(ErrorEvent event); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsFrameEncodingSupport.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsFrameEncodingSupport.java new file mode 100644 index 0000000..0ba998d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsFrameEncodingSupport.java @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.nio.ByteBuffer; + + +public class WsFrameEncodingSupport { + + /** + * Encode WebSocket message as a single frame, with the provided masking value applied. + */ + public static ByteBuffer rfc6455Encode(WsMessage message, int maskValue) { + final boolean mask = true; + + boolean fin = true; // TODO continued frames? + + ByteBuffer buf = message.getBytes(); + + int remaining = buf.remaining(); + + int offset = 2 + (mask ? 4 : 0) + calculateLengthSize(remaining); + + ByteBuffer b = ByteBuffer.allocate(offset + remaining); + + int start = b.position(); + + byte b1 = (byte) (fin ? 0x80 : 0x00); + byte b2 = (byte) (mask ? 0x80 : 0x00); + + b1 = doEncodeOpcode(b1, message); + b2 |= lenBits(remaining); + + b.put(b1).put(b2); + + doEncodeLength(b, remaining); + + if (mask) { + b.putInt(maskValue); + } + //put message data + b.put(buf); + + if ( mask ) { + b.position(offset); + mask(b, maskValue); + } + + b.limit(b.position()); + b.position(start); + return b; + } + + protected static enum Opcode { + CONTINUATION(0), + TEXT(1), + BINARY(2), + RESERVED3(3), RESERVED4(4), RESERVED5(5), RESERVED6(6), RESERVED7(7), + CLOSE(8), + PING(9), + PONG(10); + + private int code; + + public int getCode() { + return this.code; + } + + Opcode(int code) { + this.code = code; + } + + static Opcode getById(int id) { + Opcode result = null; + for (Opcode temp : Opcode.values()) + { + if(id == temp.code) + { + result = temp; + break; + } + } + + return result; + } + } + + private static int calculateLengthSize(int length) { + if (length < 126) { + return 0; + } else if (length < 65535) { + return 2; + } else { + return 8; + } + } + + + /** + * Encode a WebSocket opcode onto a byte that might have some high bits set. + * + * @param b + * @param message + * @return + */ + private static byte doEncodeOpcode(byte b, WsMessage message) { + switch (message.getKind()) { + case TEXT: { + b |= Opcode.TEXT.getCode(); + break; + } + case BINARY: { + b |= Opcode.BINARY.getCode(); + break; + } + case PING: { + b |= Opcode.PING.getCode(); + break; + } + case PONG: { + b |= Opcode.PONG.getCode(); + break; + } + case CLOSE: { + b |= Opcode.CLOSE.getCode(); + break; + } + default: + throw new IllegalArgumentException("Unrecognized frame type: " + message.getKind()); + } + return b; + } + + private static byte lenBits(int length) { + if (length < 126) { + return (byte) length; + } else if (length < 65535) { + return (byte) 126; + } else { + return (byte) 127; + } + } + + private static void doEncodeLength(ByteBuffer buf, int length) { + if (length < 126) { + return; + } else if (length < 65535) { + // FIXME? unsigned short + buf.putShort((short) length); + } else { + // Unsigned long (should never have a message that large! really!) + buf.putLong((long) length); + } + } + + /** + * Performs an in-situ masking of the readable buf bytes. + * Preserves the position of the buffer whilst masking all the readable bytes, + * such that the masked bytes will be readable after this invocation. + * + * @param buf the buffer containing readable bytes to be masked. + * @param mask the mask to apply against the readable bytes of buffer. + */ + public static void mask(ByteBuffer buf, int mask) { + // masking is the same as unmasking due to the use of bitwise XOR. + unmask(buf, mask); + } + + + /** + * Performs an in-situ unmasking of the readable buf bytes. + * Preserves the position of the buffer whilst unmasking all the readable bytes, + * such that the unmasked bytes will be readable after this invocation. + * + * @param buf the buffer containing readable bytes to be unmasked. + * @param mask the mask to apply against the readable bytes of buffer. + */ + public static void unmask(ByteBuffer buf, int mask) { + byte b; + int remainder = buf.remaining() % 4; + int remaining = buf.remaining() - remainder; + int end = remaining + buf.position(); + + // xor a 32bit word at a time as long as possible + while (buf.position() < end) { + int plaintext = buf.getInt(buf.position()) ^ mask; + buf.putInt(plaintext); + } + + // xor the remaining 3, 2, or 1 bytes + switch (remainder) { + case 3: + b = (byte) (buf.get(buf.position()) ^ ((mask >> 24) & 0xff)); + buf.put(b); + b = (byte) (buf.get(buf.position()) ^ ((mask >> 16) & 0xff)); + buf.put(b); + b = (byte) (buf.get(buf.position()) ^ ((mask >> 8) & 0xff)); + buf.put(b); + break; + case 2: + b = (byte) (buf.get(buf.position()) ^ ((mask >> 24) & 0xff)); + buf.put(b); + b = (byte) (buf.get(buf.position()) ^ ((mask >> 16) & 0xff)); + buf.put(b); + break; + case 1: + b = (byte) (buf.get(buf.position()) ^ (mask >> 24)); + buf.put(b); + break; + case 0: + default: + break; + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsMessage.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsMessage.java new file mode 100644 index 0000000..ec8b995 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsMessage.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.transport.ws; + +import java.nio.ByteBuffer; + +public class WsMessage { + + public static enum Kind { + BINARY, TEXT, CLOSE, COMMAND, PING, PONG + }; + + private Kind kind; + + public Kind getKind() { + return kind; + } + + private final ByteBuffer buf; + + public WsMessage(ByteBuffer buf, Kind kind) { + this.buf = buf; + this.kind = kind; + } + + public ByteBuffer getBytes() { + return buf; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(getKind()); + builder.append(':'); + builder.append(' '); + builder.append(buf.array().toString()); + return builder.toString(); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/Base64Util.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/Base64Util.java new file mode 100644 index 0000000..03801ff --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/Base64Util.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +import java.util.logging.Logger; + + +/** + * Internal class. This class manages the Base64 encoding and decoding + */ +public class Base64Util { + +// @FlashNative +//import mx.utils.Base64Encoder; +// public String encodeBytes(ByteArray bytes) { +// Base64Encoder encoder=new Base64Encoder(); +// encoder.insertNewLines = false; +// encoder.encodeBytes(bytes); +// return encoder.drain(); +// } + + private static final String CLASS_NAME = Base64Util.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final byte[] INDEXED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".getBytes(); + private static final byte PADDING_BYTE = (byte) '='; + + @Deprecated + private Base64Util() { + LOG.entering(CLASS_NAME, ""); + } + + public static String encode(WrappedByteBuffer decoded) { + LOG.entering(CLASS_NAME, "encode", decoded); + + int decodedSize = decoded.remaining(); + int effectiveDecodedSize = ((decodedSize+2) / 3) * 3; + int decodedFragmentSize = decodedSize % 3; + + int encodedArraySize = effectiveDecodedSize / 3 * 4; + byte[] encodedArray = new byte[encodedArraySize]; + int encodedArrayPosition = 0; + + byte[] decodedArray = decoded.array(); + int decodedArrayOffset = decoded.arrayOffset(); + int decodedArrayPosition = decodedArrayOffset + decoded.position(); + int decodedArrayLimit = decodedArrayOffset + decoded.limit() - decodedFragmentSize; + + while (decodedArrayPosition < decodedArrayLimit) { + int byte0 = decodedArray[decodedArrayPosition++] & 0xff; + int byte1 = decodedArray[decodedArrayPosition++] & 0xff; + int byte2 = decodedArray[decodedArrayPosition++] & 0xff; + + encodedArray[encodedArrayPosition++] = INDEXED[(byte0 >> 2) & 0x3f]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte0 << 4) & 0x30) | ((byte1 >> 4) & 0x0f)]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte1 << 2) & 0x3c) | ((byte2 >> 6) & 0x03)]; + encodedArray[encodedArrayPosition++] = INDEXED[byte2 & 0x3f]; + } + + if (decodedFragmentSize == 1) { + int byte0 = decodedArray[decodedArrayPosition++] & 0xff; + + encodedArray[encodedArrayPosition++] = INDEXED[(byte0 >> 2) & 0x3f]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte0 << 4) & 0x30)]; + encodedArray[encodedArrayPosition++] = PADDING_BYTE; + encodedArray[encodedArrayPosition++] = PADDING_BYTE; + } + else if (decodedFragmentSize == 2) { + int byte0 = decodedArray[decodedArrayPosition++] & 0xff; + int byte1 = decodedArray[decodedArrayPosition++] & 0xff; + + encodedArray[encodedArrayPosition++] = INDEXED[(byte0 >> 2) & 0x3f]; + encodedArray[encodedArrayPosition++] = INDEXED[((byte0 << 4) & 0x30) | ((byte1 >> 4) & 0x0f)]; + encodedArray[encodedArrayPosition++] = INDEXED[(byte1 << 2) & 0x3c]; + encodedArray[encodedArrayPosition++] = PADDING_BYTE; + } + + return new String(encodedArray); + } + + public static WrappedByteBuffer decode(String encoded) { + LOG.entering(CLASS_NAME, "decode", encoded); + + if (encoded == null) { + return null; + } + + int length = encoded.length(); + if (length % 4 != 0) { + throw new IllegalArgumentException("Invalid Base64 Encoded String"); + } + + byte[] encodedArray = encoded.getBytes(); + byte[] decodedArray = new byte[(length / 4 * 3)]; + int decodedArrayOffset = 0; + int i = 0; + while (i < length) { + int char0 = encodedArray[i++]; + int char1 = encodedArray[i++]; + int char2 = encodedArray[i++]; + int char3 = encodedArray[i++]; + + int byte0 = mapped(char0); + int byte1 = mapped(char1); + int byte2 = mapped(char2); + int byte3 = mapped(char3); + + decodedArray[decodedArrayOffset++] = (byte) (((byte0 << 2) & 0xfc) | ((byte1 >> 4) & 0x03)); + if (char2 != PADDING_BYTE) { + decodedArray[decodedArrayOffset++] = (byte) (((byte1 << 4) & 0xf0) | ((byte2 >> 2) & 0x0f)); + if (char3 != PADDING_BYTE) { + decodedArray[decodedArrayOffset++] = (byte) (((byte2 << 6) & 0xc0) | (byte3 & 0x3f)); + } + } + } + return WrappedByteBuffer.wrap(decodedArray, 0, decodedArrayOffset); + } + + private static int mapped(int ch) { + if ((ch & 0x40) != 0) { + if ((ch & 0x20) != 0) { + // a(01100001)-z(01111010) -> 26-51 + assert (ch >= 'a'); + assert (ch <= 'z'); + return (ch - 71); + } else { + // A(01000001)-Z(01011010) -> 0-25 + assert (ch >= 'A'); + assert (ch <= 'Z'); + return (ch - 65); + } + } else if ((ch & 0x20) != 0) { + if ((ch & 0x10) != 0) { + if ((ch & 0x08) != 0 && (ch & 0x04) != 0) { + // =(00111101) -> 0 + assert (ch == '='); + return 0; + } else { + // 0(00110000)-9(00111001) -> 52-61 + assert (ch >= '0'); + assert (ch <= '9'); + return (ch + 4); + } + } else { + if ((ch & 0x04) != 0) { + // /(00101111) -> 63 + assert (ch == '/'); + return 63; + } else { + // +(00101011) -> 62 + assert (ch == '+'); + return 62; + } + } + } else { + LOG.warning("Invalid BASE64 string"); + throw new IllegalArgumentException("Invalid BASE64 string"); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/GenericURI.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/GenericURI.java new file mode 100644 index 0000000..e4886ab --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/GenericURI.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +import java.net.URI; +import java.net.URISyntaxException; + +public abstract class GenericURI { + + protected URI uri; + + protected GenericURI(String location) throws URISyntaxException { + this(new URI(location)); + } + + protected GenericURI(URI uri) throws URISyntaxException { + this.uri = uri; + validateScheme(); + } + + abstract protected boolean isValidScheme(String scheme); + + private void validateScheme() throws URISyntaxException { + String scheme = getScheme(); + if (!isValidScheme(scheme)) { + throw new URISyntaxException(uri.toString(), "Invalid scheme"); + } + } + + abstract protected T duplicate(URI uri); + + public T replacePath(String path) { + return duplicate(URIUtils.replacePath(uri, path)); + } + + public T addQueryParameter(String newParam) { + String queryParams = uri.getQuery(); + if (queryParams == null) { + queryParams = newParam; + } + else { + queryParams += "&" + newParam; + } + + return duplicate(URIUtils.replaceQueryParameters(uri, queryParams)); + } + + public URI getURI() { + return uri; + } + + public String getHost() { + return uri.getHost(); + } + + public int getPort() { + return uri.getPort(); + } + + public String getScheme() { + return uri.getScheme(); + } + + public String getPath() { + return uri.getPath(); + } + + public String getQuery() { + return uri.getQuery(); + } + + @Override + public String toString() { + return uri.toString(); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/HexUtil.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/HexUtil.java new file mode 100644 index 0000000..4ddfe39 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/HexUtil.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +public class HexUtil { + + private static final byte[] FROM_HEX = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, + 0, 10, 11, 12, 13, 14, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 10, 11, 12, 13, 14, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + }; + +// public static final WrappedByteBuffer decode(WrappedByteBuffer encoded) { +// LOG.entering(CLASS_NAME, "decode", encoded); +// if (encoded == null) { +// return null; +// } +// +// byte[] decodedArray = new byte[encoded.remaining() / 2]; +// int decodedArrayOffset = 0; +// +// byte[] encodedArray = encoded.array(); +// int encodedArrayOffset = encoded.arrayOffset(); +// int encodedArrayLimit = encodedArrayOffset + encoded.limit(); +// +// for (int i = encodedArrayOffset + encoded.position(); i < encodedArrayLimit;) { +// decodedArray[decodedArrayOffset++] = (byte)((FROM_HEX[encodedArray[i++]] << 4) | FROM_HEX[encodedArray[i++]]); +// } +// WrappedByteBuffer decoded = WrappedByteBuffer.wrap(decodedArray, 0, decodedArrayOffset); +// LOG.exiting(CLASS_NAME, "decode", decoded); +// return decoded; +// } + + public static byte[] decode(byte[] input ) { + if (input == null) { + return null; + } + byte[] output = new byte[input.length/2+1]; + int decodedArrayOffset = 0; + for(int i = 0; i < input.length; ) { + output[decodedArrayOffset++] = (byte)(FROM_HEX[input[i++]] << 4 | FROM_HEX[input[i++]]); + } + return output; + } + + /** + * Converts a hex string into an array of bytes. + * @param s the hex string with two characters for each byte + * @return the byte array corresponding to the hex string + */ + public static byte[] fromHex(String s) { + int len = s.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16)); + } + return data; + } + + /** + * Convert the byte array to an int starting from the given offset. + * + * @param b The byte array + * @param offset The array offset + * @return The integer + */ + public static int byteArrayToInt(byte[] b, int offset) { + int value = 0; + for (int i = 0; i < 4; i++) { + int shift = (4 - 1 - i) * 8; + value += (b[i + offset] & 0x000000FF) << shift; +} + return value; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/HttpURI.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/HttpURI.java new file mode 100644 index 0000000..479472c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/HttpURI.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +import java.net.URI; +import java.net.URISyntaxException; + + +/** + * URI with guarantee to be a valid, non-null, http or https URI + */ +public class HttpURI extends GenericURI { + + @Override + protected boolean isValidScheme(String scheme) { + return "http".equals(scheme) || "https".equals(scheme); + } + + public HttpURI(String location) throws URISyntaxException { + this(new URI(location)); + } + + HttpURI(URI uri) throws URISyntaxException { + super(uri); + } + + protected HttpURI duplicate(URI uri) { + try { + return new HttpURI(uri); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + public boolean isSecure() { + return "https".equals(uri.getScheme()); + } + + public static HttpURI replaceScheme(GenericURI location, String scheme) throws URISyntaxException { + return HttpURI.replaceScheme(location.getURI(), scheme); + } + + public static HttpURI replaceScheme(URI location, String scheme) throws URISyntaxException { + URI uri = URIUtils.replaceScheme(location, scheme); + return new HttpURI(uri); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/StringUtils.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/StringUtils.java new file mode 100644 index 0000000..a7b2023 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/StringUtils.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Map; + +public class StringUtils { + private static final Map BASIC_ESCAPE = new HashMap(); + + static { + BASIC_ESCAPE.put("\"", """); // " - double-quote + BASIC_ESCAPE.put("&", "&"); // & - ampersand + BASIC_ESCAPE.put("<", "<"); // < - less-than + BASIC_ESCAPE.put(">", ">"); // > - greater-than + }; + + public static byte[] getUtf8Bytes(String input) { + if (input == null) + return null; + try { + return input.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Strip a String of it's ISO control characters. + * + * @param value The String that should be stripped. + * @return {@code String} A new String instance with its hexadecimal control characters replaced by a space. Or the + * unmodified String if it does not contain any ISO control characters. + */ + public static String stripControlCharacters(String rawValue) { + if (rawValue == null) { + return null; + } + + String value = replaceEntities(rawValue); + + boolean hasControlChars = false; + for (int i = value.length() - 1; i >= 0; i--) { + if (Character.isISOControl(value.charAt(i))) { + hasControlChars = true; + break; + } + } + + if (!hasControlChars) { + return value; + } + + StringBuilder buf = new StringBuilder(value.length()); + int i = 0; + + // Skip initial control characters (i.e. left trim) + for (; i < value.length(); i++) { + if (!Character.isISOControl(value.charAt(i))) { + break; + } + } + + // Copy non control characters and substitute control characters with + // a space. The last control characters are trimmed. + boolean suppressingControlChars = false; + for (; i < value.length(); i++) { + if (Character.isISOControl(value.charAt(i))) { + suppressingControlChars = true; + continue; + } else { + if (suppressingControlChars) { + suppressingControlChars = false; + buf.append(' '); + } + buf.append(value.charAt(i)); + } + } + + return buf.toString(); + } + + private static String replaceEntities(String value) { + if (value == null) { + return null; + } + + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + String c = String.valueOf(Character.toChars(Character.codePointAt(value, i))); + CharSequence escapedEntity = BASIC_ESCAPE.get(c); + + if (escapedEntity == null) { + sb.append(c); + } else { + sb.append(escapedEntity); + } + } + + return sb.toString(); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/URIUtils.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/URIUtils.java new file mode 100644 index 0000000..f4f577d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/URIUtils.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +import java.net.URI; +import java.net.URISyntaxException; + +public class URIUtils { + + public static URI replaceScheme(String location, String scheme) { + try { + return replaceScheme(new URI(location), scheme); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URI/Scheme: replacing scheme with "+scheme+" for "+location); + } + } + + public static URI replaceScheme(URI uri, String scheme) { + try { + String location = uri.toString(); + int index = location.indexOf("://"); + return new URI(scheme + location.substring(index)); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URI/Scheme: replacing scheme with "+scheme+" for "+uri); + } + } + + public static URI replacePath(URI uri, String path) { + try { + return new URI(uri.getScheme(), uri.getAuthority(), path, uri.getQuery(), uri.getFragment()); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URI/Scheme: replacing path with '"+path+"' for "+uri); + } + } + + public static URI replaceQueryParameters(URI uri, String queryParams) { + try { + return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), queryParams, uri.getFragment()); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URI/Scheme: replacing query parameters with '"+queryParams+"' for "+uri); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/WrappedByteBuffer.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/WrappedByteBuffer.java new file mode 100644 index 0000000..7d01c66 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/WrappedByteBuffer.java @@ -0,0 +1,1247 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util; + +import java.nio.BufferUnderflowException; +import java.nio.ByteOrder; +import java.nio.charset.Charset; + +/** + * WrappedByteBuffer provides an automatically expanding byte buffer for ByteSocket. + */ +public class WrappedByteBuffer { + /* Invariant: 0 <= mark <= position <= limit <= capacity */ + + static int INITIAL_CAPACITY = 128; + + java.nio.ByteBuffer _buf; + private final int _minimumCapacity; + private boolean autoExpand = true; + private boolean _isBigEndian = true; + + /** + * The order property indicates the endianness of multibyte integer types in the buffer. + * Defaults to ByteOrder.BIG_ENDIAN; + */ + public WrappedByteBuffer() { + this(INITIAL_CAPACITY); + } + + WrappedByteBuffer(int capacity) { + this(java.nio.ByteBuffer.allocate(capacity)); + } + + public WrappedByteBuffer(byte[] bytes) { + this(bytes, 0, bytes.length); + } + + WrappedByteBuffer(byte[] bytes, int offset, int length) { + this(java.nio.ByteBuffer.wrap(bytes, offset, length)); + } + + public WrappedByteBuffer(java.nio.ByteBuffer buf) { + _buf = buf; + _minimumCapacity = buf.capacity(); + } + + public java.nio.ByteBuffer getNioByteBuffer() { + return _buf; + } + + /** + * Sets whether the buffer is auto-expanded when bytes are added at the limit. + */ + public final WrappedByteBuffer setAutoExpand(boolean autoExpand) { + this.autoExpand = autoExpand; + return this; + } + + /** + * Return true if the buffer is auto-expanded when bytes are added at the limit. + * + * @return whether the buffer is auto-expanded. + */ + public final boolean isAutoExpand() { + return autoExpand; + } + + /** + * Return underlying byte array offset + * + * @return underlying byte array offset + */ + public final int arrayOffset() { + int ao = _buf.arrayOffset(); + return ao; + } + + /** + * Return underlying byte array + * + * @return returns the contents of the buffer as a byte array + */ + public final byte[] array() { + byte[] a = _buf.array(); + return a; + } + + /** + * Allocates a new WrappedByteBuffer instance. + * + * @param capacity + * the maximum buffer capacity + * + * @return the allocated WrappedByteBuffer + */ + public static WrappedByteBuffer allocate(int capacity) { + return new WrappedByteBuffer(capacity); + } + + /** + * Wraps a byte array as a new WrappedByteBuffer instance. + * + * @param bytes + * an array of byte-sized numbers + * + * @return the bytes wrapped as a WrappedByteBuffer + */ + public static WrappedByteBuffer wrap(byte[] bytes) { + return new WrappedByteBuffer(bytes); + } + + /** + * Wraps a byte array as a new WrappedByteBuffer instance. + * + * @param bytes + * an array of byte-sized numbers + * + * @return the bytes wrapped as a WrappedByteBuffer + */ + public static WrappedByteBuffer wrap(byte[] bytes, int offset, int length) { + return new WrappedByteBuffer(bytes, offset, length); + } + + /** + * Wraps a java.nio.ByteBuffer as a new WrappedByteBuffer instance. + * + * @param bytes + * an array of byte-sized numbers + * + * @return the bytes wrapped as a WrappedByteBuffer + */ + public static WrappedByteBuffer wrap(java.nio.ByteBuffer in) { + return new WrappedByteBuffer(in); + } + + /** + * Returns the ByteOrder of the buffer + * + * @return + */ + public ByteOrder order() { + ByteOrder bo = _buf.order(); + return bo; + } + + /** + * Set the byte order for this buffer + * + * @param o ByteOrder + * + * @return the ByteOrder specified + * + * @deprecated Will return WrappedByteBuffer in future releases to match java.nio.ByteBuffer + */ + public ByteOrder order(ByteOrder o) { + // TODO: In the next release, return WrappedByteBuffer - use return _buf.order(o) + _isBigEndian = "BIG_ENDIAN".equals(o.toString()); + _buf.order(o); + return o; + } + + /** + * Marks a position in the buffer. + * + * @return the buffer + * + * @see WrappedByteBuffer#reset + */ + public WrappedByteBuffer mark() { + _buf.mark(); + return this; + } + + /** + * Resets the buffer position using the mark. + * + * @throws Exception + * if the mark is invalid + * + * @return the buffer + * + * @see WrappedByteBuffer#mark + */ + public WrappedByteBuffer reset() { + _buf = (java.nio.ByteBuffer) _buf.reset(); + return this; + } + + private static final int max(int a, int b) { + return a > b ? a : b; + } + + /** + * Compacts the buffer by removing leading bytes up to the buffer position, and decrements the limit and position values + * accordingly. + * + * @return the buffer + */ + public WrappedByteBuffer compact() { + int remaining = remaining(); + int capacity = capacity(); + + if (capacity == 0) { + return this; + } + + if (remaining <= capacity >>> 2 && capacity > _minimumCapacity) { + int newCapacity = capacity; + int minCapacity = max(_minimumCapacity, remaining << 1); + for (;;) { + if (newCapacity >>> 1 < minCapacity) { + break; + } + newCapacity >>>= 1; + } + + newCapacity = max(minCapacity, newCapacity); + + if (newCapacity == capacity) { + if (_buf.remaining() == 0) { + _buf.position(0); + _buf.limit(_buf.capacity()); + } + else { + java.nio.ByteBuffer dup = _buf.duplicate(); + _buf.position(0); + _buf.limit(_buf.capacity()); + _buf.put(dup); + } + return this; + } + + // Shrink and compact: + // // Save the state. + ByteOrder bo = order(); + + // // Sanity check. + if (remaining > newCapacity) { + throw new IllegalStateException("The amount of the remaining bytes is greater than " + "the new capacity."); + } + + // // Reallocate. + java.nio.ByteBuffer oldBuf = _buf; + java.nio.ByteBuffer newBuf = java.nio.ByteBuffer.allocate(newCapacity); + newBuf.put(oldBuf); + _buf = newBuf; + + // // Restore the state. + _buf.order(bo); + } else { + _buf.compact(); + } + + return this; + } + + /** + * Duplicates the buffer by reusing the underlying byte array but with independent position, limit and capacity. + * + * @return the duplicated buffer + */ + public WrappedByteBuffer duplicate() { + return WrappedByteBuffer.wrap(_buf.duplicate()); + } + + /** + * Fills the buffer with a repeated number of zeros. + * + * @param size + * the number of zeros to repeat + * + * @return the buffer + */ + public WrappedByteBuffer fill(int size) { + _autoExpand(size); + while (size-- > 0) { + _buf.put((byte) 0); + } + return this; + } + + /** + * Fills the buffer with a specific number of repeated bytes. + * + * @param b + * the byte to repeat + * @param size + * the number of times to repeat + * + * @return the buffer + */ + public WrappedByteBuffer fillWith(byte b, int size) { + _autoExpand(size); + while (size-- > 0) { + _buf.put(b); + } + return this; + } + + /** + * Returns the index of the specified byte in the buffer. + * + * @param b + * the byte to find + * + * @return the index of the byte in the buffer, or -1 if not found + */ + public int indexOf(byte b) { + if (_buf.hasArray()) { + byte[] array = _buf.array(); + int arrayOffset = _buf.arrayOffset(); + int startAt = arrayOffset + position(); + int endAt = arrayOffset + limit(); + + for (int i = startAt; i < endAt; i++) { + if (array[i] == b) { + return i - arrayOffset; + } + } + return -1; + } else { + int startAt = _buf.position(); + int endAt = _buf.limit(); + + for (int i = startAt; i < endAt; i++) { + if (_buf.get(i) == b) { + return i; + } + } + return -1; + } + } + + /** + * Puts a single byte number into the buffer at the current position. + * + * @param v + * the single-byte number + * + * @return the buffer + */ + public WrappedByteBuffer put(byte v) { + _autoExpand(1); + _buf.put(v); + return this; + } + + /** + * Puts segment of a single-byte array into the buffer at the current position. + * + * @param v + * the source single-byte array + * @offset + * the start position of source array + * @length + * the length to put into WrappedByteBuffer + * @return the buffer + */ + public WrappedByteBuffer put(byte[] v, int offset, int length) { + _autoExpand(length); + for (int i = 0; i < length; i++) { + _buf.put(v[offset + i]); + } + return this; + } + + /** + * Puts a single byte number into the buffer at the specified index. + * + * @param index the index + * @param v the byte + * + * @return the buffer + */ + public WrappedByteBuffer putAt(int index, byte v) { + _checkForWriteAt(index, 1); + _buf.put(index, v); + return this; + } + + /** + * Puts a unsigned single-byte number into the buffer at the current position. + * + * @param v the unsigned byte as an int + * + * @return the buffer + */ + public WrappedByteBuffer putUnsigned(int v) { + byte b = (byte)(v & 0xFF); + return this.put(b); + } + + /** + * Puts an unsigned single byte into the buffer at the specified position. + * + * @param v the unsigned byte as an int + * + * @return the buffer + */ + public WrappedByteBuffer putUnsignedAt(int index, int v) { + _checkForWriteAt(index, 1); + byte b = (byte)(v & 0xFF); + return this.putAt(index, b); + } + + /** + * Puts a two-byte short into the buffer at the current position. + * + * @param v the two-byte short value + * + * @return the buffer + */ + public WrappedByteBuffer putShort(short v) { + _autoExpand(2); + _buf.putShort(v); + return this; + } + + /** + * Puts a two-byte short into the buffer at the specified index. + * + * @param index the index + * @param v the two-byte short + * + * @return the buffer + */ + public WrappedByteBuffer putShortAt(int index, short v) { + _checkForWriteAt(index, 2); + _buf.putShort(index, v); + return this; + } + + /** + * Puts a two-byte unsigned short into the buffer at the current position. + * + * @param v the two-byte short value + * + * @return the buffer + */ + public WrappedByteBuffer putUnsignedShort(int v) { + this.putShort((short)(v & 0xFFFF)); + return this; + } + + /** + * Puts an unsigned two-byte unsigned short into the buffer at the position specified. + * + * @param index the index + * @param v the short value + * + * @return the buffer + */ + public WrappedByteBuffer putUnsignedShortAt(int index, int v) { + _checkForWriteAt(index, 2); + this.putShortAt(index, (short)(v & 0xFFFF)); + return this; + } + + /** + * Puts a four-byte int into the buffer at the current position. + * + * @param v the four-byte int + * + * @return the buffer + */ + public WrappedByteBuffer putInt(int v) { + _autoExpand(4); + _buf.putInt(v); + return this; + } + + /** + * Puts a four-byte int into the buffer at the specified index. + * + * @param index the index + * @param v the four-byte int + * + * @return the buffer + */ + public WrappedByteBuffer putIntAt(int index, int v) { + _checkForWriteAt(index, 4); + _buf.putInt(index, v); + return this; + } + + /** + * Puts a four-byte array into the buffer at the current position. + * + * @param v the four-byte int + * + * @return the buffer + */ + public WrappedByteBuffer putUnsignedInt(long value) { + this.putInt((int)value & 0xFFFFFFFF); + return this; + } + + /** + * Puts an unsigned four-byte array into the buffer at the specified index. + * + * @param index the index + * @param v the four-byte int + * + * @return the buffer + */ + public WrappedByteBuffer putUnsignedIntAt(int index, long value) { + _checkForWriteAt(index, 4); + this.putIntAt(index, (int)value & 0xFFFFFFFF); + return this; + } + + /** + * Puts an eight-byte long into the buffer at the current position. + * + * @param v the eight-byte long + * + * @return the buffer + */ + public WrappedByteBuffer putLong(long v) { + _autoExpand(8); + _buf.putLong(v); + return this; + } + + /** + * Puts an eight-byte long into the buffer at the specified index. + * + * @param index the index + * @param v the eight-byte long + * + * @return the buffer + */ + public WrappedByteBuffer putLongAt(int index, long v) { + _checkForWriteAt(index, 8); + _buf.putLong(index, v); + return this; + } + + /** + * Puts a string into the buffer at the current position, using the character set to encode the string as bytes. + * + * @param v + * the string + * @param cs + * the character set + * + * @return the buffer + */ + public WrappedByteBuffer putString(String v, Charset cs) { + java.nio.ByteBuffer strBuf = cs.encode(v); + _autoExpand(strBuf.limit()); + _buf.put(strBuf); + return this; + } + + /** + * Puts a string into the buffer at the specified index, using the character set to encode the string as bytes. + * + * @param fieldSize + * the width in bytes of the prefixed length field + * @param v + * the string + * @param cs + * the character set + * + * @return the buffer + */ + public WrappedByteBuffer putPrefixedString(int fieldSize, String v, Charset cs) { + if (fieldSize == 0) { + return this; + } + boolean utf16 = cs.name().startsWith("UTF-16"); + + if (utf16 && (fieldSize == 1)) { + throw new IllegalArgumentException("fieldSize is not even for UTF-16 character set"); + } + + java.nio.ByteBuffer strBuf = cs.encode(v); + _autoExpand(fieldSize + strBuf.limit()); + + int len = strBuf.remaining(); + switch (fieldSize) { + case 1: + put((byte) len); + break; + case 2: + putShort((short) len); + break; + case 4: + putInt(len); + break; + default: + throw new IllegalArgumentException("Illegal argument, field size should be 1,2 or 4 and fieldSize is: " + fieldSize); + } + + _buf.put(strBuf); + return this; + } + + /** + * Puts a single-byte array into the buffer at the current position. + * + * @param v + * the single-byte array + * + * @return the buffer + */ + public WrappedByteBuffer putBytes(byte[] b) { + _autoExpand(b.length); + _buf.put(b); + return this; + } + + /** + * Puts a single-byte array into the buffer at the specified index. + * + * @param index the index + * @param v the single-byte array + * + * @return the buffer + */ + public WrappedByteBuffer putBytesAt(int index, byte[] b) { + _checkForWriteAt(index, b.length); + int pos = _buf.position(); + _buf.position(index); + _buf.put(b, 0, b.length); + _buf.position(pos); + return this; + } + + /** + * Puts a buffer into the buffer at the current position. + * + * @param v the WrappedByteBuffer + * + * @return the buffer + */ + public WrappedByteBuffer putBuffer(WrappedByteBuffer v) { + _autoExpand(v.remaining()); + _buf.put(v._buf); + return this; + } + + /** + * Puts a buffer into the buffer at the specified index. + * + * @param index the index + * @param v the WrappedByteBuffer + * + * @return the buffer + */ + public WrappedByteBuffer putBufferAt(int index, WrappedByteBuffer v) { + // TODO: I believe this method incorrectly moves the position! + // Can't change it without a code analysis - and this is a public API! + int pos = _buf.position(); + _buf.position(index); + _buf.put(v._buf); + _buf.position(pos); + return this; + } + + /** + * Returns a single byte from the buffer at the current position. + * + * @return the byte + */ + public byte get() { + _checkForRead(1); + byte v = _buf.get(); + return v; + } + + /** + * @see java.nio.ByteBuffer#get(byte[], int, int) + */ + public WrappedByteBuffer get(byte[] dst, int offset, int length) { + _checkForRead(length); + for (int i = 0; i < length; i++) { + dst[offset + i] = _buf.get(); + } + return this; + } + + /** + * @see java.nio.ByteBuffer#get(byte[], int, int) + */ + public WrappedByteBuffer get(byte[] dst) { + return get(dst, 0, dst.length); + } + + /** + * Returns a byte from the buffer at the specified index. + * + * @param index the index + * + * @return the byte + */ + public byte getAt(int index) { + _checkForReadAt(index,1); + byte v = _buf.get(index); + return v; + } + + /** + * Returns an unsigned byte from the buffer at the current position. + * + * @return the unsigned byte as an int + */ + public int getUnsigned() { + _checkForRead(1); + int val = ((int) (_buf.get() & 0xff)); + return val; + } + /** + * Returns an unsigned byte from the buffer at the specified index. + * + * @param index the index + * + * @return the unsigned byte as an int + */ + public int getUnsignedAt(int index) { + _checkForReadAt(index, 1); + int val = ((int) (_buf.get(index) & 0xff)); + return val; + } + /** + * Returns a byte array of length size from the buffer from current position + * @param size + * @return a new byte array with bytes read from the buffer + */ + public byte[] getBytes(int size){ + _checkForRead(size); + byte[] dst = new byte[size]; + _buf.get(dst, 0, size); + return dst; + } + /** + * Returns a byte array of length size from the buffer starting from the specified position. + * @param index the index + * @param size the size of the buffer to be returned + * @return a new byte array with bytes read from the buffer + */ + public byte[] getBytesAt(int index,int size){ + _checkForReadAt(index,size); + byte[] dst = new byte[size]; + int i=0; + int j=index; + while (i INITIAL_CAPACITY) ? expectedRemaining : INITIAL_CAPACITY); + java.nio.ByteBuffer newBuffer = java.nio.ByteBuffer.allocate(newCapacity); + _buf.flip(); + newBuffer.put(_buf); + _buf = newBuffer; + } + } + + return this; + } + + private void _autoExpandAt(int i, int expectedRemaining) { + if (autoExpand) { + expandAt(i, expectedRemaining); + } + } + + private void _autoExpand(int expectedRemaining) { + if (autoExpand) { + expand(expectedRemaining); + } + } + + private void _checkForRead(int expected) { + int end = _buf.position() + expected; + if (end > _buf.limit()) { + throw new BufferUnderflowException(); + } + } + + private void _checkForReadAt(int index, int expected) { + int end = index + expected; + if (index < 0 || end > _buf.limit()) { + throw new IndexOutOfBoundsException(); + } + } + + private void _checkForWriteAt(int index, int expected) { + int end = index + expected; + if (index < 0 || end > _buf.limit()) { + throw new IndexOutOfBoundsException(); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/gateway/client/util/auth/LoginHandlerProvider.java b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/auth/LoginHandlerProvider.java new file mode 100644 index 0000000..51abf8d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/gateway/client/util/auth/LoginHandlerProvider.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.gateway.client.util.auth; + +import org.kaazing.net.auth.LoginHandler; + +/** + * An internal marker interface to mark implementations of challenge handlers + * that may in fact provide access to Login Handlers. + * + */ +public interface LoginHandlerProvider { + + /** + * Get the login handler associated with this challenge handler. + * A login handler is used to assist in obtaining credentials to respond to challenge requests. + * + * @return a login handler to assist in providing credentials, or {@code null} if none has been established yet. + */ + public LoginHandler getLoginHandler(); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/URLFactory.java b/cloudsdk/src/main/java/org/kaazing/net/URLFactory.java new file mode 100644 index 0000000..2c8891b --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/URLFactory.java @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kaazing.net; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; + +/** + * {@link URLFactory} supports static methods to instantiate URL objects that + * support custom protocols/schemes. Since {@link URL} by default only + * guarantees protocol handlers for + *

    + * {@code + * http, https, ftp, file, and jar + * } + *

    + * and the {@link URLStreamHandlerFactory} registration is not extensible, + * URLFactory will allow application developers to create URL objects for + * protocols such as + *

    + * {@code + * ws, wse, wsn, wss, wse+ssl + * } + *

    + * like this: + *

    + * {@code + * URL url = URLFactory.createURL("ws://:/"); + * } + */ +public final class URLFactory { + private static final Map _factories; + + private URLFactory() { + + } + + static { + Class clazz = URLStreamHandlerFactorySpi.class; + ServiceLoader loader = ServiceLoader.load(clazz); + _factories = new HashMap(); + + for (URLStreamHandlerFactorySpi factory : loader) { + Collection protocols = factory.getSupportedProtocols(); + + if (protocols != null && !protocols.isEmpty()) { + for (String protocol : protocols) { + _factories.put(protocol, factory); + } + } + } + } + + /** + * Creates a URL object from the String representation. + * + * @param spec the String to parse as a URL. + * @return URL representing the passed in String + * @throws MalformedURLException if no protocol is specified, or an + * unknown protocol is found, or spec is null + */ + public static URL createURL(String spec) throws MalformedURLException { + return createURL(null, spec); + } + + /** + * Creates a URL by parsing the given spec within a specified context. The + * new URL is created from the given context URL and the spec argument as + * described in RFC2396 "Uniform Resource Identifiers : Generic Syntax" : + *

    + * {@code + * ://?# + * } + *

    + * The reference is parsed into the scheme, authority, path, query and + * fragment parts. If the path component is empty and the scheme, authority, + * and query components are undefined, then the new URL is a reference to + * the current document. Otherwise, the fragment and query parts present in + * the spec are used in the new URL. + *

    + * If the scheme component is defined in the given spec and does not match + * the scheme of the context, then the new URL is created as an absolute URL + * based on the spec alone. Otherwise the scheme component is inherited from + * the context URL. + *

    + * If the authority component is present in the spec then the spec is + * treated as absolute and the spec authority and path will replace the + * context authority and path. If the authority component is absent in the + * spec then the authority of the new URL will be inherited from the context. + *

    + * If the spec's path component begins with a slash character "/" then the + * path is treated as absolute and the spec path replaces the context path. + *

    + * Otherwise, the path is treated as a relative path and is appended to the + * context path, as described in RFC2396. Also, in this case, the path is + * canonicalized through the removal of directory changes made by + * occurrences of ".." and ".". + *

    + * For a more detailed description of URL parsing, refer to RFC2396. + * + * @param context the context in which to parse the specification + * @param spec the String to parse as a URL + * @return URL created using the spec within the specified context + * @throws MalformedURLException if no protocol is specified, or an unknown + * protocol is found, or spec is null. + */ + public static URL createURL(URL context, String spec) + throws MalformedURLException { + if ((spec == null) || (spec.trim().length() == 0)) { + return new URL(context, spec); + } + + String protocol = URI.create(spec).getScheme(); + URLStreamHandlerFactory factory = _factories.get(protocol); + + // If there is no URLStreamHandlerFactory registered for the + // scheme/protocol, then we just use the regular URL constructor. + if (factory == null) { + return new URL(context, spec); + } + + // If there is a URLStreamHandlerFactory associated for the + // scheme/protocol, then we create a URLStreamHandler. And, then use + // then use the URLStreamHandler to create a URL. + URLStreamHandler handler = factory.createURLStreamHandler(protocol); + return new URL(context, spec, handler); + } + + /** + * Creates a URL from the specified protocol name, host name, and file name. + * The default port for the specified protocol is used. + *

    + * This method is equivalent to calling the four-argument method with + * the arguments being protocol, host, -1, and file. No validation of the + * inputs is performed by this method. + * + * @param protocol the name of the protocol to use + * @param host the name of the host + * @param file the file on the host + * @return URL created using specified protocol, host, and file + * @throws MalformedURLException if an unknown protocol is specified + */ + public static URL createURL(String protocol, String host, String file) + throws MalformedURLException { + return createURL(protocol, host, -1, file); + + } + + /** + * Creates a URL from the specified protocol name, host name, port number, + * and file name. + *

    + * No validation of the inputs is performed by this method. + * + * @param protocol the name of the protocol to use + * @param host the name of the host + * @param port the port number + * @param file the file on the host + * @return URL created using specified protocol, host, and file + * @throws MalformedURLException if an unknown protocol is specified + */ + public static URL createURL(String protocol, + String host, + int port, + String file) throws MalformedURLException { + URLStreamHandlerFactory factory = _factories.get(protocol); + + // If there is no URLStreamHandlerFactory registered for the + // scheme/protocol, then we just use the regular URL constructor. + if (factory == null) { + return new URL(protocol, host, port, file); + } + + // If there is a URLStreamHandlerFactory associated for the + // scheme/protocol, then we create a URLStreamHandler. And, then use + // then use the URLStreamHandler to create a URL. + URLStreamHandler handler = factory.createURLStreamHandler(protocol); + return new URL(protocol, host, port, file, handler); + } +} + diff --git a/cloudsdk/src/main/java/org/kaazing/net/URLStreamHandlerFactorySpi.java b/cloudsdk/src/main/java/org/kaazing/net/URLStreamHandlerFactorySpi.java new file mode 100644 index 0000000..4d5ce9d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/URLStreamHandlerFactorySpi.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kaazing.net; + +import java.net.URLStreamHandlerFactory; +import java.util.Collection; + +/** + * This a Service Provider Interface (SPI) class. Implementors + * can create extensions of this class. At runtime, the extensions will be + * instantiated using the {@link ServiceLoader} APIs using the META-INF/services + * mechanism in the {@link URLFactory} implementation. + *

    + * {@link URLStreamHandlerFactory} is a singleton that is registered using the + * static method + * {@link URL#setURLStreamHandlerFactory(URLStreamHandlerFactory)}. Also, + * the {@link URL} objects can only be created for the following protocols: + * -- http, https, file, ftp, and jar. In order to install protocol handlers + * for other protocols, one has to hijack or override the system's singleton + * {@link URLStreamHandlerFactory} instance with a custom implementation. The + * objective of this class is to make the {@link URLStreamHandler} registration + * for other protocols such as ws, wss, etc. feasible without hijacking the + * system's {@link URLStreamHandlerFactory}. + *

    + */ +public abstract class URLStreamHandlerFactorySpi implements URLStreamHandlerFactory { + + /** + * Returns a list of supported protocols. This can be used to instantiate + * appropriate {@link URLStreamHandler} objects based on the protocol. + * + * @return list of supported protocols + */ + public abstract Collection getSupportedProtocols(); +} + diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/BasicChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/auth/BasicChallengeHandler.java new file mode 100644 index 0000000..21ee8a9 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/BasicChallengeHandler.java @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + + +/** + * Challenge handler for Basic authentication as defined in RFC 2617. + *

    + * This BasicChallengeHandler can be loaded and instantiated using + * {@link BasicChallengeHandler#create()}, and registered + * at a location using {@link DispatchChallengeHandler#register(String, ChallengeHandler)}. + *

    + * In addition, one can install general and realm-specific {@link LoginHandler} objects onto this + * {@link BasicChallengeHandler} to assist in handling challenges associated + * with any or specific realms. This can be achieved using {@link #setLoginHandler(LoginHandler)} and + * {@link #setRealmLoginHandler(String, LoginHandler)} methods. + *

    + * The following example loads an instance of a {@link BasicChallengeHandler}, sets a login + * handler onto it and registers the basic handler at a URI location. In this way, all attempts to access + * that URI for which the server issues "Basic" challenges are handled by the registered {@link BasicChallengeHandler}. + *

    + * {@code
    + * LoginHandler loginHandler = new LoginHandler() {
    + *    public PasswordAuthentication getCredentials() {
    + *        return new PasswordAuthentication("global", "credentials".toCharArray());
    + *    }
    + * };
    + * BasicChallengeHandler    bch = BasicChallengeHandler.create();
    + * DispatchChallengeHandler dch = DispatchChallengeHandler.create();
    + * WebSocketFactory     wsFactory = WebSocketFactory.createWebSocketFactory();
    + * wsFactory.setDefaultChallengeHandler(dch.register("ws://localhost:8001", bch.setLoginHandler(loginHandler)));
    + * }
    + * 
    + * + * @see RFC 2616 - HTTP 1.1 + * @see RFC 2617 Section 2 - Basic Authentication + */ +public abstract class BasicChallengeHandler extends ChallengeHandler { + + /** + * Creates a new instance of {@link BasicChallengeHandler} using the + * {@link ServiceLoader} API with the implementation specified under + * META-INF/services. + * + * @return BasicChallengeHandler + */ + public static BasicChallengeHandler create() { + return create(BasicChallengeHandler.class); + } + + /** + * Creates a new instance of {@link BasicChallengeHandler} with the + * specified {@link ClassLoader} using the {@link ServiceLoader} API with + * the implementation specified under META-INF/services. + * + * @param classLoader ClassLoader to be used to instantiate + * @return BasicChallengeHandler + */ + public static BasicChallengeHandler create(ClassLoader classLoader) { + return create(BasicChallengeHandler.class, classLoader); + } + + /** + * Set a Login Handler to be used if and only if a challenge request has + * a realm parameter matching the provided realm. + * + * @param realm the realm upon which to apply the {@code loginHandler}. + * @param loginHandler the login handler to use for the provided realm. + */ + public abstract void setRealmLoginHandler(String realm, LoginHandler loginHandler); + + + /** + * Provide a general (non-realm-specific) login handler to be used in association with this challenge handler. + * The login handler is used to assist in obtaining credentials to respond to any Basic + * challenge requests when no realm-specific login handler matches the realm parameter of the request (if any). + * + * @param loginHandler a login handler for credentials. + */ + public abstract BasicChallengeHandler setLoginHandler(LoginHandler loginHandler); + + /** + * Get the general (non-realm-specific) login handler associated with this challenge handler. + * A login handler is used to assist in obtaining credentials to respond to challenge requests. + * + * @return a login handler to assist in providing credentials, or {@code null} if none has been established yet. + */ + public abstract LoginHandler getLoginHandler() ; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeHandler.java new file mode 100644 index 0000000..b57578b --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeHandler.java @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + +import java.util.ServiceLoader; + +/** + * A ChallengeHandler is responsible for producing responses to authentication challenges. + *

    + * When an attempt to access a protected URI is made, the server responsible for serving the resource + * may respond with a challenge, indicating that credentials need be provided before access to the + * resource is granted. The specific type of challenge is indicated in a HTTP header called "WWW-Authenticate". + * This response and that header are converted into a {@link ChallengeRequest} and sent to a + * registered ChallengeHandler for authentication challenge responses. The {@link ChallengeResponse} credentials + * generated by a registered challenge handler are included in a replay of the original HTTP request to the server, which + * (assuming the credentials are sufficient) allows access to the resource. + *

    + * Public subclasses of ChallengeHandler can be loaded and instantiated using {@link ChallengeHandlers}, + * and registered to handle server challenges for specific URI locations + * using {@link DispatchChallengeHandler#register(String, ChallengeHandler)}. + *

    + * Any challenge responses to requests matching the registered location may be handled by the registered {@link ChallengeHandler} + * as long as {@link #canHandle(ChallengeRequest)} returns true. In the case where multiple registered challenge handlers + * could respond to a challenge request, the earliest challenge handler registered at the most specific location matching + * the protected URI is selected. + * + */ +public abstract class ChallengeHandler { + /** + * Creates a new instance of the sub-class of {@link ChallengeHandler} using + * the {@link ServiceLoader} API with the implementation specified under + * META-INF/services. + * + * @param T sub-class of ChallengeHandler + * @param clazz Class object of the sub-type + * @return ChallengeHandler + */ + protected static T create(Class clazz) { + return load0(clazz, ServiceLoader.load(clazz)); + } + + /** + * Creates a new instance of the sub-class of {@link ChallengeHandler} with + * specified {@link ClassLoader} using the {@link ServiceLoader} API with + * the implementation specified under META-INF/services. + * + * @param T sub-type of ChallengeHandler + * @param clazz Class object of the sub-type + * @param classLoader ClassLoader to be used to instantiate + * @return ChallengeHandler + */ + protected static T create(Class clazz, + ClassLoader clazzLoader) { + return load0(clazz, ServiceLoader.load(clazz, clazzLoader)); + } + + /** + * Can the presented challenge be potentially handled by this challenge handler? + * + * @param challengeRequest a challenge request object containing a challenge + * @return true iff this challenge handler could potentially respond meaningfully to the challenge. + */ + public abstract boolean canHandle(ChallengeRequest challengeRequest); + + /** + * Handle the presented challenge by creating a challenge response or returning {@code null}. + * This responsibility is usually achieved + * by using the associated {@link LoginHandler} to obtain user credentials, and transforming those credentials + * into a {@link ChallengeResponse}. + *

    + * When it is not possible to create a {@link ChallengeResponse}, this method MUST return {@code null}. + * + * @param challengeRequest a challenge object + * @return a challenge response object or {@code null} if no response is possible. + */ + public abstract ChallengeResponse handle(ChallengeRequest challengeRequest); + + // ----------------------- Private Methods ------------------------------- + + private static T load0(Class clazz, + ServiceLoader serviceLoader) { + for (ChallengeHandler challengeHandler: serviceLoader) { + Class c = challengeHandler.getClass(); + if ((clazz != null) && clazz.isAssignableFrom(c)) { + try { + return clazz.cast(c.newInstance()); + } catch (InstantiationException e) { + String s = "Failed to instantiate class " + c; + throw new RuntimeException(s,e); + } catch (IllegalAccessException e) { + String s = "Failed to access and instantiate class " + c; + throw new RuntimeException(s, e); + } + } + } + return null; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeRequest.java b/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeRequest.java new file mode 100644 index 0000000..077a9e7 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeRequest.java @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + +/** + * An immutable object representing the challenge presented by the server when the client accessed + * the URI represented by a location. + *

    + * According to RFC 2617, + *

    + *     challenge   = auth-scheme 1*SP 1#auth-param
    + * 
    + * so we model the authentication scheme and parameters in this class. + *

    + * This class is also responsible for detecting and adapting the {@code Application Basic} + * and {@code Application Negotiate} authentication schemes into their {@code Basic} and {@code Negotiate} + * counterpart authentication schemes. + */ +public class ChallengeRequest { + + private static final String APPLICATION_PREFIX = "Application "; + + + String location; + String authenticationScheme; + String authenticationParameters; + + + /** + * Constructor from the protected URI location triggering the challenge, + * and an entire server-provided 'WWW-Authenticate:' string. + * + * @param location the protected URI location triggering the challenge + * @param challenge an entire server-provided 'WWW-Authenticate:' string + */ + public ChallengeRequest(String location, String challenge) { + if ( location == null ) { + throw new NullPointerException("location"); + } + if ( challenge == null ) { + return; + } + + if (challenge.startsWith(APPLICATION_PREFIX)) { + challenge = challenge.substring(APPLICATION_PREFIX.length()); + } + + this.location = location; + this.authenticationParameters = null; + + int space = challenge.indexOf(' '); + if ( space == -1 ) { + this.authenticationScheme = challenge; + } else { + this.authenticationScheme = challenge.substring(0, space); + if ( challenge.length() > (space+1)) { + this.authenticationParameters = challenge.substring(space+1); + } + } + } + + /** + * Return the protected URI the access of which triggered this challenge as a {@code String}. + *

    + * For some authentication schemes, the production of a response to the challenge may require + * access to the location of the URI triggering the challenge. + * + * @return the protected URI the access of which triggered this challenge as a {@code String} + */ + public String getLocation() { + return location; + } + + /** + * Return the authentication scheme with which the server is challenging. + * + * @return the authentication scheme with which the server is challenging. + */ + public String getAuthenticationScheme() { + return authenticationScheme; + } + + /** + * Return the string after the space separator, not including the authentication scheme nor the space itself, + * or {@code null} if no such string exists. + * + * @return the string after the space separator, not including the authentication scheme nor the space itself, + * or {@code null} if no such string exists. + */ + public String getAuthenticationParameters() { + return authenticationParameters; + } + + + @Override + public String toString() { + return String.format("%s: %s: %s %s", super.toString(), location, authenticationScheme, authenticationParameters); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeResponse.java b/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeResponse.java new file mode 100644 index 0000000..354a272 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeResponse.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + +import java.util.Arrays; + +/** + * A challenge response contains a character array representing the response to the server, + * and a reference to the next challenge handler to handle any further challenges for the request. + * + */ +public class ChallengeResponse { + + private char[] credentials; + private ChallengeHandler nextChallengeHandler; + + /** + * Constructor from a set of credentials to send to the server in an 'Authorization:' header + * and the next challenge handler responsible for handling any further challenges for the request. + * + * @param credentials a set of credentials to send to the server in an 'Authorization:' header + * @param nextChallengeHandler the next challenge handler responsible for handling any further challenges for the request. + */ + public ChallengeResponse(char[] credentials, ChallengeHandler nextChallengeHandler) { + this.credentials = credentials; + this.nextChallengeHandler = nextChallengeHandler; + } + + /** + * Return the next challenge handler responsible for handling any further challenges for the request. + * + * @return the next challenge handler responsible for handling any further challenges for the request. + */ + public ChallengeHandler getNextChallengeHandler() { + return nextChallengeHandler; + } + + /** + * Return a set of credentials to send to the server in an 'Authorization:' header. + * + * @return a set of credentials to send to the server in an 'Authorization:' header. + */ + public char[] getCredentials() { + return credentials; + } + + /** + * Establish the credentials for this response. + * + * @param credentials the credentials to be used for this challenge response. + */ + public void setCredentials(char[] credentials) { + this.credentials = credentials; + } + + /** + * Establish the next challenge handler responsible for handling any further challenges for the request. + * + * @param nextChallengeHandler the next challenge handler responsible for handling any further challenges for the request. + */ + public void setNextChallengeHandler(ChallengeHandler nextChallengeHandler) { + this.nextChallengeHandler = nextChallengeHandler; + } + + /** + * Clear the credentials of this response. + *

    + * Calling this method once the credentials have been communicated to the network layer + * protects credentials in memory. + */ + public void clearCredentials() { + if (getCredentials() != null) { + Arrays.fill(getCredentials(), (char) 0); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/DispatchChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/auth/DispatchChallengeHandler.java new file mode 100644 index 0000000..c319343 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/DispatchChallengeHandler.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + +/** + * A DispatchChallengeHandler is responsible for dispatching challenge requests + * to appropriate challenge handlers when challenges + * arrive from specific URI locations in challenge responses. + *

    + * This allows clients to use specific challenge handlers to handle specific + * types of challenges at different URI locations. + *

    + */ +public abstract class DispatchChallengeHandler extends ChallengeHandler { + + /** + * Creates a new instance of {@link DispatchChallengeHandler} using the + * {@link ServiceLoader} API with the implementation specified under + * META-INF/services. + * + * @return DispatchChallengeHandler + */ + public static DispatchChallengeHandler create() { + return create(DispatchChallengeHandler.class); + } + + /** + * Creates a new instance of {@link DispatchChallengeHandler} with the + * specified {@link ClassLoader} using the {@link ServiceLoader} API with + * the implementation specified under META-INF/services. + * + * @param classLoader ClassLoader to be used to instantiate + * @return DispatchChallengeHandler + */ + public static DispatchChallengeHandler create(ClassLoader classLoader) { + return create(DispatchChallengeHandler.class, classLoader); + } + + @Override + public abstract boolean canHandle(ChallengeRequest challengeRequest) ; + + @Override + public abstract ChallengeResponse handle(ChallengeRequest challengeRequest) ; + + /** + * Register a challenge handler to respond to challenges at one or more locations. + *

    + * When a challenge response is received for a protected URI, the {@code locationDescription} + * matches against elements of the protected URI; if a match is found, one + * consults the challenge handler(s) registered at that {@code locationDescription} to find + * a challenge handler suitable to respond to the challenge. + *

    + * A {@code locationDescription} comprises a username, password, host, port and paths, + * any of which can be wild-carded with the "*" character to match any number of request URIs. + * If no port is explicitly mentioned in a {@code locationDescription}, a default port will be inferred + * based on the scheme mentioned in the location description, according to the following table: + * + * + * + * + * + * + *
    schemedefault portSample locationDescription
    http80foo.example.com or http://foo.example.com
    ws80foo.example.com or ws://foo.example.com
    https443https://foo.example.com
    wss443wss://foo.example.com
    + *

    + * The protocol scheme (e.g. http or ws) if present in {@code locationDescription} will not be used to + * match {@code locationDescription} with the protected URI, because authentication challenges are + * implemented on top of one of the HTTP/s protocols always, whether one is initiating web socket + * connections or regular HTTP connections. That is to say for example, the locationDescription {@code "foo.example.com"} + * matches both URIs {@code http://foo.example.com} and {@code ws://foo.example.com}. + *

    + * Some examples of {@code locationDescription} values with wildcards are: + *

      + *
    1. {@code *}/ -- matches all requests to any host on port 80 (default port), with no user info or path specified.
    2. + *
    3. {@code *.hostname.com:8000} -- matches all requests to port 8000 on any sub-domain of {@code hostname.com}, + * but not {@code hostname.com} itself.
    4. + *
    5. {@code server.hostname.com:*}/{@code *} -- matches all requests to a particular server on any port on any path but not the empty path.
    6. + *
    + * @param locationDescription the (possibly wild-carded) location(s) at which to register a handler. + * @param challengeHandler the challenge handler to register at the location(s). + * + * @return a reference to this challenge handler for chained calls + */ + public abstract DispatchChallengeHandler register(String locationDescription, ChallengeHandler challengeHandler) ; + + /** + * If the provided challengeHandler is registered at the provided location, clear that + * association such that any future challenge requests matching the location will never + * be handled by the provided challenge handler. + *

    + * If no such location or challengeHandler registration exists, this method silently succeeds. + * @param locationDescription the exact location description at which the challenge handler was originally registered + * @param challengeHandler the challenge handler to de-register. + * + * @return a reference to this object for chained call support + */ + public abstract DispatchChallengeHandler unregister(String locationDescription, ChallengeHandler challengeHandler) ; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/LoginHandler.java b/cloudsdk/src/main/java/org/kaazing/net/auth/LoginHandler.java new file mode 100644 index 0000000..c5aae4c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/LoginHandler.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + +import java.net.PasswordAuthentication; + +/** + * A login handler is responsible for obtaining credentials from an arbitrary + * source. + *

    + * Login Handlers can be associated with one or more {@link ChallengeHandler} + * objects, to ensure that when a Challenge Handler requires credentials for a {@link ChallengeResponse}, + * the work is delegated to a {@link LoginHandler}. + *

    + * At client configuration time, a {@link LoginHandler} can be associated with a {@link ChallengeHandler} as follows: + *

    + * {@code
    + *
    + * BasicChallengeHandler basicChallengeHandler = ChallengeHandlerLoader.load(BasicChallengeHandler.class);
    + * LoginHandler loginHandler = new LoginHandler() {
    + *    public PasswordAuthentication getCredentials() {
    + *        // Obtain credentials in an application-specific manner
    + *    }
    + * }
    + * basicChallengeHandler.setLoginHandler(loginHandler);
    + *    
    + * }
    + * 
    + */ +public abstract class LoginHandler { + + /** + * Default constructor. + */ + protected LoginHandler() { + } + + /** + * Gets the password authentication credentials from an arbitrary source. + * @return the password authentication obtained. + */ + public abstract PasswordAuthentication getCredentials(); + +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/NegotiableChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/auth/NegotiableChallengeHandler.java new file mode 100644 index 0000000..a7ea216 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/NegotiableChallengeHandler.java @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + + +import java.util.Collection; + +/** + * A NegotiableChallengeHandler can be used to directly respond to + * "Negotiate" challenges, and in addition, can be used indirectly in conjunction + * with a {@link NegotiateChallengeHandler} + * to assist in the construction of a challenge response using object identifiers. + * + * @see RFC 4178 Section 4.2.1 for details + * about how the supported object identifiers contribute towards the initial context token in the challenge response. + * + *

    + * + */ +public abstract class NegotiableChallengeHandler extends ChallengeHandler { + + /** + * Creates a new instance of {@link NegotiableChallengeHandler} using the + * {@link ServiceLoader} API with the implementation specified under + * META-INF/services. + * + * @return NegotiableChallengeHandler + */ + public static NegotiableChallengeHandler create() { + return create(NegotiableChallengeHandler.class); + } + + /** + * Creates a new instance of {@link NegotiableChallengeHandler} with the + * specified {@link ClassLoader} using the {@link ServiceLoader} API with + * the implementation specified under META-INF/services. + * + * @param classLoader ClassLoader to be used to instantiate + * @return NegotiableChallengeHandler + */ + public static NegotiableChallengeHandler create(ClassLoader classLoader) { + return create(NegotiableChallengeHandler.class, classLoader); + } + + /** + * Default constructor. + */ + protected NegotiableChallengeHandler() { + } + + /** + * Return a collection of string representations of object identifiers + * supported by this challenge handler implementation, in dot-separated notation. + * For example, {@literal 1.3.5.1.5.2}. + * + * @return a collection of string representations of object identifiers + * supported by this challenge handler implementation. + */ + public abstract Collection getSupportedOids(); + + /** + * Provide a general login handler to be used in association with this challenge handler. + * The login handler is used to assist in obtaining credentials to respond to any + * challenge requests when this challenge handler handles the request. + * + * @param loginHandler a login handler for credentials. + * + * @return this challenge handler object, to support chained calls + */ + public abstract NegotiableChallengeHandler setLoginHandler(LoginHandler loginHandler); + + /** + * Get the general login handler associated with this challenge handler. + * A login handler is used to assist in obtaining credentials to respond to challenge requests. + * + * @return a login handler to assist in providing credentials, or {@code null} if none has been established yet. + */ + public abstract LoginHandler getLoginHandler() ; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/auth/NegotiateChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/auth/NegotiateChallengeHandler.java new file mode 100644 index 0000000..cdbe45e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/auth/NegotiateChallengeHandler.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.auth; + +/** + * A Negotiate Challenge Handler handles initial empty "Negotiate" challenges from the + * server. It uses other "candidate" challenger handlers to assemble an initial context token + * to send to the server, and is responsible for creating a challenge response that can delegate + * to the winning candidate. + *

    + * This NegotiateChallengeHandler can be loaded and instantiated using + * {@link #create()}, and registered at a location using + * {@link DispatchChallengeHandler#register(String, ChallengeHandler)}. + *

    + * In addition, one can register more specific {@link NegotiableChallengeHandler} objects with + * this initial {@link NegotiateChallengeHandler} to handle initial Negotiate challenges and subsequent challenges associated + * with specific Negotiation mechanism types / object identifiers. + *

    + * The following example establishes a Negotiation strategy at a specific URL location. + * We show the use of a {@link DispatchChallengeHandler} to register a {@link NegotiateChallengeHandler} at + * a specific location. The {@link NegotiateChallengeHandler} has a {@link NegotiableChallengeHandler} + * instance registered as one of the potential negotiable alternative challenge handlers. + *

    + * {@code
    + * LoginHandler someServerLoginHandler = ...
    + * NegotiateChallengeHandler  nch = NegotiateChallengeHandler.create();
    + * NegotiableChallengeHandler nblch = NegotiableChallengeHandler.create();
    + * DispatchChallengeHandler   dch = DispatchChallengeHandler.create();
    + * WebSocketFactory       wsFactory = WebSocketFactory.createWebSocketFactory();
    + * wsFactory.setDefaultChallengeHandler(dch.register("ws://some.server.com",
    + *                                     nch.register(nblch).setLoginHandler(someServerLoginHandler)
    + *                                     );
    + *             // could register more alternatives to negotiate amongst here.
    + *         )
    + * );
    + * }
    + * 
    + * + * @see RFC 2616 - HTTP 1.1 + * @see RFC 2617 - HTTP Authentication + */ +public abstract class NegotiateChallengeHandler extends ChallengeHandler { + + /** + * Creates a new instance of {@link NegotiateChallengeHandler} using the + * {@link ServiceLoader} API with the implementation specified under + * META-INF/services. + * + * @return NegotiateChallengeHandler + */ + public static NegotiateChallengeHandler create() { + return create(NegotiateChallengeHandler.class); + } + + /** + * Creates a new instance of {@link NegotiateChallengeHandler} with the + * specified {@link ClassLoader} using the {@link ServiceLoader} API with + * the implementation specified under META-INF/services. + * + * @param classLoader ClassLoader to be used to instantiate + * @return NegotiateChallengeHandler + */ + public static NegotiateChallengeHandler create(ClassLoader classLoader) { + return create(NegotiateChallengeHandler.class, classLoader); + } + + /** + * Register a candidate negotiable challenge handler that will be used to respond + * to an initial "Negotiate" server challenge and can then potentially be + * a winning candidate in the race to handle the subsequent server challenge. + * + * @param handler the mechanism-type-specific challenge handler. + * + * @return a reference to this handler, to support chained calls + */ + public abstract NegotiateChallengeHandler register(NegotiableChallengeHandler handler); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/http/HttpRedirectPolicy.java b/cloudsdk/src/main/java/org/kaazing/net/http/HttpRedirectPolicy.java new file mode 100644 index 0000000..e8b6df8 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/http/HttpRedirectPolicy.java @@ -0,0 +1,317 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.http; + +import java.net.URI; +import java.util.Comparator; + +/** + * Options for following HTTP redirect requests with response code 3xx. + */ +public enum HttpRedirectPolicy implements Comparator { + /** + * Do not follow HTTP redirects. + */ + NEVER() { + @Override + public int compare(URI current, URI redirect) { + if ((current == null) || (redirect == null)) { + String s = "Null URI passed in to compare()"; + throw new IllegalArgumentException(s); + } + + // URIs don't matter as this option indicates never to follow + // redirects. + return -1; + } + + @Override + public String toString() { + return "HttpRedirectOption.NEVER"; + } + }, + + /** + * Follow HTTP redirect requests always regardless of the origin, domain, etc. + */ + ALWAYS() { + @Override + public int compare(URI current, URI redirect) { + if ((current == null) || (redirect == null)) { + String s = "Null URI passed in to compare()"; + throw new IllegalArgumentException(s); + } + + // URIs don't matter as this option indicates always to follow + // redirects. + return 0; + } + + @Override + public String toString() { + return "HttpRedirectOption.ALWAYS"; + } + }, + + /** + * Follow HTTP redirect only if the redirected request is for the same + * origin. This implies that both the scheme/protocol and the + * authority should match between the current and the redirect URIs. + * Note that authority includes the hostname and the port. + */ + SAME_ORIGIN() { + @Override + public int compare(URI current, URI redirect) { + if ((current == null) || (redirect == null)) { + String s = "Null URI passed in to compare()"; + throw new IllegalArgumentException(s); + } + + // Two URIs have same origin if the protocol and authority + // are the same. + if (current.getScheme().equalsIgnoreCase(redirect.getScheme()) && + current.getAuthority().equalsIgnoreCase(redirect.getAuthority())) { + return 0; + } + + return -1; + } + + @Override + public String toString() { + return "HttpRedirectOption.SAME_ORIGIN"; + } + }, + + /** + * Follow HTTP redirect only if the redirected request is for the same + * domain. This implies that both the scheme/protocol and the + * hostname should match between the current and the redirect URIs. + *

    + * URIs that satisfy HttpRedirectPolicy.SAME_ORIGIN policy will implicitly + * satisfy HttpRedirectPolicy.SAME_DOMAIN policy. + *

    + * URIs with identical domains would be ws://production.example.com:8001 and + * ws://production.example.com:8002. + */ + SAME_DOMAIN() { + @Override + public int compare(URI current, URI redirect) { + if (HttpRedirectPolicy.SAME_ORIGIN.compare(current, redirect) == 0) { + // If the URIs have same origin, then they implicitly have same + // domain. + return 0; + } + + if ((current == null) || (redirect == null)) { + String s = "Null URI passed in to compare()"; + throw new IllegalArgumentException(s); + } + + // We should allow redirecting to a more secure scheme from a less + // secure scheme. For example, we should allow redirecting from + // ws -> wss. + String currScheme = current.getScheme(); + String newScheme = redirect.getScheme(); + if (newScheme.equalsIgnoreCase(currScheme) || + newScheme.contains(currScheme)) { + // Check if the host names are the same between the two URIs. + String currHost = current.getHost(); + String newHost = redirect.getHost(); + + if (currHost.equalsIgnoreCase(newHost)) { + return 0; + } + } + + return -1; + } + + @Override + public String toString() { + return "HttpRedirectOption.SAME_DOMAIN"; + } + }, + + /** + * Follow HTTP redirect only if the redirected request is for a peer-domain. + * This implies that both the scheme/protocol and the domain should + * match between the current and the redirect URIs. + *

    + * URIs that satisfy HttpRedirectPolicy.SAME_DOMAIN policy will implicitly + * satisfy HttpRedirectPolicy.PEER_DOMAIN policy. + *

    + * To determine if the two URIs have peer-domains, we do the following: + *

      + *
    • compute base-domain by removing the token before the first '.' in + * the hostname of the original URI and check if the hostname of the + * redirected URI ends with the computed base-domain + *
    • compute base-domain by removing the token before the first '.' in + * the hostname of the redirected URI and check if the hostname of the + * original URI ends with the computed base-domain + *
    + *

    + * If both the conditions are satisfied, then we conclude that the URIs are + * for peer-domains. However, if the host in the URI has no '.'(for eg., + * ws://localhost:8000), then we just use the entire hostname as the + * computed base-domain. + *

    + * If you are using this policy, it is recommended that the number of tokens + * in the hostname be atleast 2 + number_of_tokens(top-level-domain). For + * example, if the top-level-domain(TLD) is "com", then the URIs should have + * atleast 3 tokens in the hostname. So, ws://marketing.example.com:8001 and + * ws://sales.example.com:8002 are examples of URIs with peer-domains. Similarly, + * if the TLD is "co.uk", then the URIs should have atleast 4 tokens in the + * hostname. So, ws://marketing.example.co.uk:8001 and + * ws://sales.example.co.uk:8002 are examples of URIs with peer-domains. + */ + PEER_DOMAIN() { + @Override + public int compare(URI current, URI redirect) { + if (HttpRedirectPolicy.SAME_DOMAIN.compare(current, redirect) == 0) { + // If the domains are the same, then they are peers. + return 0; + } + + if ((current == null) || (redirect == null)) { + String s = "Null URI passed in to compare()"; + throw new IllegalArgumentException(s); + } + + // We should allow redirecting to a more secure scheme from a less + // secure scheme. For example, we should allow redirecting from + // ws -> wss. + String currScheme = current.getScheme(); + String newScheme = redirect.getScheme(); + if (newScheme.equalsIgnoreCase(currScheme) || + newScheme.contains(currScheme)) { + String currHost = current.getHost(); + String redirectHost = redirect.getHost(); + String currBaseDomain = getBaseDomain(currHost); + String redirectBaseDomain = getBaseDomain(redirectHost); + + if (currHost.endsWith(redirectBaseDomain) && + redirectHost.endsWith(currBaseDomain)) { + return 0; + } + } + + return -1; + } + + @Override + public String toString() { + return "HttpRedirectOption.PEER_DOMAIN"; + } + + // Get the base domain for a given hostname. For example, jms.kaazing.com + // will return kaazing.com. + private String getBaseDomain(String hostname) { + String[] tokens = hostname.split("\\."); + + if (tokens.length <= 2) { + return hostname; + } + + String baseDomain = ""; + for (int i = 1; i < tokens.length; i++) { + baseDomain += "." + tokens[i]; + } + + return baseDomain; + } + }, + + /** + * Follow HTTP redirect only if the redirected request is for child-domain + * or sub-domain of the original request. + *

    + * URIs that satisfy HttpRedirectPolicy.SAME_DOMAIN policy will implicitly + * satisfy HttpRedirectPolicy.SUB_DOMAIN policy. + *

    + * To determine if the domain of the redirected URI is sub-domain/child-domain + * of the domain of the original URI, we check if the hostname of the + * redirected URI ends with the hostname of the original URI. + *

    + * Domain of the redirected URI ws://benefits.hr.example.com:8002 is a + * sub-domain/child-domain of the domain of the original URI + * ws://hr.example.com:8001. Note that domain in ws://example.com:9001 is a + * sub-domain of the domain in ws://example.com:9001. + */ + SUB_DOMAIN() { + @Override + public int compare(URI current, URI redirect) { + if (HttpRedirectPolicy.SAME_DOMAIN.compare(current, redirect) == 0) { + // If the domains are the same, then one can be a sub-domain + // of the other. + return 0; + } + + if ((current == null) || (redirect == null)) { + String s = "Null URI passed in to compare()"; + throw new IllegalArgumentException(s); + } + + // We should allow redirecting to a more secure scheme from a less + // secure scheme. For example, we should allow redirecting from + // ws -> wss. + String currScheme = current.getScheme(); + String newScheme = redirect.getScheme(); + if (newScheme.equalsIgnoreCase(currScheme) || + newScheme.contains(currScheme)) { + // If the current host is gateway.example.com, and the new + // is child.gateway.example.com, then allow redirect. + String currHost = current.getHost(); + String newHost = redirect.getHost(); + + if (newHost.length() < currHost.length()) { + return -1; + } + + if (newHost.endsWith("." + currHost)) { + return 0; + } + } + + return -1; + } + + @Override + public String toString() { + return "HttpRedirectOption.SUB_DOMAIN"; + } + }; + + /** + * Returns 0, if the aspects of current and the redirected URIs match as per + * the option. Otherwise, -1 is returned. + * + * @param current URI of the current request + * @param redirect URI of the redirected request + * @return 0, for a successful match; otherwise -1 + */ + @Override + public abstract int compare(URI current, URI redirect); + + @Override + public abstract String toString(); +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/impl/auth/BasicChallengeResponseFactory.java b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/BasicChallengeResponseFactory.java new file mode 100644 index 0000000..765920f --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/BasicChallengeResponseFactory.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.impl.auth; + +import org.kaazing.gateway.client.util.Base64Util; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.auth.ChallengeResponse; + +import java.net.PasswordAuthentication; +import java.util.Arrays; + +public class BasicChallengeResponseFactory { + + public static ChallengeResponse create(PasswordAuthentication creds, ChallengeHandler nextChallengeHandler) { + String unencoded = String.format("%s:%s", creds.getUserName(), new String(creds.getPassword())); + String response = String.format("Basic %s", Base64Util.encode(WrappedByteBuffer.wrap(unencoded.getBytes()))); + Arrays.fill(creds.getPassword(), (char) 0); + return new ChallengeResponse(response.toCharArray(), nextChallengeHandler); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultBasicChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultBasicChallengeHandler.java new file mode 100644 index 0000000..3dfd062 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultBasicChallengeHandler.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.impl.auth; + + +import java.net.PasswordAuthentication; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.util.auth.LoginHandlerProvider; +import org.kaazing.net.auth.BasicChallengeHandler; +import org.kaazing.net.auth.ChallengeRequest; +import org.kaazing.net.auth.ChallengeResponse; +import org.kaazing.net.auth.LoginHandler; + +/** + * Challenge handler for Basic authentication. See RFC 2617. + */ +public class DefaultBasicChallengeHandler extends BasicChallengeHandler implements LoginHandlerProvider { + +// ------------------------------ FIELDS ------------------------------ + + private static final String CLASS_NAME = DefaultBasicChallengeHandler.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private Map loginHandlersByRealm = new ConcurrentHashMap(); + + @Override + public void setRealmLoginHandler(String realm, LoginHandler loginHandler) { + if ( realm == null) { + throw new NullPointerException("realm"); + } + if ( loginHandler == null ) { + throw new NullPointerException("loginHandler"); + } + + loginHandlersByRealm.put(realm, loginHandler); + } + + /** + * If specified, this login handler is responsible for assisting in the + * production of challenge responses. + */ + private LoginHandler loginHandler; + + /** + * Provide a login handler to be used in association with this challenge handler. + * The login handler is used to assist in obtaining credentials to respond to challenge requests. + * + * @param loginHandler a login handler for credentials. + */ + public BasicChallengeHandler setLoginHandler(LoginHandler loginHandler) { + this.loginHandler = loginHandler; + return this; + } + + /** + * Get the login handler associated with this challenge handler. + * A login handler is used to assist in obtaining credentials to respond to challenge requests. + * + * @return a login handler to assist in providing credentials, or {@code null} if none has been established yet. + */ + public LoginHandler getLoginHandler() { + return loginHandler; + } + + @Override + public boolean canHandle(ChallengeRequest challengeRequest) { + return challengeRequest != null && + "Basic".equals(challengeRequest.getAuthenticationScheme()); + } + + @Override + public ChallengeResponse handle(ChallengeRequest challengeRequest) { + + LOG.entering(CLASS_NAME, "handle", new String[]{challengeRequest.getLocation(), + challengeRequest.getAuthenticationParameters()}); + + if (challengeRequest.getLocation() != null) { + + + + // Start by using this generic Basic handler + LoginHandler loginHandler = getLoginHandler(); + + // Try to delegate to a realm-specific login handler if we can + String realm = RealmUtils.getRealm(challengeRequest); + if ( realm != null && loginHandlersByRealm.get(realm) != null) { + loginHandler = loginHandlersByRealm.get(realm); + } + LOG.finest("BasicChallengeHandler.getResponse: login handler = " + loginHandler); + if (loginHandler != null) { + PasswordAuthentication creds = loginHandler.getCredentials(); + if (creds != null && creds.getUserName() != null && creds.getPassword() != null) { + return BasicChallengeResponseFactory.create(creds, this); + } + } + } + return null; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultDispatchChallengeHandler.java b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultDispatchChallengeHandler.java new file mode 100644 index 0000000..9b35201 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultDispatchChallengeHandler.java @@ -0,0 +1,784 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.impl.auth; + +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.auth.ChallengeRequest; +import org.kaazing.net.auth.ChallengeResponse; +import org.kaazing.net.auth.DispatchChallengeHandler; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * The DefaultDispatchChallengeHandler is responsible for defining and using appropriate challenge handlers when challenges + * arrive from specific URI locations. This allows clients to use specific challenge handlers to handle specific + * types of challenges at different URI locations. + *

    + */ +public class DefaultDispatchChallengeHandler extends DispatchChallengeHandler { +// ------------------------------ FIELDS ------------------------------ + + static final String SCHEME_URI = "^(.*)://(.*)"; + static final Pattern SCHEME_URI_PATTERN = Pattern.compile(SCHEME_URI); + + enum UriElement { + HOST, + USERINFO, + PORT, + PATH + } + private Node rootNode; + + public void clear() { + rootNode = new Node(); + } + + @Override + public boolean canHandle(ChallengeRequest challengeRequest) { + return lookup(challengeRequest) != null; + } + + @Override + public ChallengeResponse handle(ChallengeRequest challengeRequest) { + ChallengeHandler challengeHandler = lookup(challengeRequest); + if (challengeHandler == null) { + return null; + } + return challengeHandler.handle(challengeRequest); + } + + + + +// --------------------------- CONSTRUCTORS --------------------------- + + public DefaultDispatchChallengeHandler() { + rootNode = new Node(); + } + + @Override + public DispatchChallengeHandler register(String locationDescription, ChallengeHandler challengeHandler) { + if (locationDescription == null || locationDescription.length() == 0) { + throw new IllegalArgumentException("Must specify a location to handle challenges upon."); + } + + if (challengeHandler == null) { + throw new IllegalArgumentException("Must specify a handler to handle challenges."); + } + + addChallengeHandlerAtLocation(locationDescription, challengeHandler); + return this; + } + + @Override + public DispatchChallengeHandler unregister(String locationDescription, ChallengeHandler challengeHandler) { + if (locationDescription == null || locationDescription.length() == 0) { + throw new IllegalArgumentException("Must specify a location to un-register challenge handlers upon."); + } + + if (challengeHandler == null) { + throw new IllegalArgumentException("Must specify a handler to un-register."); + } + + delChallengeHandlerAtLocation(locationDescription, challengeHandler); + + return this; + } + + private void delChallengeHandlerAtLocation(String locationDescription, ChallengeHandler challengeHandler) { + List> tokens = tokenize(locationDescription); + Node cursor = rootNode; + for (Token token : tokens) { + if (!cursor.hasChild(token.getName(), token.getKind())) { + return; // silently remove nothing + } else { + cursor = cursor.getChild(token.getName()); + } + } + cursor.removeValue(challengeHandler); + } + + private void addChallengeHandlerAtLocation(String locationDescription, ChallengeHandler challengeHandler) { + List> tokens = tokenize(locationDescription); + Node cursor = rootNode; + + for (Token token : tokens) { + if (!cursor.hasChild(token.getName(), token.getKind())) { + cursor = cursor.addChild(token.getName(), token.getKind()); + } else { + cursor = cursor.getChild(token.getName()); + } + } + cursor.appendValues(challengeHandler); + } + + + +// -------------------------- OTHER METHODS -------------------------- + /** + * Locate all challenge handlers to serve the given location. + * + * @param location a location + * @return a collection of {@link ChallengeHandler}s if found registered at a matching location or an empty list if none are found. + */ + public List lookup(String location) { + List result = Collections.emptyList(); + if (location != null) { + Node resultNode = findBestMatchingNode(location); + if (resultNode != null) { + return resultNode.getValues(); + } + } + return result; + } + + /** + * Locate a challenge handler factory to serve the given location and challenge type. + * + * + * @param challengeRequest A challenge string from the server. + * @return a challenge handler registered to handle the challenge at the location, + * or null if none could be found. + */ + ChallengeHandler lookup(ChallengeRequest challengeRequest) { + ChallengeHandler result = null; + String location = challengeRequest.getLocation(); + if (location != null) { + Node resultNode = findBestMatchingNode(location); + + // + // If we found an exact or wildcard match, try to find a handler + // for the requested challenge. + // + if (resultNode != null) { + List handlers = resultNode.getValues(); + if (handlers != null) { + for (ChallengeHandler challengeHandler : handlers) { + if (challengeHandler.canHandle(challengeRequest)) { + result = challengeHandler; + break; + } + } + } + } + } + return result; + } + + /** + * Return the Node corresponding to ("matching") a location, or null if none can be found. + * + * @param location the location at which to find a Node + * @return the Node corresponding to ("matching") a location, or null if none can be found. + */ + private Node findBestMatchingNode(String location) { + List> tokens = tokenize(location); + int tokenIdx = 0; + + return rootNode.findBestMatchingNode(tokens, tokenIdx); + } + + /** + * Tokenize a given string assuming it is like a URL. + * + * @param s the string to be parsed as a wildcard-able URI + * @return the array of tokens of URI parts. + * @throws IllegalArgumentException when the string cannot be parsed as a wildcard-able URI + */ + List> tokenize(String s) throws IllegalArgumentException { + if (s == null || s.length() == 0) { + return new ArrayList>(); + } + + // + // Make sure if a scheme is not specified, we default one before we parse as a URI. + // + if ( !SCHEME_URI_PATTERN.matcher(s).matches()) { + s = ("http://")+s; + } + + // + // Parse as a URI + // + URI uri = URI.create(s); + + // + // Detect what the scheme is, if any. + // + List> result = new ArrayList>(10); + String scheme = "http"; + if (uri.getScheme() != null) { + scheme = uri.getScheme(); + } + + + // + // A wildcard-ed hostname is parsed as an authority. + // + String host = uri.getHost(); + String parsedPortFromAuthority = null; + String parsedUserInfoFromAuthority = null; + String userFromAuthority = null; + String passwordFromAuthority = null; + if (host == null) { + String authority = uri.getAuthority(); + if (authority != null) { + host = authority; + int asteriskIdx = host.indexOf("@"); + if ( asteriskIdx >= 0) { + parsedUserInfoFromAuthority = host.substring(0, asteriskIdx); + host = host.substring(asteriskIdx+1); + int colonIdx = parsedUserInfoFromAuthority.indexOf(":"); + if ( colonIdx >= 0) { + userFromAuthority = parsedUserInfoFromAuthority.substring(0, colonIdx); + passwordFromAuthority = parsedUserInfoFromAuthority.substring(colonIdx+1); + } + } + int colonIdx = host.indexOf(":"); + if ( colonIdx >=0 ) { + parsedPortFromAuthority = host.substring(colonIdx + 1); + host = host.substring(0, colonIdx); + } + } else { + throw new IllegalArgumentException("Hostname is required."); + } + } + + // + // Split the host and reverse it for the tokenization. + // + List hostParts = Arrays.asList(host.split("\\.")); + Collections.reverse(hostParts); + for (String hostPart: hostParts) { + result.add(new Token(hostPart, UriElement.HOST)); + } + + if (parsedPortFromAuthority != null) { + result.add(new Token(parsedPortFromAuthority, UriElement.PORT)); + } else if (uri.getPort() > 0) { + result.add(new Token(String.valueOf(uri.getPort()), UriElement.PORT)); + } else if (getDefaultPort(scheme) > 0) { + result.add(new Token(String.valueOf(getDefaultPort(scheme)), UriElement.PORT)); + } + + + if ( parsedUserInfoFromAuthority != null ) { + if ( userFromAuthority != null) { + result.add(new Token(userFromAuthority, UriElement.USERINFO)); + } + if ( passwordFromAuthority != null ) { + result.add(new Token(passwordFromAuthority, UriElement.USERINFO)); + } + if ( userFromAuthority == null && passwordFromAuthority == null) { + result.add(new Token(parsedUserInfoFromAuthority, UriElement.USERINFO)); + } + } else if (uri.getUserInfo() != null) { + String userInfo = uri.getUserInfo(); + int colonIdx = userInfo.indexOf(":"); + if ( colonIdx >= 0) { + result.add(new Token(userInfo.substring(0, colonIdx), UriElement.USERINFO)); + result.add(new Token(userInfo.substring(colonIdx+1), UriElement.USERINFO)); + } else { + result.add(new Token(uri.getUserInfo(), UriElement.USERINFO)); + } + } + + if (isNotBlank(uri.getPath())) { + String path = uri.getPath(); + if (path.startsWith("/")) { + path = path.substring(1); + } + if (isNotBlank(path)) { + for (String p: path.split("/")) { + result.add(new Token(p, UriElement.PATH)); + } + } + } + return result; + } + + int getDefaultPort(String scheme) { + if ( defaultPortsByScheme.containsKey(scheme.toLowerCase())) { + return defaultPortsByScheme.get(scheme); + } else { + return -1; + } + } + + static Map defaultPortsByScheme = new HashMap(); + static { + defaultPortsByScheme.put("http", 80); + defaultPortsByScheme.put("ws", 80); + defaultPortsByScheme.put("wss", 443); + defaultPortsByScheme.put("https", 443); + } + + + private boolean isNotBlank(String s) { + return s != null && s.length() > 0; + } + + /** + * A Node instance has a kind, holds a list of {@link #values} (parameterized type instances), + * and a sub-tree of nodes called {@link #children}. It is used as a model for + * holding typed {@link org.kaazing.net.auth.ChallengeHandler} instances at "locations". + *

    + * {@link org.kaazing.net.auth.impl.DefaultDispatchChallengeHandler.Node} instances are mutable. Nodes have {@link #name}s. One can add children + * with distinct names, add {@link #values} and recall them. + *

    + * {@link org.kaazing.net.auth.impl.DefaultDispatchChallengeHandler.Node} instances are considered to be "wildcard" nodes when their name is equal + * to {@link #getWildcardChar()}. Nodes are considered to have a wildcard defined if they or + * any of their children are named {@link #getWildcardChar()}. Wildcard nodes are treated + * as matching one or multiple elements during {@link org.kaazing.net.auth.impl.DefaultDispatchChallengeHandler.Node searches}. + *

    + * The concept of a Node has the following restrictions in the following cases. + * An {@link IllegalArgumentException} will result in each case. + *

      + *
    1. One is not permitted to add values to the root node. Use a wildcard node instead.
    2. + *
    3. One is not permitted to create Node instances with null or empty names.
    4. + *
    + * + * @private + */ + static class Node> { + + /** + * The name of this Node instance. + * Must not be null or empty. + */ + private String name; + + /** + * The parameterized type instances. + * Optimized for fewer values per node. + */ + private List values = new ArrayList(3); + + /** + * An up-link to this Node instance's parent. + */ + private Node parent; + + /** + * An enumerated value representing the "kind" of this node. + */ + private E kind; + + /** + * The down-links to children sub-nodes of this node. + * Each link is accessed through the child Node's name. + * This means that child names must be unique. + */ + private Map> children = new LinkedHashMap>(); + + /** + * A method to access the wildcard character. + * Making this a method rather than a constant will allow + * this value to change without recompilation of client classes. + * @return the character assumed to be the wildcard character for this tree. + */ + public static String getWildcardChar() { + return "*"; + } + + /** + * Create a new root node, with a null name and parent. + */ + Node() { + this.name=null; + this.parent=null; + this.kind = null; + } + + + /** + * Create a new node with the provided name and parent node. + * @param name the name of the node instance to create. + * @param parent the parent of the new node to establish the new node's place in the tree. + * @param kind the kind of the node instance to create. + */ + private Node(String name, Node parent, E kind) { + this.name = name; + this.parent = parent; + this.kind = kind; + } + + /** + * Add a new node with the given name of the given kind to this node. + *

    + * If an existing child has that name, replace the existing named sub-tree with + * a new single node with the provided name. + * + * + * @param name the name of the new node + * @param kind the kind of the new node + * @return the freshly added Node, for chained calls if needed. + * @throws IllegalArgumentException if the name is null or empty + */ + public Node addChild(String name, E kind) { + if ( name == null || name.length() == 0) { + throw new IllegalArgumentException("A node may not have a null name."); + } + + Node result = new Node(name, this, kind); + children.put(name, result); + return result; + } + + /** + * Return whether this node has a child with the name and kind provided. + * + * + * @param name the name of the child node sought + * @param kind the kind of the child node sought + * @return true iff this instance has a child with that name and kind + */ + public boolean hasChild(String name, E kind) { + return null != getChild(name) && kind == getChild(name).getKind(); + } + + /** + * Return the child node instance corresponding to the provided name, + * or null if no such node can be found. + * + * + * @param name the name of the node sought + * @return the child node instance corresponding to the provided name, + * or null if no such node can be found. + */ + public Node getChild(String name) { + return children.get(name); + } + + /** + * Return the distance that this token is away from the root. + * @return 0 if this node is the root node, + * otherwise the number of nodes from this node to the root node including this node. + */ + public int getDistanceFromRoot() { + int result = 0; + Node cursor = this; + while (!cursor.isRootNode()) { + result++; + cursor = cursor.getParent(); + } + return result; + } + + /** + * Add the provided values to the current node. + * + * @param values the values to add to this node instance + * @throws IllegalArgumentException when attempting to add values to the root node. + */ + public void appendValues(T... values) { + if ( isRootNode() ) { + throw new IllegalArgumentException("Cannot set a values on the root node."); + } + if ( values != null ) { + this.values.addAll(Arrays.asList(values)); + } + } + + /** + * Remove the provided value from the current node. + * @param value the value to remove from this node instance. + */ + public void removeValue(T value) { + if ( isRootNode() ) { + return; + } + this.values.remove(value); + } + + /** + * Return the collection of stored values for this node instance. + * If no values have been stored, we return an empty list. + * @return the collection of stored values for this node instance; an empty list if none have been stored. + */ + public List getValues() { + return values; + } + + /** + * Returns whether this node instance contains any values. + * @return true iff values have been stored in this node. + */ + public boolean hasValues() { + return values != null && values.size()>0; + } + + /** + * Return a link to the parent instance of this node, or null + * when invoked on the root node. + * + * @return a link to the parent instance of this node, or null + * when invoked on the root node. + */ + public Node getParent() { + return parent; + } + + /** + * Return the enumerated value from E for this kind of this node, or null + * when invoked on the root node. + * + * @return the enumerated value from E for this kind of this node, or null + * when invoked on the root node. + */ + public E getKind() { + return this.kind; + } + + /** + * Is this node the root node? + * @return true iff this node instance is the root node. + */ + public boolean isRootNode() { + return this.parent == null; + } + + /** + * Return the name of this node. + * @return the name of this node. null iff this node is the root node. + */ + public String getName() { + return name; + } + + /** + * Has at least one child been added to this node instance explicitly? + * @return true iff one child been added to this node instance explicitly. + */ + public boolean hasChildren() { + return children != null && children.size() > 0; + } + + /** + * Return whether this node instance's name is equal to {@link #getWildcardChar()}. + * + * @return true iff this node instance's name is equal to {@link #getWildcardChar()}. + */ + public boolean isWildcard() { + return name!=null && name.equals(getWildcardChar()); + } + + + + boolean hasWildcardChild() { + return hasChildren() && children.keySet().contains(getWildcardChar()); + } + + /** + * Get a fully qualified name from the root node down to this node. + *

    + * Implemented as: Walk up to the root, gathering names, then emit a dot-separated list of names. + * Useful for debugging. + * + * @return a fully qualified name from the root node down to this node. + */ + String getFullyQualifiedName() { + StringBuilder b = new StringBuilder(); + List name = new ArrayList(); + Node cursor = this; + while (!cursor.isRootNode()) { + name.add(cursor.name); + cursor = cursor.parent; + } + Collections.reverse(name); + for(String s: name) { + b.append(s).append('.'); + } + + if ( b.length() >= 1 && b.charAt(b.length()-1) == '.') { + b.deleteCharAt(b.length()-1); + } + + return b.toString(); + } + + public List> getChildrenAsList() { + return new ArrayList>(children.values()); + } + + /** + * Find the best matching node with respect to the tokens underneath this node. + * @param tokens the tokenized location to query. + * @param tokenIdx the index into the tokens to commence matching at. + * @return the best matching node or {@code null} if no matching node could be found. + */ + Node findBestMatchingNode(List> tokens, int tokenIdx) { + List> matches = findAllMatchingNodes(tokens, tokenIdx); + + Node resultNode = null; + int score = 0; + for (Node node : matches) { + if (node.getDistanceFromRoot() > score) { + score = node.getDistanceFromRoot(); + resultNode = node; + } + } + return resultNode; + } + + /** + * Find all matching nodes with respect to the tokens underneath this node. + * @param tokens the tokenized location to query. + * @param tokenIdx the index into the tokens to commence matching at. + * @return a collection of all matching nodes, which may be empty if no matching nodes were found. + */ + private List> findAllMatchingNodes(List> tokens, int tokenIdx) { + List> result = new ArrayList>(); + + // + // Iterate over this node's children. + // + List> nodes = this.getChildrenAsList(); + for (Node node : nodes) { + + // + // Do any tokens match the child node? + // + int matchResult = node.matches(tokens, tokenIdx); + if (matchResult < 0) { + // The node matched no tokens. + continue; + } + if (matchResult >= tokens.size()) { + // This node matched all remaining tokens. + + // + // Make sure we walk down further wildcard node(s) of the same kind + // as the node that matched all tokens and gather all values. + // + do { + if (node.hasValues()) { + result.add(node); + } + if ( node.hasWildcardChild()) { + Node child = node.getChild(getWildcardChar()); + if (child.getKind() != getKind()) { + node = null; + } else { + node = child; + } + + } else { + node = null; + } + } while (node != null); + + } else { + // + // This node matched some of the remaining tokens. + // So continue to find matching nodes for the remaining tokens (from matchResult onwards). + // + result.addAll(node.findAllMatchingNodes(tokens, matchResult)); + } + } + return result; + } + + /** + * Does this node match one or more tokens starting at tokenIdx? + *

    + * If not, returns {@code -1}. + * If so, return the index of the first non-matching token in the provided + * tokens, or {@code tokens.length} when all tokens in the array match the node. + * + * @param tokens the tokenized lcoation query + * @param tokenIdx the index of interest into the tokens + * @return the index of the first non-matching token starting at tokenIdx, + * {@code -1} when this node does not match any tokens starting at tokenIdx, + * {@code tokens.length} when this node matches all tokens starting at tokenIdx. + */ + private int matches(List> tokens, int tokenIdx) { + // Return no match (-1) for bad token indices + if (tokenIdx < 0 || tokenIdx >= tokens.size()) { + return -1; + } + + // For exact name matches return the next token index + if (matchesToken(tokens.get(tokenIdx))) { + return tokenIdx + 1; + } + + // Return no match (-1) since we are not a wildcard and not an exact match + if (!this.isWildcard()) { + return -1; + } else { + + // Return no match because wildcards match within Node kinds + if ( this.kind != tokens.get(tokenIdx).getKind()) { + return -1; + } + + do { + tokenIdx++; + } while ( tokenIdx < tokens.size() && this.kind == tokens.get(tokenIdx).getKind()); + return tokenIdx; + } + } + + private boolean matchesToken(Token token) { + return this.getName().equals(token.getName()) && + this.kind == token.getKind(); + } + + + } + + + static class Token> { + E kind; + String name; + + Token(String name, E element) { + this.kind = element; + this.name = name; + } + + public E getKind() { + return kind; + } + + public void setKind(E kind) { + this.kind = kind; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/impl/auth/RealmUtils.java b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/RealmUtils.java new file mode 100644 index 0000000..a1cfc5f --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/impl/auth/RealmUtils.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.impl.auth; + +import org.kaazing.net.auth.ChallengeRequest; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class RealmUtils { + + private RealmUtils() { + // prevent object creation + } + + private static final String REALM_REGEX = "(.*)\\s?(?i:realm=)(\"(.*)\")(.*)"; + private static final Pattern REALM_PATTERN= Pattern.compile(REALM_REGEX); + + /** + * A realm parameter is a valid authentication parameter for all authentication schemes + * according to RFC 2617 Section 2.1". + * + * + * The realm directive (case-insensitive) is required for all + * authentication schemes that issue a challenge. The realm value + * (case-sensitive), in combination with the canonical root URL (the + * absoluteURI for the server whose abs_path is empty) of the server + * being accessed, defines the protection space. + * + * + * @param challengeRequest the challenge request to extract a realm from + * + * @return the unquoted realm parameter value if present, or {@code null} if no such parameter exists. + */ + public static String getRealm(ChallengeRequest challengeRequest) { + String authenticationParameters = challengeRequest.getAuthenticationParameters(); + if ( authenticationParameters == null) { + return null; + } + Matcher m = REALM_PATTERN.matcher(authenticationParameters); + if ( m.matches() && m.groupCount()>=3) { + return m.group(3); + } + return null; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/impl/util/BlockingQueueImpl.java b/cloudsdk/src/main/java/org/kaazing/net/impl/util/BlockingQueueImpl.java new file mode 100644 index 0000000..1e6faa7 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/impl/util/BlockingQueueImpl.java @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.impl.util; + +import java.util.concurrent.ArrayBlockingQueue; + +/** + * ArrayBlockingQueue extension with ability to interrupt or end-of-stream. + * This will be used by producer(ie. the listener) and the consumer(ie. the + * WebSocketMessageReader). To match the 3.X event-listener behavior, the + * capacity of the queue can be set to 1. + * + * @param element type + */ +public class BlockingQueueImpl extends ArrayBlockingQueue { + private static final long serialVersionUID = 1L; + + // ### TODO: Maybe expose an API on WebSocket/WsURLConnection for developers + // to specify the number of incoming messages that can be held + // before we start pushing on the network. + private static final int _QUEUE_CAPACITY = 32; + + private boolean _done = false; + + public BlockingQueueImpl() { + super(_QUEUE_CAPACITY, true); + } + + public synchronized void done() { + _done = true; + notifyAll(); + } + + public boolean isDone() { + return _done; + } + + public synchronized void reset() { + // Wake up threads that maybe blocked to retrieve data. + notifyAll(); + clear(); + _done = false; + } + + // Override to make peek() a blocking call. + @Override + public E peek() { + E el; + + while (((el = super.peek()) == null) && !isDone()) { + synchronized (this) { + try { + wait(); + } catch (InterruptedException e) { + String s = "Reader has been interrupted maybe the connection is closed"; + throw new RuntimeException(s); + } + } + } + + if ((el == null) && isDone()) { + String s = "Reader has been interrupted maybe the connection is closed"; + throw new RuntimeException(s); + } + + return el; + } + + @Override + public void put(E el) throws InterruptedException { + synchronized (this) { + while ((size() == _QUEUE_CAPACITY) && !isDone()) { + // Push on the network as the messages are not being retrieved. + wait(); + } + + if (isDone()) { + notifyAll(); + return; + } + } + + super.put(el); + + synchronized (this) { + notifyAll(); + } + } + + @Override + public E take() throws InterruptedException { + E el = null; + + synchronized (this) { + while (isEmpty() && !isDone()) { + wait(); + } + + if (isDone()) { + notifyAll(); + + if (size() == 0) { + String s = "Reader has been interrupted maybe the connection is closed"; + throw new InterruptedException(s); + } + } + } + + el = super.take(); + + synchronized (this) { + notifyAll(); + } + + return el; + } +} + diff --git a/cloudsdk/src/main/java/org/kaazing/net/impl/util/ResumableTimer.java b/cloudsdk/src/main/java/org/kaazing/net/impl/util/ResumableTimer.java new file mode 100644 index 0000000..cb1547d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/impl/util/ResumableTimer.java @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.impl.util; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicLong; + +public class ResumableTimer { + public enum PauseStrategy { UPDATE_DELAY, DO_NOT_UPDATE_DELAY }; + + private final Runnable runnable; + private volatile boolean taskExecuted = false; + + private AtomicLong delay; // milliseconds + private AtomicLong startTime; // milliseconds since epoch + private Timer timer; + private boolean updateDelayWhenPaused; + + public ResumableTimer(Runnable runnable, long delay, boolean updateDelayWhenPaused) { + if (runnable == null) { + throw new IllegalArgumentException("runnable is null"); + } + + if (delay < 0) { + throw new IllegalArgumentException("Timer delay cannot be negative"); + } + + this.delay = new AtomicLong(delay); + this.startTime = new AtomicLong(0L); + this.runnable = runnable; + this.updateDelayWhenPaused = updateDelayWhenPaused; + } + + public synchronized void cancel() { + if (timer != null) { + timer.cancel(); + } + + timer = null; + delay.set(-1L); + startTime.set(-1L); + taskExecuted = false; + } + + public boolean didTaskExecute() { + return taskExecuted; + } + + public synchronized long getDelay() { + return delay.get(); + } + + public synchronized void pause() { + long elapsedTime = System.currentTimeMillis() - startTime.get(); + + if (timer == null) { + // throw new IllegalStateException("Timer is not running"); + return; + } + + timer.cancel(); + timer = null; + + // If updateDelayWhenPaused is true, then update this.delay by + // subtracting the elapsed time. Otherwise, this.delay is not modified. + if (this.updateDelayWhenPaused) { + assert(elapsedTime < delay.get()); + delay.compareAndSet(delay.get(), (delay.get() - elapsedTime)); + } + } + + public synchronized void resume() { + if (timer != null) { + // throw new IllegalStateException("Timer is already running"); + return; + } + + if (delay.get() < 0) { + throw new IllegalStateException("Timer delay cannot be negative"); + } + + timer = new Timer("ResumableTimer", true); + startTime.compareAndSet(startTime.get(), System.currentTimeMillis()); + timer.schedule(new RunnableTask(runnable), delay.get()); + } + + public synchronized void start() { + resume(); + } + + private synchronized void cleanup() { + taskExecuted = true; + startTime.set(-1L); + timer = null; + } + + private class RunnableTask extends TimerTask { + private final Runnable runnable; + + public RunnableTask(Runnable runnable) { + if (runnable == null) { + throw new NullPointerException("runnable is null"); + } + + this.runnable = runnable; + } + + public void run() { + runnable.run(); + ResumableTimer.this.cleanup(); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventReader.java b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventReader.java new file mode 100644 index 0000000..3627456 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventReader.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse; + +import java.io.IOException; + +public abstract class SseEventReader { + /** + * Returns the payload of the last received event. This method returns a + * null until the first event is received. Note that this is not a blocking + * call. Typically, this method should be invoked after {@link #next()} ONLY + * if the returned type is {@link SseEventType#DATA}. Otherwise, an + * IOException is thrown. + *

    + * If {@link #next()} returns any other event type, then subsequent + * invocation of this method returns a null. + *

    + * @return String event payload; a null is returned if + * invoked when not connected or before the first + * event is received + * @throws IOException if the event type is not SseEventType.DATA + */ + public abstract CharSequence getData() throws IOException; + + /** + * Returns the name of the last received event. This method returns a null + * until the first event is received. Note that this is not a blocking + * call. Typically, this method should be invoked after {@link #next()}. + * It's perfectly legal for an event name to be null even if it contains + * data. Similarly, it is perfectly legal for an event of type + * {@link SseEvent#EMPTY} to have an event name. + *

    + * @return String event name; a null is returned if invoked + * when not connected or before the first event is + * received + */ + public abstract String getName(); + + /** + * Returns the {@link SseEventType} of the already received event. This + * method returns a null until the first event is received. Note that + * this is not a blocking call. When connected, if this method is invoked + * immediately after {@link #next()}, then they will return the same value. + *

    + * Based on the returned {@link SseEventType}, the application developer can + * decide whether to read the data. This method will continue to return the + * same {@link SseEventType} till the next event arrives. When the next + * event arrives, this method will return the the {@link SseEventType} + * associated with that event. + *

    + * @return SseEventType SseEventType.DATA for an event that contain + * data; SseEventType.EMPTY for an event that + * is empty with no data; WebSocketMessageType.EOS + * if the connection is closed; a null is + * returned if not connected or before the first + * event is received + */ + public abstract SseEventType getType(); + + /** + * Invoking this method will cause the thread to block until an event is + * received. When the event is received, it will return the type of the + * newly received event. Based on {@link SseEventType}, the application + * developer can decide whether to invoke the {@link #readData()} method. + * When the connection is closed, this method returns + * {@link SseEventType#EOS}. + *

    + * An IOException is thrown if this method is invoked before the connection + * has been established. + *

    + * @return SseEventType SseEventType.DATA for an event that contain + * data; SseEventType.EMPTY for an event that + * is empty with no data; WebSocketMessageType.EOS + * if the connection is closed + * @throws IOException if invoked before the connection is established + */ + public abstract SseEventType next() throws IOException; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSource.java b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSource.java new file mode 100644 index 0000000..0b00dcb --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSource.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse; + +import java.io.IOException; + +import org.kaazing.net.http.HttpRedirectPolicy; + +/** + * SseEventSource provides an implementation of HTML5 Server-sent Events. + * Refer to HTML5 EventSource at + * {@link http://www.whatwg.org/specs/web-apps/current-work/#server-sent-events} + * {@link http://www.whatwg.org/specs/web-apps/current-work/#the-event-source} + */ +public abstract class SseEventSource { + /** + * Disconnects with the server. This is a blocking call that returns only + * when the shutdown is complete. + * + * @throws IOException if the disconnect did not succeed + */ + public abstract void close() throws IOException; + + /** + * Connects with the server using an end-point. This is a blocking call. The + * thread invoking this method will be blocked till a successful connection + * is established. If the connection cannot be established, then an + * IOException is thrown and the thread is unblocked. + * + * @throws IOException if the connection cannot be established + */ + public abstract void connect() throws IOException; + + /** + * Returns a {@link SseEventReader} that can be used to receive + * events based on the {@link SseEventType}. + *

    + * If this method is invoked before a connection is established successfully, + * then an IOException is thrown. + * + * @return SseEventReader to receive events + * @throws IOException if invoked before the connection is opened + */ + public abstract SseEventReader getEventReader() throws IOException; + + /** + * Returns {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). The default option is + * {@link HttpRedirectPolicy#NONE}. + * + * @return HttpRedirectOption indicating the + */ + public abstract HttpRedirectPolicy getFollowRedirect(); + + /** + * Returns the retry timeout in milliseconds. The default is 3000ms. + * + * @return retry timeout in milliseconds + */ + public abstract long getRetryTimeout(); + + /** + * Sets {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). + * + * @param option HttpRedirectOption to used for following the + * redirects + */ + public abstract void setFollowRedirect(HttpRedirectPolicy option); + + /** + * Sets the retry timeout specified in milliseconds. + * + * @param millis retry timeout + */ + public abstract void setRetryTimeout(long millis); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSourceFactory.java b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSourceFactory.java new file mode 100644 index 0000000..bffcb45 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSourceFactory.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ServiceLoader; + +import org.kaazing.net.http.HttpRedirectPolicy; + +/** + * {@link SseEventSourceFactory} is an abstract class that can be used to create + * {@link SseEventSource}s by specifying the end-point. It may be extended to + * instantiate particular subclasses of {@link SseEventSource} and thus provide + * a general framework for the addition of public SSE-level functionality. + */ +public abstract class SseEventSourceFactory { + + protected SseEventSourceFactory() { + + } + + /** + * Creates and returns a new instance of the default implementation of the + * {@link SseEventSourceFactory}. + * + * @return SseEventSourceFactory + */ + public static SseEventSourceFactory createEventSourceFactory() { + Class clazz = SseEventSourceFactory.class; + ServiceLoader loader = ServiceLoader.load(clazz); + return loader.iterator().next(); + } + + /** + * Creates a {@link SseEventSource} to connect to the target location. + *

    + * + * @param location URI of the SSE provider for the connection + * @throws URISyntaxException + */ + public abstract SseEventSource createEventSource(URI location) + throws URISyntaxException; + + /** + * Returns the default {@link HttpRedirectPolicy} that was specified at + * on the factory. + * + * ### TODO: If this wasn't set, should we return HttpRedirectOption.NONE or + * null. + * + * @return HttpRedirectOption + */ + public abstract HttpRedirectPolicy getDefaultFollowRedirect(); + + /** + * Returns the default retry timeout. The default is 3000 milliseconds. + * + * @return retry timeout in milliseconds + */ + public abstract long getDefaultRetryTimeout(); + + /** + * Sets the default {@link HttpRedirectPolicy} that is to be inherited by + * all the {@link EventSource}s created using this factory instance. + * + * @param option HttpRedirectOption + */ + public abstract void setDefaultFollowRedirect(HttpRedirectPolicy option); + + /** + * Sets the default retry timeout that is to be inherited by all the + * {@link EventSource}s created using this factory instance. + * + * @param millis retry timeout + */ + public abstract void setDefaultRetryTimeout(long millis); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventType.java b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventType.java new file mode 100644 index 0000000..9f9c177 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/SseEventType.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse; + +public enum SseEventType { + EOS, EMPTY, DATA; +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/SseException.java b/cloudsdk/src/main/java/org/kaazing/net/sse/SseException.java new file mode 100644 index 0000000..11fce00 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/SseException.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse; + +import java.io.IOException; + +public class SseException extends IOException { + + private static final long serialVersionUID = 1L; + + public SseException(String reason) { + super(reason); + } + + public SseException(Exception ex) { + super(ex); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedEventSourceFactory.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedEventSourceFactory.java new file mode 100644 index 0000000..1f8e1b8 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedEventSourceFactory.java @@ -0,0 +1,61 @@ +/** + * Portions of this file copyright (c) 2007-2014 Kaazing Corporation. + * All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import org.kaazing.net.sse.SseEventSource; + +import java.net.URI; +import java.net.URISyntaxException; + +import io.particle.android.sdk.cloud.ParticleCloud; + + +public class AuthenticatedEventSourceFactory extends DefaultEventSourceFactory { + + private final ParticleCloud cloud; + + public AuthenticatedEventSourceFactory(ParticleCloud cloud) { + this.cloud = cloud; + } + + @Override + public SseEventSource createEventSource(URI location) throws URISyntaxException { + + String scheme = location.getScheme(); + if (!scheme.toLowerCase().equals("sse") && + !scheme.toLowerCase().equals("http") && + !scheme.toLowerCase().equals("https")) { + String s = String.format("Incorrect scheme or protocol '%s'", scheme); + throw new URISyntaxException(location.toString(), s); + } + + SseEventSourceImpl eventSource = new AuthenticatedSseEventSourceImpl(location, cloud); + + // Set up the defaults from the factory. + eventSource.setFollowRedirect(getDefaultFollowRedirect()); + eventSource.setRetryTimeout(getDefaultRetryTimeout()); + + return eventSource; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventSourceImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventSourceImpl.java new file mode 100644 index 0000000..f9fdaf7 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventSourceImpl.java @@ -0,0 +1,277 @@ +/** + * Portions of this file copyright (c) 2007-2014 Kaazing Corporation. + * All rights reserved. + *

    + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.impl.util.BlockingQueueImpl; +import org.kaazing.net.sse.SseEventReader; +import org.kaazing.net.sse.SseException; + +import java.io.IOException; +import java.net.URI; + +import io.particle.android.sdk.cloud.ParticleCloud; +import io.particle.android.sdk.utils.TLog; + +import static io.particle.android.sdk.utils.Py.list; + + +// FIXME: replace this entire SSE stack with something backed by OkHttp. +public class AuthenticatedSseEventSourceImpl extends SseEventSourceImpl { + + private static final String _CLASS_NAME = AuthenticatedSseEventSourceImpl.class.getName(); + private static final TLog _LOG = TLog.get(AuthenticatedSseEventSourceImpl.class); + + enum ReadyState { + CONNECTING, + OPEN, + CLOSING, + CLOSED + } + + private final SseEventReaderImpl _eventReader; + private final BlockingQueueImpl _sharedQueue; + private final URI _location; + + private SseEventStream _eventStream; + private long _retryTimeout = 3000; + private HttpRedirectPolicy _redirectOption; + private ReadyState _readyState; + private SseException _exception; + private ParticleCloud cloud; + + public AuthenticatedSseEventSourceImpl(URI location, ParticleCloud cloud) { + super(location); + this.cloud = cloud; + + URI loc = location; + + // Map "sse" to "http". + if (location.getScheme().equalsIgnoreCase("sse")) { + String fragment = location.getFragment(); + String schemeSpecificPart = location.getSchemeSpecificPart(); + + if (fragment == null) { + fragment = ""; + } + loc = URI.create("http:" + schemeSpecificPart + fragment); + } + + _location = loc; + _readyState = ReadyState.CLOSED; + + // Used by the producer(i.e. the eventSourceListener) and the + // consumer(i.e. the SseEventReader). + _sharedQueue = new BlockingQueueImpl<>(); + _eventReader = new SseEventReaderImpl(this, _sharedQueue); + } + + @Override + public synchronized void close() throws IOException { + if (list(ReadyState.CLOSED, ReadyState.CLOSING).contains(_readyState)) { + // Since the WebSocket is already closed/closing, we just bail. + _LOG.v("Event source is not connected"); + return; + } + + setException(null); + _readyState = ReadyState.CLOSING; + + _eventStream.stop(); + + _readyState = ReadyState.CLOSED; + + cleanupAfterClose(); + } + + @Override + public synchronized void connect() throws IOException { + if (_readyState != ReadyState.CLOSED) { + String s = "Event source must be closed before connecting"; + throw new SseException(s); + } + + _eventStream = new AuthenticatedSseEventStream(_location.toString(), cloud); + _eventStream.setListener(_eventStreamListener); + _eventStream.setRetryTimeout(_retryTimeout); + + // Ensure that the reader is reset and ready to block the consumer + // if no data has been produced. + _eventReader.reset(); + + // Prepare the state for connection. + _readyState = ReadyState.CONNECTING; + setException(null); + + // Connect to the event source. Note that it all happens on the same + // thread. The registered SseEventStreamListener is also invoked as part + // of this call. + _eventStream.connect(); + + // Check if there is any exception that needs to be reported. + SseException exception = getException(); + if (exception != null) { + throw exception; + } + } + + @Override + public SseEventReader getEventReader() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot get the SseEventReader as the event source is not yet connected"; + throw new IOException(s); + } + + return _eventReader; + } + + + @Override + public HttpRedirectPolicy getFollowRedirect() { + return _redirectOption; + } + + @Override + public long getRetryTimeout() { + return _retryTimeout; + } + + @Override + public void setFollowRedirect(HttpRedirectPolicy redirectOption) { + _redirectOption = redirectOption; + } + + @Override + public void setRetryTimeout(long millis) { + _retryTimeout = millis; + + if (_eventStream != null) { + _eventStream.setRetryTimeout(millis); + } + } + + // ---------------------- Internal Implementation ------------------------ + public SseException getException() { + return _exception; + } + + public void setException(SseException exception) { + _exception = exception; + } + + public boolean isConnected() { + return (_readyState == ReadyState.OPEN); + } + + public boolean isDisconnected() { + return (_readyState == ReadyState.CLOSED); + } + + public BlockingQueueImpl getSharedQueue() { + return _sharedQueue; + } + + // --------------------- Private Methods --------------------------------- + private synchronized void connectionOpened() { + _readyState = ReadyState.OPEN; + + // Unblock the connect() call so that it can proceed. + notifyAll(); + } + + private void messageArrived(String eventName, String data) { + if (_readyState != ReadyState.OPEN) { + // If the connection is closed, then we should be queuing the + // events/payload. + return; + } + + synchronized (_sharedQueue) { + try { + _sharedQueue.put(new SsePayload(eventName, data)); + } catch (InterruptedException ex) { + _LOG.i(ex.getMessage(), ex); + } + } + } + + @SuppressWarnings("unused") + private synchronized void connectionClosed(String reason) { + _readyState = ReadyState.CLOSED; + + if (reason != null) { + setException(new SseException(reason)); + } + + cleanupAfterClose(); + + // Unblock the close() call so that it can proceed. + notifyAll(); + } + + private synchronized void connectionFailed(Exception exception) { + SseException ex = (exception == null) + ? new SseException("Connection Failed") + : new SseException(exception); + setException(ex); + + _readyState = ReadyState.CLOSED; + + cleanupAfterClose(); + + // Unblock threads so that they can proceed. + notifyAll(); + } + + private synchronized void cleanupAfterClose() { + // Notify the waiting consumers that the connection is closing. + try { + _eventReader.close(); + } catch (IOException ex) { + _LOG.v(ex.getMessage(), ex); + } + } + + + private SseEventStreamListener _eventStreamListener = new SseEventStreamListener() { + + @Override + public void streamOpened() { + _LOG.d("entering " + _CLASS_NAME + ".streamOpened"); + connectionOpened(); + } + + @Override + public void messageReceived(String eventName, String message) { + _LOG.d("entering " + _CLASS_NAME + ".messageReceived: " + message); + messageArrived(eventName, message); + } + + @Override + public void streamErrored(Exception exception) { + _LOG.d("entering " + _CLASS_NAME + ".streamErrored"); + connectionFailed(exception); + } + }; + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventStream.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventStream.java new file mode 100644 index 0000000..bd17492 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventStream.java @@ -0,0 +1,371 @@ +/** + * Parts of this code copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + *

    + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.impl.http.HttpRequestAuthenticationHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandlerFactory; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestRedirectHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestTransportHandler; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.ws.ReadyState; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +import io.particle.android.sdk.cloud.ParticleCloud; + +/** + * ServerSentEvent stream implementation. + */ +public class AuthenticatedSseEventStream extends SseEventStream { + + private static final String MESSAGE = "message"; + private static final String CLASS_NAME = AuthenticatedSseEventStream.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private final StringBuffer dataBuffer = new StringBuffer(); + private transient static final Timer timer = new Timer("reconnect", true); + + static final HttpRequestHandlerFactory SSE_HANDLER_FACTORY = () -> { + HttpRequestAuthenticationHandler authHandler = new HttpRequestAuthenticationHandler(); + HttpRequestRedirectHandler redirectHandler = new HttpRequestRedirectHandler(); + HttpRequestHandler transportHandler = HttpRequestTransportHandler.DEFAULT_FACTORY.createHandler(); + + authHandler.setNextHandler(redirectHandler); + redirectHandler.setNextHandler(transportHandler); + + return authHandler; + }; + + private ReadyState readyState = ReadyState.CONNECTING; + private String lastEventId = ""; + private boolean aborted = false; + private boolean errored = false; + private String sseLocation; + private long retry = 3000; // same as actionscript implementation + private boolean immediateReconnect = false; + private String messageBuffer = ""; + private HttpRequest sseSource; + private AtomicBoolean progressEventReceived = new AtomicBoolean(false); + private AtomicBoolean reconnected = new AtomicBoolean(false); + private HttpRequestHandler sseHandler; + private SseEventStreamListener listener; + private final ParticleCloud cloud; + private String name = MESSAGE; + + public AuthenticatedSseEventStream(String sseLoc, ParticleCloud cloud) throws IOException { + super(sseLoc); + LOG.entering(CLASS_NAME, "", sseLoc); + + this.cloud = cloud; + + // Validate the URI. + URI.create(sseLoc); + + this.sseLocation = sseLoc; + + sseHandler = SSE_HANDLER_FACTORY.createHandler(); + + sseHandler.setListener(new EventStreamHttpRequestListener()); + } + + public ReadyState getReadyState() { + return readyState; + } + + public void stop() { + LOG.entering(CLASS_NAME, "stop"); + readyState = ReadyState.CLOSED; + sseHandler.processAbort(sseSource); + aborted = true; + } + + public void connect() throws IOException { + LOG.entering(CLASS_NAME, "connect"); + if (lastEventId != null && (lastEventId.length() > 0)) { + sseLocation += (!sseLocation.contains("?") ? "?" : "&") + ".ka=" + lastEventId; + } + + try { + HttpURI uri = new HttpURI(this.sseLocation); + sseSource = new HttpRequest(Method.GET, uri, true); + sseSource.setHeader("Authorization", "Bearer " + this.cloud.getAccessToken()); + sseHandler.processOpen(sseSource); + + if (!reconnected.get()) { + TimerTask timerTask = new TimerTask() { + @Override + public void run() { + // TODO: Why is this commented out? - no fallback to long polling? + + // if (!SseEventStream.this.progressEventReceived.get() && readyState != ReadyState.CLOSED) { + // if (sseLocation.indexOf("?") == -1) { + // sseLocation += "?.ki=p"; + // } + // else { + // sseLocation += "&.ki=p"; + // } + // listener.reconnectScheduled = true; + // reconnected.set(true); + // retry = 0; + // try { + // connect(); + // } + // catch (IOException e) { + // // TODO Auto-generated catch block + // e.printStackTrace(); + // } + // } + } + }; + Timer timer = new Timer(); + timer.schedule(timerTask, 3000); + } + } catch (Exception e) { + LOG.log(Level.INFO, e.getMessage(), e); + doError(e); + } + } + + public long getRetryTimeout() { + return retry; + } + + public void setRetryTimeout(long millis) { + retry = millis; + } + + private synchronized void reconnect() { + LOG.entering(CLASS_NAME, "reconnect"); + if (readyState != ReadyState.CLOSED) { + TimerTask task = new TimerTask() { + @Override + public void run() { + try { + connect(); + } catch (IOException e) { + LOG.log(Level.INFO, e.getMessage(), e); + throw new RuntimeException(e); + } + } + }; + timer.schedule(task, retry); + } + } + + private synchronized void processProgressEvent(String message) { + LOG.entering(CLASS_NAME, "processProgressEvent", message); + String line; + try { + messageBuffer = messageBuffer + message; + String field; + String value; + immediateReconnect = false; + while (!aborted && !errored) { + line = fetchLineFromBuffer(); + if (line == null) { + break; + } + + if (line.length() == 0 && dataBuffer.length() > 0) { + synchronized (dataBuffer) { + int dataBufferlength = dataBuffer.length(); + if (dataBuffer.charAt(dataBufferlength - 1) == '\n') { + dataBuffer.replace(dataBufferlength - 1, dataBufferlength, ""); + } + doMessage(name, dataBuffer.toString()); + dataBuffer.setLength(0); + } + } + + int colonAt = line.indexOf(':'); + if (colonAt == -1) { + // no colon, line is field name with empty value + field = line; + value = ""; + } else if (colonAt == 0) { + // leading colon indicates comment line + continue; + } else { + field = line.substring(0, colonAt); + int valueAt = colonAt + 1; + if (line.length() > valueAt && line.charAt(valueAt) == ' ') { + valueAt++; + } + value = line.substring(valueAt); + } + // process the field of completed event + switch (field) { + case "event": + name = value; + break; + case "id": + this.lastEventId = value; + break; + case "retry": + retry = Integer.parseInt(value); + break; + case "data": + // deliver event if data is specified and non-empty, or name is specified and not "message" + if (name != null && name.length() > 0 && !MESSAGE.equals(name)) { + dataBuffer.append(value).append("\n"); + } + break; + case "location": + if (value.length() > 0) { + this.sseLocation = value; + } + break; + case "reconnect": + immediateReconnect = true; + break; + } + } + + if (immediateReconnect) { + retry = 0; + // this will be done on the load + } + } catch (Exception e) { + LOG.log(Level.INFO, e.getMessage(), e); + doError(e); + } + } + + private String fetchLineFromBuffer() { + LOG.entering(CLASS_NAME, "fetchLineFromBuffer"); + int lf = this.messageBuffer.indexOf("\n"); + if (lf == -1) { + lf = this.messageBuffer.indexOf("\r"); + } + if (lf != -1) { + String ret = messageBuffer.substring(0, lf); + messageBuffer = messageBuffer.substring(lf + 1); + return ret; + } + return null; + } + + private class EventStreamHttpRequestListener implements HttpRequestListener { + private final String CLASS_NAME = EventStreamHttpRequestListener.class.getName(); + private final Logger LOG = Logger.getLogger(CLASS_NAME); + + boolean reconnectScheduled = false; + + EventStreamHttpRequestListener() { + LOG.entering(CLASS_NAME, ""); + } + + @Override + public void requestReady(HttpRequest request) { + } + + @Override + public void requestOpened(HttpRequest request) { + doOpen(); + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + progressEventReceived.set(true); + String response = payload.getString(UTF_8); + processProgressEvent(response); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + // for Long polling. If we get an onload we have to + // reconnect. + if (readyState != ReadyState.CLOSED) { + if (immediateReconnect) { + retry = 0; + if (!reconnectScheduled) { + reconnect(); + } + } + } + } + + @Override + public void requestAborted(HttpRequest request) { + } + + @Override + public void requestClosed(HttpRequest request) { + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + doError(exception); + } + } + + private void doOpen() { + /* + Only file the event once in the case its already opened, + Currently, this is being called twice, once when the SSE + gets connected and then again when the ready state changes. + */ + if (readyState == ReadyState.CONNECTING) { + readyState = ReadyState.OPEN; + listener.streamOpened(); + } + } + + private void doMessage(String eventName, String data) { + // messages before OPEN and after CLOSE should not be delivered. + if (getReadyState() != ReadyState.OPEN) { + LOG.log(Level.INFO, "event message discarded " + getReadyState().name()); + return; + } + + listener.messageReceived(eventName, data); + } + + private void doError(Exception exception) { + if (getReadyState() == ReadyState.CLOSED) { + LOG.log(Level.INFO, "event error discarded " + getReadyState().name()); + return; + } + + // TODO: Set readyState to CLOSED? + errored = true; + listener.streamErrored(exception); + } + + public void setListener(SseEventStreamListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/DefaultEventSourceFactory.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/DefaultEventSourceFactory.java new file mode 100644 index 0000000..513c477 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/DefaultEventSourceFactory.java @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.sse.SseEventSource; +import org.kaazing.net.sse.SseEventSourceFactory; + +public class DefaultEventSourceFactory extends SseEventSourceFactory { + + private long _retryTimeout; + private HttpRedirectPolicy _redirectOption; + + public DefaultEventSourceFactory() { + _retryTimeout = 3000; + + // ### TODO: Should _redirectOption be null or + // HttpRedirectOption.ALWAYS by default. Note that in + // HttpURLConnection, followRedirects is true by default. + _redirectOption = HttpRedirectPolicy.ALWAYS; + } + + @Override + public SseEventSource createEventSource(URI location) + throws URISyntaxException { + + String scheme = location.getScheme(); + if (!scheme.toLowerCase().equals("sse") && + !scheme.toLowerCase().equals("http") && + !scheme.toLowerCase().equals("https")) { + String s = String.format("Incorrect scheme or protocol '%s'", scheme); + throw new URISyntaxException(location.toString(), s); + } + + SseEventSourceImpl eventSource = new SseEventSourceImpl(location); + + // Set up the defaults from the factory. + eventSource.setFollowRedirect(_redirectOption); + eventSource.setRetryTimeout(_retryTimeout); + + return eventSource; + } + + @Override + public HttpRedirectPolicy getDefaultFollowRedirect() { + return _redirectOption; + } + + @Override + public long getDefaultRetryTimeout() { + return _retryTimeout; + } + + @Override + public void setDefaultFollowRedirect(HttpRedirectPolicy redirectOption) { + _redirectOption = redirectOption; + } + + @Override + public void setDefaultRetryTimeout(long millis) { + _retryTimeout = millis; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventReaderImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventReaderImpl.java new file mode 100644 index 0000000..2bd5c13 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventReaderImpl.java @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.net.impl.util.BlockingQueueImpl; +import org.kaazing.net.sse.SseEventReader; +import org.kaazing.net.sse.SseEventType; +import org.kaazing.net.sse.SseException; + +public class SseEventReaderImpl extends SseEventReader { + private static final String _CLASS_NAME = SseEventReaderImpl.class.getName(); + private static final Logger _LOG = Logger.getLogger(_CLASS_NAME); + + private final BlockingQueueImpl _sharedQueue; + private final SseEventSourceImpl _eventSource; + + private SsePayload _payload; + private SseEventType _eventType; + private String _eventName; + private String _data; + + public SseEventReaderImpl(SseEventSourceImpl eventSource, + BlockingQueueImpl sharedQueue) { + _eventSource = eventSource; + _sharedQueue = sharedQueue; + + _payload = null; + _eventType = null; + _eventName = null; + _data = null; + } + + @Override + public CharSequence getData() throws IOException { + if (_payload == null) { + return null; + } + + if (_eventType != SseEventType.DATA) { + String s = "readData() can only be used to read events " + + "of type SseEventType.DATA"; + throw new SseException(s); + } + + return _data; + } + + @Override + public String getName() { + return _eventName; + } + + @Override + public SseEventType getType() { + return _eventType; + } + + @Override + public SseEventType next() throws IOException { + if (_sharedQueue.isDone()) { + _eventType = SseEventType.EOS; + return _eventType; + } + + synchronized (this) { + if (!_eventSource.isConnected()) { + _eventType = SseEventType.EOS; + return _eventType; + } + + try { + _payload = null; + _payload = (SsePayload) _sharedQueue.take(); + } + catch (InterruptedException ex) { + _LOG.log(Level.FINE, ex.getMessage()); + } + + if (_payload == null) { + String s = "Reader has been interrupted maybe the connection " + + "is closed"; + // throw new SseException(s); + _LOG.log(Level.FINE, _CLASS_NAME, s); + + _eventType = SseEventType.EOS; + return _eventType; + } + + _data = _payload.getData(); + _eventName = _payload.getEventName(); + _eventType = (_payload.getData() == null) ? SseEventType.EMPTY : + SseEventType.DATA; + } + + return _eventType; + } + + // ------------------ Package-Private Implementation ---------------------- + // These methods are called from other classes in this package. They are + // not part of the public API. + void close() throws IOException { + _sharedQueue.done(); + _payload = null; + _eventType = null; + _data = null; + _eventName = null; + } + + void reset() throws IOException { + _sharedQueue.reset(); + _payload = null; + _eventType = null; + _data = null; + _eventName = null; + } + + // ------------- Currently not being used methods ------------------------- + // This was earlier part of our public API. It's no longer being exposed. + /** + * Returns the payload of the event. Use this method to retrieve the + * payload only if the event's type is {@link SseEventType.DATA}. + *

    + * If this method is invoked AFTER {@link #next()} method, then it will not + * block. If this method is invoked before {@link #next()} method, then it + * will block till a message is received. If the type of received event is + * {@link SseEventType#EMPTY}, then this method will throw an IOException. + *

    + * An IOException is thrown if the connection is closed while blocked. An + * IOException is thrown if this method is invoked before the connection + * has been established. + *

    + * @return CharSequence event's payload + * @throws IOException if the connection is closed; if the received + * event's type is SseEventType.EMPTY; if invoked + * before connection is established + */ + @SuppressWarnings("unused") + private CharSequence readData() throws IOException { + if (!_eventSource.isConnected()) { + String s = "Can't read using the MessageReader if the event " + + "source is not connected"; + throw new SseException(s); + } + + synchronized (this) { + if (_payload != null) { + // If we are here, then it means that readData() was invoked + // after next(). So, the _payload is already setup and we just + // have to return the data. + if (_eventType != SseEventType.DATA) { + String s = "readData() can only be used to read events " + + "of type SseEventType.DATA"; + throw new SseException(s); + } + + // Clear the _payload member variable for the internal state + // machine. + _payload = null; + return _data; + } + + // This will block the thread. If we are here, this means that + // readData() was invoked without a previous invocation of next(). + // So, we invoke next() and ensure that the next message is a text + // message. Otherwise, throw an exception. + SseEventType type = next(); + + if (type != SseEventType.DATA) { + String s = "readData() can only be used to read events " + + "of type SseEventType.DATA"; + throw new SseException(s); + } + + _data = _payload.getData(); + _eventName = _payload.getEventName(); + + // Clear the _payload member variable for the internal state machine. + _payload = null; + return _data; + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventSourceImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventSourceImpl.java new file mode 100644 index 0000000..b85f7e9 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventSourceImpl.java @@ -0,0 +1,328 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import java.io.IOException; +import java.net.URI; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.impl.util.BlockingQueueImpl; +import org.kaazing.net.sse.SseEventReader; +import org.kaazing.net.sse.SseEventSource; +import org.kaazing.net.sse.SseException; + +public class SseEventSourceImpl extends SseEventSource { + private static final String _CLASS_NAME = SseEventSourceImpl.class.getName(); + private static final Logger _LOG = Logger.getLogger(_CLASS_NAME); + + /** + * Values are CONNECTING = 0, OPEN = 1, CLOSING = 2, and CLOSED = 3; + */ + enum ReadyState { + CONNECTING, OPEN, CLOSING, CLOSED; + } + + private SseEventStream _eventStream; + private ReadyState _readyState; + private SseException _exception; + private SseEventReaderImpl _eventReader; + private BlockingQueueImpl _sharedQueue; + private URI _location; + private HttpRedirectPolicy _redirectOption; + private long _retryTimeout = 3000; + + public SseEventSourceImpl(URI location) { + URI loc = location; + + // Map "sse" to "http". + if (location.getScheme().equalsIgnoreCase("sse")) { + String fragment = location.getFragment(); + String schemeSpecificPart = location.getSchemeSpecificPart(); + + if (fragment == null) { + fragment = ""; + } + loc = URI.create("http:" + schemeSpecificPart + fragment); + } + + _location = loc; + _readyState = ReadyState.CLOSED; + + // Used by the producer(i.e. the eventSourceListener) and the + // consumer(i.e. the SseEventReader). + _sharedQueue = new BlockingQueueImpl(); + + } + + @Override + public synchronized void close() throws IOException { + _LOG.entering(_CLASS_NAME, "close"); + + if ((_readyState == ReadyState.CLOSED) || + (_readyState == ReadyState.CLOSING)) { + // Since the WebSocket is already closed/closing, we just bail. + _LOG.log(Level.FINE, "Event source is not connected"); + return; + } + + setException(null); + _readyState = ReadyState.CLOSING; + + _eventStream.stop(); + + _readyState = ReadyState.CLOSED; + + cleanupAfterClose(); + } + + @Override + public synchronized void connect() throws IOException { + _LOG.entering(_CLASS_NAME, "connect"); + + if (_readyState != ReadyState.CLOSED) { + String s = "Event source must be closed before connecting"; + throw new SseException(s); + } + + _eventStream = new SseEventStream(_location.toString()); + _eventStream.setListener(_eventStreamListener); + _eventStream.setRetryTimeout(_retryTimeout); + + if (_eventReader != null) { + // Ensure that the reader is reset and ready to block the consumer + // if no data has been produced. + _eventReader.reset(); + } + + // Prepare the state for connection. + _readyState = ReadyState.CONNECTING; + setException(null); + + // Connect to the event source. Note that it all happens on the same + // thread. The registered SseEventStreamListener is also invoked as part + // of this call. In WebSocket, we have to block to synchronize with the + // other thread that invokes the listener. Here, we don't have to. + _eventStream.connect(); + + // Check if there is any exception that needs to be reported. + SseException exception = getException(); + if (exception != null) { + throw exception; + } + } + + @Override + public SseEventReader getEventReader() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot get the SseEventReader as the event source is not yet connected"; + throw new IOException(s); + } + + synchronized (this) { + if (_eventReader != null) { + return _eventReader; + } + + _eventReader = new SseEventReaderImpl(this, _sharedQueue); + } + + return _eventReader; + } + + + @Override + public HttpRedirectPolicy getFollowRedirect() { + return _redirectOption; + } + + @Override + public long getRetryTimeout() { + return _retryTimeout; + } + + @Override + public void setFollowRedirect(HttpRedirectPolicy redirectOption) { + _redirectOption = redirectOption; + } + + @Override + public void setRetryTimeout(long millis) { + _retryTimeout = millis; + + if (_eventStream != null) { + _eventStream.setRetryTimeout(millis); + } + } + + // ---------------------- Internal Implementation ------------------------ + public SseException getException() { + return _exception; + } + + public void setException(SseException exception) { + _exception = exception; + } + + public boolean isConnected() { + return (_readyState == ReadyState.OPEN); + } + + public boolean isDisconnected() { + return (_readyState == ReadyState.CLOSED); + } + + public BlockingQueueImpl getSharedQueue() { + return _sharedQueue; + } + + // --------------------- Private Methods --------------------------------- + private synchronized void connectionOpened() { + _readyState = ReadyState.OPEN; + + // Unblock the connect() call so that it can proceed. + notifyAll(); + } + + private void messageArrived(String eventName, String data) { + if (_readyState != ReadyState.OPEN) { + // If the connection is closed, then we should be queuing the + // events/payload. + return; + } + + synchronized (_sharedQueue) { + try { + _sharedQueue.put(new SsePayload(eventName, data)); + } + catch (InterruptedException ex) { + _LOG.log(Level.INFO, ex.getMessage(), ex); + } + } + } + + @SuppressWarnings("unused") + private synchronized void connectionClosed(String reason) { + _readyState = ReadyState.CLOSED; + + if (reason != null) { + setException(new SseException(reason)); + } + + cleanupAfterClose(); + + // Unblock the close() call so that it can proceed. + notifyAll(); + } + + private synchronized void connectionFailed(Exception exception) { + SseException ex = null; + + if (exception == null) { + ex = new SseException("Connection Failed"); + } + else { + ex = new SseException(exception); + } + + setException(ex); + + _readyState = ReadyState.CLOSED; + + cleanupAfterClose(); + + // Unblock threads so that they can proceed. + notifyAll(); + } + + private synchronized void cleanupAfterClose() { + if (_eventReader != null) { + // Notify the waiting consumers that the connection is closing. + try { + _eventReader.close(); + } + catch (IOException ex) { + _LOG.log(Level.FINE, ex.getMessage(), ex); + } + } + else { + getSharedQueue().done(); + } + } + + + private SseEventStreamListener _eventStreamListener = new SseEventStreamListener() { + + @Override + public void streamOpened() { + _LOG.entering(_CLASS_NAME, "streamOpened"); + connectionOpened(); + + /* + EventSourceEvent event = new EventSourceEvent(this, EventSourceEvent.Type.OPEN); + for (EventSourceListener listener : listeners) { + try { + listener.onOpen(event); + } catch (RuntimeException e) { + String s = "Application threw an exception during onOpen: "+e.getMessage(); + _LOG.logp(Level.WARNING, _CLASS_NAME, "onOpen", s, e); + } + } + */ + } + + @Override + public void messageReceived(String eventName, String message) { + _LOG.entering(_CLASS_NAME, "messageReceived", message); + messageArrived(eventName, message); + + /* + EventSourceEvent event = new EventSourceEvent(this, EventSourceEvent.Type.MESSAGE, message); + for (EventSourceListener listener : listeners) { + try { + listener.onMessage(event); + } catch (RuntimeException e) { + LOG.logp(Level.WARNING, CLASS_NAME, "onMessage", "Application threw an exception during onMessage: "+e.getMessage(), e); + } + } + */ + } + + @Override + public void streamErrored(Exception exception) { + _LOG.entering(_CLASS_NAME, "streamErrored"); + connectionFailed(exception); + + /* + EventSourceEvent event = new EventSourceEvent(this, EventSourceEvent.Type.ERROR); + for (EventSourceListener listener : listeners) { + try { + listener.onError(event); + } catch (RuntimeException e) { + LOG.logp(Level.WARNING, CLASS_NAME, "onError", "Application threw an exception during onError: "+e.getMessage(), e); + } + } + */ + } + }; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStream.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStream.java new file mode 100644 index 0000000..f1820f8 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStream.java @@ -0,0 +1,362 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + *

    + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + *

    + * http://www.apache.org/licenses/LICENSE-2.0 + *

    + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import org.kaazing.gateway.client.impl.http.HttpRequest; +import org.kaazing.gateway.client.impl.http.HttpRequest.Method; +import org.kaazing.gateway.client.impl.http.HttpRequestAuthenticationHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestHandlerFactory; +import org.kaazing.gateway.client.impl.http.HttpRequestListener; +import org.kaazing.gateway.client.impl.http.HttpRequestRedirectHandler; +import org.kaazing.gateway.client.impl.http.HttpRequestTransportHandler; +import org.kaazing.gateway.client.impl.http.HttpResponse; +import org.kaazing.gateway.client.impl.ws.ReadyState; +import org.kaazing.gateway.client.util.HttpURI; +import org.kaazing.gateway.client.util.WrappedByteBuffer; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * ServerSentEvent stream implementation. + */ +public class SseEventStream { + private static final String MESSAGE = "message"; + private static final String CLASS_NAME = SseEventStream.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + private static final Charset UTF_8 = Charset.forName("UTF-8"); + private final StringBuffer dataBuffer = new StringBuffer(); + private String name = MESSAGE; + + private transient static final Timer timer = new Timer("reconnect", true); + + static final HttpRequestHandlerFactory SSE_HANDLER_FACTORY = () -> { + HttpRequestAuthenticationHandler authHandler = new HttpRequestAuthenticationHandler(); + HttpRequestRedirectHandler redirectHandler = new HttpRequestRedirectHandler(); + HttpRequestHandler transportHandler = HttpRequestTransportHandler.DEFAULT_FACTORY.createHandler(); + + authHandler.setNextHandler(redirectHandler); + redirectHandler.setNextHandler(transportHandler); + + return authHandler; + }; + + private ReadyState readyState = ReadyState.CONNECTING; + private String lastEventId = ""; + private boolean aborted = false; + private boolean errored = false; + private String sseLocation; + private long retry = 3000; // same as actionscript implementation + private boolean immediateReconnect = false; + private String messageBuffer = ""; + private HttpRequest sseSource; + private AtomicBoolean progressEventReceived = new AtomicBoolean(false); + private AtomicBoolean reconnected = new AtomicBoolean(false); + private HttpRequestHandler sseHandler; + private SseEventStreamListener listener; + + + public SseEventStream(String sseLoc) throws IOException { + LOG.entering(CLASS_NAME, "", sseLoc); + + // Validate the URI. + URI.create(sseLoc); + + this.sseLocation = sseLoc; + + sseHandler = SSE_HANDLER_FACTORY.createHandler(); + + sseHandler.setListener(new EventStreamHttpRequestListener()); + } + + public ReadyState getReadyState() { + return readyState; + } + + public void stop() { + LOG.entering(CLASS_NAME, "stop"); + readyState = ReadyState.CLOSED; + sseHandler.processAbort(sseSource); + aborted = true; + } + + public void connect() throws IOException { + LOG.entering(CLASS_NAME, "connect"); + if (lastEventId != null && (lastEventId.length() > 0)) { + sseLocation += (!sseLocation.contains("?") ? "?" : "&") + ".ka=" + lastEventId; + } + + try { + HttpURI uri = new HttpURI(this.sseLocation); + sseSource = new HttpRequest(Method.GET, uri, true); + sseHandler.processOpen(sseSource); + + if (!reconnected.get()) { + TimerTask timerTask = new TimerTask() { + @Override + public void run() { + // TODO: Why is this commented out? - no fallback to long polling? + + // if (!SseEventStream.this.progressEventReceived.get() && readyState != ReadyState.CLOSED) { + // if (sseLocation.indexOf("?") == -1) { + // sseLocation += "?.ki=p"; + // } + // else { + // sseLocation += "&.ki=p"; + // } + // listener.reconnectScheduled = true; + // reconnected.set(true); + // retry = 0; + // try { + // connect(); + // } + // catch (IOException e) { + // // TODO Auto-generated catch block + // e.printStackTrace(); + // } + // } + } + }; + Timer timer = new Timer(); + timer.schedule(timerTask, 3000); + } + } catch (Exception e) { + LOG.log(Level.INFO, e.getMessage(), e); + doError(e); + } + } + + public long getRetryTimeout() { + return retry; + } + + public void setRetryTimeout(long millis) { + retry = millis; + } + + private synchronized void reconnect() { + LOG.entering(CLASS_NAME, "reconnect"); + if (readyState != ReadyState.CLOSED) { + TimerTask task = new TimerTask() { + @Override + public void run() { + try { + connect(); + } catch (IOException e) { + LOG.log(Level.INFO, e.getMessage(), e); + throw new RuntimeException(e); + } + } + }; + timer.schedule(task, retry); + } + } + + private synchronized void processProgressEvent(String message) { + LOG.entering(CLASS_NAME, "processProgressEvent", message); + String line; + try { + messageBuffer = messageBuffer + message; + String field; + String value; + immediateReconnect = false; + while (!aborted && !errored) { + line = fetchLineFromBuffer(); + if (line == null) { + break; + } + + if (line.length() == 0 && dataBuffer.length() > 0) { + synchronized (dataBuffer) { + int dataBufferlength = dataBuffer.length(); + if (dataBuffer.charAt(dataBufferlength - 1) == '\n') { + dataBuffer.replace(dataBufferlength - 1, dataBufferlength, ""); + } + doMessage(name, dataBuffer.toString()); + dataBuffer.setLength(0); + } + } + + int colonAt = line.indexOf(':'); + if (colonAt == -1) { + // no colon, line is field name with empty value + field = line; + value = ""; + } else if (colonAt == 0) { + // leading colon indicates comment line + continue; + } else { + field = line.substring(0, colonAt); + int valueAt = colonAt + 1; + if (line.length() > valueAt && line.charAt(valueAt) == ' ') { + valueAt++; + } + value = line.substring(valueAt); + } + // process the field of completed event + switch (field) { + case "event": + name = value; + break; + case "id": + this.lastEventId = value; + break; + case "retry": + retry = Integer.parseInt(value); + break; + case "data": + // deliver event if data is specified and non-empty, or name is specified and not "message" + if (name != null && name.length() > 0 && !MESSAGE.equals(name)) { + dataBuffer.append(value).append("\n"); + } + break; + case "location": + if (value.length() > 0) { + this.sseLocation = value; + } + break; + case "reconnect": + immediateReconnect = true; + break; + } + } + + if (immediateReconnect) { + retry = 0; + // this will be done on the load + } + } catch (Exception e) { + LOG.log(Level.INFO, e.getMessage(), e); + doError(e); + } + } + + private String fetchLineFromBuffer() { + LOG.entering(CLASS_NAME, "fetchLineFromBuffer"); + int lf = this.messageBuffer.indexOf("\n"); + if (lf == -1) { + lf = this.messageBuffer.indexOf("\r"); + } + if (lf != -1) { + String ret = messageBuffer.substring(0, lf); + messageBuffer = messageBuffer.substring(lf + 1); + return ret; + } + return null; + } + + private class EventStreamHttpRequestListener implements HttpRequestListener { + private final String CLASS_NAME = EventStreamHttpRequestListener.class.getName(); + private final Logger LOG = Logger.getLogger(CLASS_NAME); + + boolean reconnectScheduled = false; + + EventStreamHttpRequestListener() { + LOG.entering(CLASS_NAME, ""); + } + + @Override + public void requestReady(HttpRequest request) { + } + + @Override + public void requestOpened(HttpRequest request) { + doOpen(); + } + + @Override + public void requestProgressed(HttpRequest request, WrappedByteBuffer payload) { + progressEventReceived.set(true); + String response = payload.getString(UTF_8); + processProgressEvent(response); + } + + @Override + public void requestLoaded(HttpRequest request, HttpResponse response) { + // for Long polling. If we get an onload we have to + // reconnect. + if (readyState != ReadyState.CLOSED) { + if (immediateReconnect) { + retry = 0; + if (!reconnectScheduled) { + reconnect(); + } + } + } + } + + @Override + public void requestAborted(HttpRequest request) { + } + + @Override + public void requestClosed(HttpRequest request) { + } + + @Override + public void errorOccurred(HttpRequest request, Exception exception) { + doError(exception); + } + } + + private void doOpen() { + /* + Only file the event once in the case its already opened, + Currently, this is being called twice, once when the SSE + gets connected and then again when the ready state changes. + */ + if (readyState == ReadyState.CONNECTING) { + readyState = ReadyState.OPEN; + listener.streamOpened(); + } + } + + private void doMessage(String eventName, String data) { + // messages before OPEN and after CLOSE should not be delivered. + if (getReadyState() != ReadyState.OPEN) { + return; + } + + listener.messageReceived(eventName, data); + } + + private void doError(Exception exception) { + if (getReadyState() == ReadyState.CLOSED) { + return; + } + + // TODO: Set readyState to CLOSED? + errored = true; + listener.streamErrored(exception); + } + + public void setListener(SseEventStreamListener listener) { + this.listener = listener; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStreamListener.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStreamListener.java new file mode 100644 index 0000000..a9fba75 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStreamListener.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +public interface SseEventStreamListener { + + void streamOpened(); + void messageReceived(String eventName, String data); + void streamErrored(Exception exception); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SsePayload.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SsePayload.java new file mode 100644 index 0000000..ecd3c5a --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SsePayload.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +public class SsePayload { + + private String _eventName; + private String _data; + + public SsePayload(String eventName, String data) { + _eventName = eventName; + _data = data; + } + + public String getData() { + return _data; + } + + public String getEventName() { + return _eventName; + } + + public void setData(String data) { + _data = data; + } + + public void setEventName(String eventName) { + if ((eventName == null) || (eventName.trim().length() == 0)) { + eventName = "message"; + } + + _eventName = eventName; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnection.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnection.java new file mode 100644 index 0000000..564e029 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnection.java @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; + +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.sse.SseEventReader; +import org.kaazing.net.sse.SseEventType; + +public abstract class SseURLConnection extends URLConnection { + + protected SseURLConnection(URL url) { + super(url); + } + + /** + * Disconnects with the server. This is a blocking call that returns only + * when the shutdown is complete. + * + * @throws IOException if the disconnect did not succeed + */ + public abstract void close() throws IOException; + + /** + * Connects with the server using an end-point. This is a blocking call. The + * thread invoking this method will be blocked till a successful connection + * is established. If the connection cannot be established, then an + * IOException is thrown and the thread is unblocked. + * + * @throws IOException if the connection cannot be established + */ + @Override + public abstract void connect() throws IOException; + + /** + * Returns a {@link SseEventReader} that can be used to receive + * events based on the {@link SseEventType}. + *

    + * If this method is invoked before a connection is established successfully, + * then an IOException is thrown. + * + * @return SseEventReader to receive events + * @throws IOException if invoked before the connection is opened + */ + public abstract SseEventReader getEventReader() throws IOException; + + /** + * Returns {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). The default option is + * {@link HttpRedirectPolicy#NONE}. + * + * @return HttpRedirectOption indicating the + */ + public abstract HttpRedirectPolicy getFollowRedirect(); + + /** + * Returns the retry timeout in milliseconds. The default is 3000ms. + * + * @return timeout interval in milliseconds + */ + public abstract long getRetryTimeout(); + + /** + * Sets {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). + * + * @param option HttpRedirectOption to used for following the + * redirects + */ + public abstract void setFollowRedirect(HttpRedirectPolicy option); + + /** + * Sets the retry timeout interval specified in milliseconds. + * + * @param millis retry timeout + */ + public abstract void setRetryTimeout(long millis); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnectionImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnectionImpl.java new file mode 100644 index 0000000..937344e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnectionImpl.java @@ -0,0 +1,281 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URL; +import java.security.Permission; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.sse.SseEventReader; + +public class SseURLConnectionImpl extends SseURLConnection { + private static final String _CLASS_NAME = SseURLConnectionImpl.class.getName(); + private static final Logger _LOG = Logger.getLogger(_CLASS_NAME); + + private SseEventSourceImpl _eventSource; + + public SseURLConnectionImpl(URL url) { + super(url); + _eventSource = new SseEventSourceImpl(URI.create(url.toString())); + } + + @Override + public void close() throws IOException { + _eventSource.close(); + } + + @Override + public void connect() throws IOException { + _LOG.entering(_CLASS_NAME, "connect"); + _eventSource.connect(); + } + + @Override + public SseEventReader getEventReader() throws IOException { + return _eventSource.getEventReader(); + } + + + @Override + public HttpRedirectPolicy getFollowRedirect() { + return _eventSource.getFollowRedirect(); + } + + @Override + public long getRetryTimeout() { + return _eventSource.getRetryTimeout(); + } + + @Override + public void setFollowRedirect(HttpRedirectPolicy option) { + _eventSource.setFollowRedirect(option); + } + + @Override + public void setRetryTimeout(long millis) { + _eventSource.setRetryTimeout(millis); + } + + // --------------- Unsupported URLConnection Methods ---------------------- + @Override + public void addRequestProperty(String key, String value) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getConnectTimeout() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setConnectTimeout(int timeout) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getReadTimeout() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setReadTimeout(int timeout) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + // @Override -- Not available in JDK 6. + public long getContentLengthLong() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getContentLength() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getContentType() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getContentEncoding() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getExpiration() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getDate() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getLastModified() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getHeaderField(String name) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Map> getHeaderFields() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getHeaderFieldInt(String name, int Default) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + // @Override -- Not available in JDK 6. + public long getHeaderFieldLong(String name, long Default) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getHeaderFieldDate(String name, long Default) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getHeaderFieldKey(int n) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getHeaderField(int n) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Object getContent() throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @SuppressWarnings("rawtypes") + @Override + public Object getContent(Class[] classes) throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Permission getPermission() throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getDoInput() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setDoInput(boolean doinput) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getDoOutput() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setDoOutput(boolean dooutput) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getAllowUserInteraction() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getUseCaches() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setUseCaches(boolean usecaches) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getIfModifiedSince() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setIfModifiedSince(long ifmodifiedsince) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getDefaultUseCaches() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getRequestProperty(String key) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Map> getRequestProperties() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setRequestProperty(String key, String value) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public InputStream getInputStream() throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public OutputStream getOutputStream() throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSource.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSource.java new file mode 100644 index 0000000..4a37cf5 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSource.java @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.legacy; + +import java.io.IOException; +import java.util.logging.Logger; + + + +/* +import org.kaazing.gateway.client.impl.sse.EventStream; +import org.kaazing.gateway.client.impl.sse.EventStreamListener; +*/ +/** + * EventSource provides an implementation of HTML5 Server-sent Events. Refer to HTML5 EventSource at {@link http + * ://www.whatwg.org/specs/web-apps/current-work/#server-sent-events} {@link http + * ://www.whatwg.org/specs/web-apps/current-work/#the-event-source} + */ +public class EventSource { + private static final String CLASS_NAME = EventSource.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private EventSource _delegate; + + /** + * State of the Event Source. CONNECTING = 0, OPEN = 1 and CLOSED = 2 + */ + public enum ReadyState { + CONNECTING, OPEN, CLOSED + }; + + /** + * EventSource provides a text-based stream abstraction for Java + */ + public EventSource() { + LOG.entering(CLASS_NAME, ""); + } + + /** + * The ready state indicates the stream status, Possible values are 0 (CONNECTING), 1 (OPEN) and 2 (CLOSED) + * + * @return current state + */ + public ReadyState getReadyState() { + return _getDelegate().getReadyState(); + } + + + /** + * Connects the EventSource instance to the stream location. + * + * @param eventSourceUrl + * stream location + * @throws IOException + * on error + */ + public void connect(String eventSourceUrl) throws IOException { + LOG.entering(CLASS_NAME, "connect", eventSourceUrl); + _getDelegate().connect(eventSourceUrl); + } + + /** + * Disconnects the stream. + */ + public void disconnect() { + LOG.entering(CLASS_NAME, "disconnect"); + _getDelegate().disconnect(); + } + + /** + * Register a listener for EventSource events + * + * @param listener + */ + public void addEventSourceListener(EventSourceListener listener) { + LOG.entering(CLASS_NAME, "addEventSourceListener", listener); + _getDelegate().addEventSourceListener(listener); + } + + /** + * Removes the given EventSource listener from the listener list. + * + * @param listener + * EventSourceListener to be unregistered + */ + public void removeEventSourceListener(EventSourceListener listener) { + LOG.entering(CLASS_NAME, "removeEventSourceListener", listener); + _getDelegate().removeEventSourceListener(listener); + } + + private EventSource _getDelegate() { + if (_delegate != null) { + return _delegate; + } + + try { + _delegate = (EventSource)Class.forName("org.kaazing.net.sse.impl.legacy.EventSourceImpl").newInstance(); + } catch (Exception e) { + throw new Error("Cannot instantiate default EventSourceImpl"); + } + + return _delegate; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceAdapter.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceAdapter.java new file mode 100644 index 0000000..8fd4fed --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceAdapter.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.legacy; + + +/** + * An adapter class for {@link EventSourceListener}. Use this as a base class to + * override selected methods only. + * + */ +public class EventSourceAdapter implements EventSourceListener { + + @Override + public void onError(EventSourceEvent error) { + + } + + @Override + public void onMessage(EventSourceEvent message) { + + } + + @Override + public void onOpen(EventSourceEvent open) { + + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceEvent.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceEvent.java new file mode 100644 index 0000000..186ef94 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceEvent.java @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.legacy; + +import java.util.EventObject; +import java.util.logging.Logger; + +/** + * This class represents events generated by the EventSource object + * + */ +public class EventSourceEvent extends EventObject { + private static final String CLASS_NAME = EventSourceEvent.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private static final long serialVersionUID = -3654347840399311101L; + private final Type type; + private final String data; + + /** + * Type of the EventSourceEvent. + */ + public enum Type { + OPEN, MESSAGE, ERROR + } + + /** + * + * @param source + * @param type + */ + public EventSourceEvent(Object source, Type type) { + this(source, type, null); + } + + public EventSourceEvent(Object source, Type type, String message) { + super(source); + LOG.entering(CLASS_NAME, "", new Object[] { source, type, message }); + this.type = type; + this.data = message; + } + + /** + * Get type of the EventSourceEvent OPEN, MESSAGE or ERROR + * + * @return the EventSourceEvent type + */ + public Type getType() { + LOG.exiting(CLASS_NAME, "getType", type); + return type; + } + + /** + * Returns the message data delivered by the event source + * + * @return the message data + */ + public String getData() { + LOG.exiting(CLASS_NAME, "getData", data); + return data; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceImpl.java new file mode 100644 index 0000000..2213fbb --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceImpl.java @@ -0,0 +1,180 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.legacy; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.net.sse.impl.SseEventStream; +import org.kaazing.net.sse.impl.SseEventStreamListener; + + + +/** + * EventSourceImpl provides an implementation of HTML5 Server-sent Events. Refer + * to HTML5 EventSource at {@link http + * ://www.whatwg.org/specs/web-apps/current-work/#server-sent-events} {@link http + * ://www.whatwg.org/specs/web-apps/current-work/#the-event-source} + */ +public class EventSourceImpl extends EventSource { + private static final String CLASS_NAME = EventSourceImpl.class.getName(); + private static final Logger LOG = Logger.getLogger(CLASS_NAME); + + private SseEventStream stream = null; + private final List listeners = new ArrayList(); + + /** + * EventSource provides a text-based stream abstraction for Java + */ + public EventSourceImpl() { + LOG.entering(CLASS_NAME, ""); + } + + /** + * The ready state indicates the stream status, Possible values are 0 (CONNECTING), 1 (OPEN) and 2 (CLOSED) + * + * @return current state + */ + public ReadyState getReadyState() { + if (stream == null) { + return EventSource.ReadyState.CONNECTING; + } + else { + switch (stream.getReadyState()) { + case CONNECTING: + return EventSource.ReadyState.CONNECTING; + + case OPEN: + return EventSource.ReadyState.OPEN; + + case CLOSING: + case CLOSED: + default: + return EventSource.ReadyState.CLOSED; + } + } + } + + /** + * Connects the EventSource instance to the stream location. + * + * @param eventSourceUrl + * stream location + * @throws IOException + * on error + */ + public void connect(String eventSourceUrl) throws IOException { + LOG.entering(CLASS_NAME, "connect", eventSourceUrl); + if (stream != null) { + LOG.warning("Reusing the same event source for a differnt URL, please create a new EventSource object"); + throw new IllegalArgumentException( + "Reusing the same event source for a differnt URL, please create a new EventSource object"); + } + stream = new SseEventStream(eventSourceUrl); + stream.setListener(eventStreamListener); + stream.connect(); + } + + /** + * Disconnects the stream. + */ + public void disconnect() { + LOG.entering(CLASS_NAME, "disconnect"); + stream.stop(); + stream = null; + } + + /** + * Register a listener for EventSource events + * + * @param listener + */ + public void addEventSourceListener(EventSourceListener listener) { + LOG.entering(CLASS_NAME, "addEventSourceListener", listener); + if (listener == null) { + throw new NullPointerException("listener"); + } + listeners.add(listener); + } + + /** + * Removes the given EventSource listener from the listener list. + * + * @param listener + * EventSourceListener to be unregistered + */ + public void removeEventSourceListener(EventSourceListener listener) { + LOG.entering(CLASS_NAME, "removeEventSourceListener", listener); + if (listener == null) { + throw new NullPointerException("listener"); + } + listeners.remove(listener); + } + + private SseEventStreamListener eventStreamListener = new SseEventStreamListener() { + + @Override + public void streamOpened() { + LOG.entering(CLASS_NAME, "streamOpened"); + + EventSourceEvent event = new EventSourceEvent(this, EventSourceEvent.Type.OPEN); + for (EventSourceListener listener : listeners) { + try { + listener.onOpen(event); + } catch (RuntimeException e) { + LOG.logp(Level.WARNING, CLASS_NAME, "onOpen", "Application threw an exception during onOpen: "+e.getMessage(), e); + } + } + } + + @Override + public void messageReceived(String eventName, String message) { + LOG.entering(CLASS_NAME, "fireMessageListeners", message); + + EventSourceEvent event = new EventSourceEvent(this, EventSourceEvent.Type.MESSAGE, message); + for (EventSourceListener listener : listeners) { + try { + listener.onMessage(event); + } catch (RuntimeException e) { + LOG.logp(Level.WARNING, CLASS_NAME, "onMessage", "Application threw an exception during onMessage: "+e.getMessage(), e); + } + } + } + + @Override + public void streamErrored(Exception exception) { + LOG.entering(CLASS_NAME, "fireErrorListeners"); + + EventSourceEvent event = new EventSourceEvent(this, EventSourceEvent.Type.ERROR); + for (EventSourceListener listener : listeners) { + try { + listener.onError(event); + } catch (RuntimeException e) { + LOG.logp(Level.WARNING, CLASS_NAME, "onError", "Application threw an exception during onError: "+e.getMessage(), e); + } + } + } + }; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceListener.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceListener.java new file mode 100644 index 0000000..27e97b7 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceListener.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.legacy; + +import java.util.EventListener; + +/** + * Interface for the listener listening to events on the EventSource object + * + */ +public interface EventSourceListener extends EventListener{ + + /** + * Called when the EventSource is opened + * + * @param open EventSourceEvent of type OPEN + */ + public void onOpen(EventSourceEvent open); + + /** + * Called on the receipt of a message from the EventSource + * + * @param message EventSourceEvent of type MESSAGE + */ + public void onMessage(EventSourceEvent message); + + /** + * Called on the receipt of an error from the EventSource + * + * @param error EventSourceEvent of type ERROR + */ + public void onError(EventSourceEvent error); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerFactorySpiImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerFactorySpiImpl.java new file mode 100644 index 0000000..d4aa698 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerFactorySpiImpl.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.url; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; + +import java.net.URLStreamHandler; +import java.util.Collection; + +import org.kaazing.net.URLStreamHandlerFactorySpi; + +public class SseURLStreamHandlerFactorySpiImpl extends URLStreamHandlerFactorySpi { + private static final Collection _supportedProtocols = unmodifiableList(asList("sse")); + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if (!_supportedProtocols.contains(protocol)) { + String s = String.format("Protocol not supported '%s'", protocol); + throw new IllegalArgumentException(s); + } + + return new SseURLStreamHandlerImpl(protocol); + } + + @Override + public Collection getSupportedProtocols() { + return _supportedProtocols; + } + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerImpl.java new file mode 100644 index 0000000..54f1f54 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerImpl.java @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.sse.impl.url; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +import org.kaazing.net.sse.impl.SseURLConnectionImpl; + +public class SseURLStreamHandlerImpl extends URLStreamHandler { + + private String _scheme; + + public SseURLStreamHandlerImpl(String scheme) { + _scheme = scheme; + } + + @Override + protected URLConnection openConnection(URL location) throws IOException { + return new SseURLConnectionImpl(location); + } + + @Override + protected int getDefaultPort() { + return 80; + } + + @Override + protected void parseURL(URL location, String spec, int start, int limit) { + _scheme = spec.substring(0, spec.indexOf("://")); + + URI specURI = _getSpecURI(spec); + String host = specURI.getHost(); + int port = specURI.getPort(); + String authority = specURI.getAuthority(); + String userInfo = specURI.getUserInfo(); + String path = specURI.getPath(); + String query = specURI.getQuery(); + + setURL(location, _scheme, host, port, authority, userInfo, path, query, null); + } + + // ----------------- Private Methods ----------------------------------- + // Creates a URI that can be used to retrieve various parts such as host, + // port, etc. Based on whether the scheme includes ':', the method returns + // the appropriate URI that can be used to retrieve the needed parts. + private URI _getSpecURI(String spec) { + URI specURI = URI.create(spec); + + if (_scheme.indexOf(':') == -1) { + return specURI; + } + + String schemeSpecificPart = specURI.getSchemeSpecificPart(); + return URI.create(schemeSpecificPart); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocket.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocket.java new file mode 100644 index 0000000..4b43403 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocket.java @@ -0,0 +1,433 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.util.Collection; + +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.ws.WebSocketExtension.Parameter; + + +/** + * WebSocket provides bi-directional communications for text and binary + * messaging via the Kaazing Gateway. + *

    + * Refer to {@link http://www.w3.org/TR/websockets/} for the published standard + * W3C WebSocket API specification. + *

    + * Instances of {@link WebSocket} can be created using + * {@link WebSocketFactory#createWebSocket(java.net.URI, String...)} API. + */ +public abstract class WebSocket implements Closeable { + /** + * Disconnects with the server. This is a blocking call that returns only + * when the shutdown is complete. If the connection is already closed, then + * this method has no effect. + * + * @throws IOException if the disconnect did not succeed + */ + public abstract void close() throws IOException; + + /** + * Disconnects with the server with code. This is a blocking + * call that returns only when the shutdown is complete. If the connection + * is already closed, then this method has no effect. + * + * @param code the error code for closing + * @throws IOException if the disconnect did not succeed + * @throws IllegalArgumentException if the code isn't 1000 or out of + * range 3000 - 4999. + */ + public abstract void close(int code) + throws IOException; + + /** + * Disconnects with the server with code and reason. This is a blocking + * call that returns only when the shutdown is complete. If the connection + * is already closed, then this method has no effect. + * + * @param code the error code for closing + * @param reason the reason for closing + * @throws IOException if the disconnect did not succeed + * @throws IllegalArgumentException if the code isn't 1000 or out of + * range 3000 - 4999 OR if the reason + * is more than 123 bytes + */ + public abstract void close(int code, String reason) + throws IOException; + + /** + * Connects with the server using an endpoint. This is a blocking call. The + * thread invoking this method will be blocked till a successful connection + * is established. + *

    + * If the connection cannot be established, then an IOException is thrown + * and the thread is unblocked. + *

    + * An IllegalStateException is thrown if required parameters of an enabled + * extension has not been set/enabled. + * + * @throws IOException if the connection cannot be established + * @throws IllegalStateException if the required parameters of an enabled + * extension are not set/enabled + */ + public abstract void connect() throws IOException; + + /** + * Gets the {@link ChallengeHandler} that is used during authentication + * both at the connect-time as well as at subsequent revalidation-time that + * occurs at regular intervals. + * + * @return ChallengeHandler + */ + public abstract ChallengeHandler getChallengeHandler(); + + /** + * Gets the connect timeout in milliseconds. The timeout will expire if + * there is no exchange of packets(for example, 100% packet loss) while + * establishing the connection. A timeout value of zero indicates + * no timeout. Default connect timeout is zero. + * + * @return connect timeout value in milliseconds + */ + public abstract int getConnectTimeout(); + + /** + * Gets the names of all the extensions that have been enabled for this + * connection. The enabled extensions are negotiated between the client + * and the server during the handshake only if all the required parameters + * belonging to the extension have been set as enabled parameters. The + * names of the negotiated extensions can be obtained using + * {@link #getNegotiatedExtensions()} API. An empty Collection is returned + * if no extensions have been enabled for this connection. The enabled + * extensions will be a subset of the supported extensions. + * + * @return Collection names of the enabled extensions for this + * connection + */ + public abstract Collection getEnabledExtensions(); + + /** + * Gets the value of the specified {@link Parameter} defined in an enabled + * extension. If the parameter is not defined for this connection but a + * default value for the parameter is set using the method + * {@link WebSocketFactory#setDefaultParameter(Parameter, Object)}, + * then the default value is returned. + *

    + * Setting the parameter value when the connection is successfully + * established will result in an IllegalStateException. + *

    + * @param Generic type of the value of the Parameter + * @param parameter Parameter whose value needs to be set + * @return the value of the specified parameter + * @throw IllegalStateException if this method is invoked after connect() + */ + public abstract T getEnabledParameter(Parameter parameter); + + /** + * Gets the names of all the protocols that are enabled for this + * connection. Returns an empty Collection if protocols are not enabled. + * + * @return Collection supported protocols by this connection + */ + public abstract Collection getEnabledProtocols(); + + /** + * Returns {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (status code 3xx). The default options is + * {@link HttpRedirectPolicy.ALWAYS}. + * + * @return HttpRedirectOption option for following redirects(status + * code 3XX) + */ + public abstract HttpRedirectPolicy getRedirectPolicy(); + + /** + * Returns the {@link InputStream} to receive binary messages. The + * methods on {@link InputStream} will block till the message arrives. The + * {@link InputStream} must be used to only receive binary + * messages. + *

    + * An IOException is thrown if this method is invoked when the connection + * has not been established. Receiving a text message using the + * {@link InputStream} will result in an IOException. + *

    + * Once the connection is closed, a new {@link InputStream} should be + * obtained using this method after the connection has been established. + * Using the old InputStream will result in an IOException. + *

    + * @return InputStream to receive binary messages + * @throws IOException if the method is invoked before the connection is + * successfully opened; if a text message is being + * read using the InputStream + */ + public abstract InputStream getInputStream() throws IOException; + + /** + * Returns a {@link WebSocketMessageReader} that can be used to receive + * binary and text messages based on the + * {@link WebSocketMessageType}. + *

    + * If this method is invoked before a connection is established successfully, + * then an IOException is thrown. + *

    + * Once the connection is closed, a new {@link WebSocketMessageReader} + * should be obtained using this method after the connection has been + * established. Using the old WebSocketMessageReader will result in an + * IOException. + *

    + * @return WebSocketMessageReader to receive binary and text messages + * @throws IOException if invoked before the connection is opened + */ + public abstract WebSocketMessageReader getMessageReader() throws IOException; + + /** + * Returns a {@link WebSocketMessageWriter} that can be used to send + * binary and text messages. + *

    + * If this method is invoked before a connection is established + * successfully, then an IOException is thrown. + *

    + * Once the connection is closed, a new {@link WebSocketMessageWriter} + * should be obtained using this method after the connection has been + * established. Using the old WebSocketMessageWriter will result in an + * IOException. + *

    + * @return WebSocketMessageWriter to send binary and text messages + * @throws IOException if invoked before the connection is opened + */ + public abstract WebSocketMessageWriter getMessageWriter() throws IOException; + + /** + * Gets names of all the enabled extensions that have been successfully + * negotiated between the client and the server during the initial + * handshake. + *

    + * Returns an empty Collection if no extensions were negotiated between the + * client and the server. The negotiated extensions will be a subset of the + * enabled extensions. + *

    + * If this method is invoked before a connection is successfully established, + * an IllegalStateException is thrown. + * + * @return Collection successfully negotiated using this + * connection + * @throws IllegalStateException if invoked before the {@link #connect()} + * completes + */ + public abstract Collection getNegotiatedExtensions(); + + /** + * Returns the value of the specified {@link Parameter} of a negotiated + * extension. + *

    + * If this method is invoked before the connection is successfully + * established, an IllegalStateException is thrown. + *

    + * Once the connection is closed, the negotiated parameters are cleared. + * Trying to retrieve the value will result in an IllegalStateException. + *

    + * @param parameter type + * @param parameter parameter of a negotiated extension + * @return T value of the specified parameter + * @throws IllegalStateException if invoked before the {@link #connect()} + * completes + */ + public abstract T getNegotiatedParameter(Parameter parameter); + + /** + * Gets the protocol that the client and the server have successfully + * negotiated. + *

    + * If this method is invoked before the connection is successfully + * established, an IllegalStateException is thrown. + *

    + * Once the connection is closed, trying to retrieve the negotiated + * protocol will result in an IllegalStateException. + *

    + * @return protocol negotiated by the client and the server + * @throws IllegalStateException if invoked before the {@link #connect()} + * completes + */ + public abstract String getNegotiatedProtocol(); + + /** + * Returns the {@link OutputStream} to send binary messages. The + * message is put on the wire only when {@link OutputStream#flush()} is + * invoked. + *

    + * If this method is invoked before {@link #connect()} is complete, an + * IOException is thrown. + *

    + * Once the connection is closed, a new {@link OutputStream} should + * be obtained using this method after the connection has been + * established. Using the old OutputStream will result in IOException. + *

    + * @return OutputStream to send binary messages + * @throws IOException if the method is invoked before the connection is + * successfully opened + */ + public abstract OutputStream getOutputStream() throws IOException; + + /** + * Returns a {@link Reader} to receive text messages from this + * connection. This method should be used to only to receive text + * messages. Methods on {@link Reader} will block till a message arrives. + *

    + * If the Reader is used to receive binary messages, then an + * IOException is thrown. + *

    + * If this method is invoked before a connection is established + * successfully, then an IOException is thrown. + *

    + * Once the connection is closed, a new {@link Reader} should be obtained + * using this method after the connection has been established. Using the + * old Reader will result in an IOException. + *

    + * @return Reader used to receive text messages from this connection + * @throws IOException if the method is invoked before the connection is + * successfully opened + */ + public abstract Reader getReader() throws IOException; + + /** + * Returns the names of supported extensions that have been discovered. An + * empty Collection is returned if no extensions were discovered. + * + * @return Collection extension names discovered for this + * connection + */ + public abstract Collection getSupportedExtensions(); + + /** + * Returns a {@link Writer} to send text messages from this + * connection. The message is put on the wire only when + * {@link Writer#flush()} is invoked. + *

    + * An IOException is thrown if this method is invoked when the connection + * has not been established. + *

    + * Once the connection is closed, a new {@link Writer} should be obtained + * using this method after the connection has been established. Using the + * old Writer will result in an IOException. + *

    + * @return Writer used to send text messages from this connection + * @throws IOException if the method is invoked before the connection is + * successfully opened + */ + public abstract Writer getWriter() throws IOException; + + /** + * Sets the {@link ChallengeHandler} that is used during authentication + * both at the connect-time as well as at subsequent revalidation-time that + * occurs at regular intervals. + * + * @param challengeHandler ChallengeHandler used for authentication + */ + public abstract void setChallengeHandler(ChallengeHandler challengeHandler); + + /** + * Sets the connect timeout in milliseconds. The timeout will expire if + * there is no exchange of packets(for example, 100% packet loss) while + * establishing the connection. A timeout value of zero indicates + * no timeout. + * + * @param connectTimeout timeout value in milliseconds + * @throws IllegalStateException if the connect timeout is being set + * after the connection has been established + * @throws IllegalArgumentException if connectTimeout is negative + */ + public abstract void setConnectTimeout(int connectTimeout); + + /** + * Registers the names of all the extensions that must be negotiated between + * the client and the server during the handshake. This method must be + * invoked before invoking the {@link #connect()} method. The + * enabled extensions should be a subset of the supported extensions. Only + * the extensions that are explicitly enabled are put on the wire even + * though there could be more supported extensions on this connection. + *

    + * If this method is invoked after connection is successfully established, + * an IllegalStateException is thrown. If an enabled extension is not + * discovered as a supported extension, then IllegalStateException is thrown. + *

    + * @param extensions list of extensions to be negotiated with the server + * during the handshake + * @throw IllegalStateException if this method is invoked after successful + * connection or any of the specified + * extensions is not a supported extension + */ + public abstract void setEnabledExtensions(Collection extensions); + + /** + * Sets the value of the specified {@link Parameter} defined in an enabled + * extension. The application developer should set the extension + * parameters of the enabled extensions before invoking the + * {@link #connect()} method. + *

    + * Setting the parameter value when the connection is successfully + * established will result in an IllegalStateException. + *

    + * If the parameter has a default value that was specified using + * {@link WebSocketFactory#setDefaultParameter(Parameter, Object)}, + * then setting the same parameter using this method will override the + * default value. + *

    + * @param extension parameter type + * @param parameter Parameter whose value needs to be set + * @param value of the specified parameter + * @throw IllegalStateException if this method is invoked after connect() + */ + public abstract void setEnabledParameter(Parameter parameter, + T value); + + /** + * Registers the protocols to be negotiated with the server during the + * handshake. This method must be invoked before {@link #connect()} is + * called. + *

    + * If this method is invoked after a connection has been successfully + * established, an IllegalStateException is thrown. + *

    + * @param extensions list of extensions to be negotiated with the server + * during the handshake + * @throw IllegalStateException if this method is invoked after connect() + */ + public abstract void setEnabledProtocols(Collection protocols); + + /** + * Sets {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). + * + * @param option HttpRedirectOption to used for following the + * redirects + */ + public abstract void setRedirectPolicy(HttpRedirectPolicy option); + +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketException.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketException.java new file mode 100644 index 0000000..19bf720 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketException.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class WebSocketException extends IOException { + private static final long serialVersionUID = 1L; + private static final Map _STATUS_CODES; + + static { + _STATUS_CODES = new HashMap(); + _STATUS_CODES.put(1000, "Connection has been closed normally"); + _STATUS_CODES.put(1001, "End-point is going away"); + _STATUS_CODES.put(1002, "Connection terminated due to protocol error"); + _STATUS_CODES.put(1003, "Connection terminated due to incorrect message type"); + _STATUS_CODES.put(1004, "Reserved for future use"); + _STATUS_CODES.put(1005, "No status code was present"); + _STATUS_CODES.put(1006, "Connection was closed abnormally, e.g., without sending or receiving a Close control frame."); + _STATUS_CODES.put(1007, "Connection terminated due to inconsistency between the data and the message type"); + _STATUS_CODES.put(1008, "Connection terminated as the received a message violates the policy"); + _STATUS_CODES.put(1009, "Connection terminated as the received message is too big to process"); + _STATUS_CODES.put(1010, "Connection terminated by the client because an extension could not be negotiated with the server during the handshake"); + _STATUS_CODES.put(1011, "Connection terminated by the server because of an unexpected condition"); + _STATUS_CODES.put(1015, "Connection was closed due to a failure to perform a TLS handshake"); + } + + private int _code = 0; + + public WebSocketException(String reason) { + super(reason); + } + + public WebSocketException(Exception ex) { + super(ex); + } + + public WebSocketException(String reason, Exception ex) { + super(reason, ex); + } + + public WebSocketException(int code, String reason) { + super((_STATUS_CODES.get(code) == null) ? reason : _STATUS_CODES.get(code)); + _code = code; + } + + public WebSocketException(int code, String reason, Exception ex) { + super((_STATUS_CODES.get(code) == null) ? reason : _STATUS_CODES.get(code), + ex); + _code = code; + } + + public int getCode() { + return _code; + } + + public String getReason() { + String s = _STATUS_CODES.get(_code); + if (s == null) { + return super.getMessage(); + } + + return s; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketExtension.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketExtension.java new file mode 100644 index 0000000..e08e30c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketExtension.java @@ -0,0 +1,315 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import org.kaazing.net.ws.WebSocketExtension.Parameter.Metadata; + +/** + * {@link WebSocketExtension} should be extended to define or register + * {@link Parameter}s constants that will be used by the + * application developers. + */ +public abstract class WebSocketExtension { + private static final Map _extensions; + + static { + _extensions = new HashMap(); + }; + + private Collection> _parameters; + + /** + * Creates an {@link WebSocketExtensionParamter} of the specified type. + * + * @param generic parameter type + * @param parameterName name of the parameter + * @param parameterType Class object representing the parameter type + * @param parameterMetadata characteristics of the parameter + * @return Parameter of the specified type + */ + protected Parameter createParameter(String parameterName, + Class parameterType, + EnumSet parameterMetadata) { + if ((parameterName == null) || (parameterName.trim().length() == 0)) { + String s = "Parameter name cannot be null or empty"; + throw new IllegalArgumentException(s); + } + + if (parameterType == null) { + String s = String.format("Null type specified for parameter '%s'", + parameterName); + throw new IllegalArgumentException(s); + } + + Parameter parameter = new Parameter(this, + parameterName, + parameterType, + parameterMetadata); + _parameters.add(parameter); + + return parameter; + } + + /** + * Protected constructor to be invoked by the sub-class constructor. + * + * @param name name of the WebSocketExtension + */ + protected WebSocketExtension() { + _parameters = new ArrayList>(); + _extensions.put(name(), this); + } + + /** + * Returns the {@link WebSocketExtension} with the specified name. A null + * is returned if there are no extensions with the specified name. + * + * @param name name of the WebSocketExtension + * @return WebSocketExtension + */ + public static WebSocketExtension getWebSocketExtension(String name) { + return _extensions.get(name); + } + + /** + * Returns the {@link Parameter} defined in this + * {@link WebSoketExtension} with the specified name. + * + * @param name parameter's name + * @return Parameter + */ + public Parameter getParameter(String name) { + Collection> extnParameters = getParameters(); + + for (Parameter extnParameter : extnParameters) { + if (extnParameter.name().equals(name)) { + return extnParameter; + } + } + + return null; + } + + /** + * Returns all the {@link Parameter}s that are defined in this + * {@link WebSocketExtension}. An empty Collection is returned if there + * are no {@link Parameter}s defined. + * + * @return Collection of WebSocketExtensionParameters + */ + public Collection> getParameters() { + if (_parameters == null) { + return Collections.>emptyList(); + } + + return Collections.unmodifiableCollection(_parameters); + } + + /** + * Returns {@link Parameter}s defined in this {@link WebSocketExtension} + * that match all the specified characteristics. An empty Collection is + * returned if none of the {@link Parameter}s defined in this + * {@link WebSocketExtension} match all the specified characteristics. + * + * @return Collection of WebSocketExtensionParameters + */ + public Collection> getParameters(Metadata... characteristics) { + if ((characteristics == null) || (characteristics.length == 0)) { + return Collections.>emptySet(); + } + + EnumSet metadataSet = null; + int length = characteristics.length; + + if (length == 1) { + metadataSet = EnumSet.of(characteristics[0]); + } + else { + Metadata[] array = new Metadata[length -1]; + + // Start from the second(0-based index) element onwards to populate + // the array. + for (int i = 1; i < length; i++) { + array[i - 1] = characteristics[i]; + } + + metadataSet = EnumSet.of(characteristics[0], array); + } + + Collection> extnParameters = getParameters(); + Collection> result = new ArrayList>(); + + for (Parameter extnParameter : extnParameters) { + EnumSet paramMetadata = extnParameter.metadata(); + if (paramMetadata.containsAll(metadataSet)) { + result.add(extnParameter); + } + } + + return result; + } + + /** + * Returns the name of this {@link WebSocketExtension}. + * @return + */ + public abstract String name() ; + + /** + * {@link Parameter} represents an extension parameter. + * + * @param parameter type + */ + public static final class Parameter { + public enum Metadata { + /** + * Name of a parameter marked as anonymous will not be put on the wire + * during the handshake. By default, a parameter is considered "named" + * and it's name will be put on the wire during the handshake. + */ + ANONYMOUS, + + /** + * Parameters marked as required must be set for the entire extension + * to be negotiated during the handshake. By default, a parameter is + * considered to be optional. + */ + REQUIRED, + + /** + * Parameter marked as temporal will not be negotiated during the + * handshake. + */ + TEMPORAL; + }; + + private final WebSocketExtension _parent; + private final String _parameterName; + private final Class _parameterType; + private final EnumSet _parameterMetadata; + + public Parameter(WebSocketExtension parent, + String name, + Class type, + EnumSet metadata) { + if ((name == null) || (name.trim().length() == 0)) { + String s = String.format("Parameters must have a name"); + throw new IllegalArgumentException(s); + } + + if (parent == null) { + String s = String.format("Null parent specified for " + + "Parameter '%s'", name); + throw new IllegalArgumentException(s); + } + + if ((metadata == null) || metadata.isEmpty()) { + _parameterMetadata = EnumSet.noneOf(Metadata.class); + } + else { + _parameterMetadata = metadata; + } + + _parent = parent; + _parameterName = name; + _parameterType = type; + } + + /** + * Returns the parent {@link WebSocketExtension} that this parameter is + * defined in. + * + * @return String name of the extension + */ + public WebSocketExtension extension() { + return _parent; + } + + /** + * Indicates whether the parameter is anonymous or named. If the parameter + * is anonymous and it is not transient, then it's name is NOT put on the + * wire during the handshake. However, it's value is put on the wire. + * + * @return boolean true if the parameter is anonymous, false if the + * parameter is named + */ + public boolean anonymous() { + return _parameterMetadata.contains(Metadata.ANONYMOUS); + } + + /** + * Returns the metadata characteristics of this extension parameter. The + * returned EnumSet is a clone so any changes to it will not be picked by + * by the extension parameter. + * + * @return EnumSet characteristics of the extension parameter + */ + public EnumSet metadata() { + return _parameterMetadata.clone(); + } + + /** + * Returns the name of the parameter. + * + * @return String name of the parameter + */ + public String name() { + return _parameterName; + } + + /** + * Indicates whether the parameter is required. If the required parameter + * is not set, then the extension is not negotiated during the handshake. + * + * @return boolean true if the parameter is required, otherwise false + */ + public boolean required() { + return _parameterMetadata.contains(Metadata.REQUIRED); + } + + /** + * Indicates whether the parameter is temporal/transient. Temporal + * parameters are not put on the wire during the handshake. + * + * @return boolean true if the parameter is temporal, otherwise false + */ + public boolean temporal() { + return _parameterMetadata.contains(Metadata.TEMPORAL); + } + + /** + * Returns the type of the parameter. + * + * @return Class type of the parameter + */ + public Class type() { + return _parameterType; + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketFactory.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketFactory.java new file mode 100644 index 0000000..773fc7b --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketFactory.java @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.ServiceLoader; + +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.ws.WebSocketExtension.Parameter; + +/** + * {@link WebSocketFactory} is an abstract class that can be used to create + * {@link WebSocket}s by specifying the end-point and the enabled protocols. + * It may be extended to instantiate particular subclasses of {@link WebSocket} + * and thus provide a general framework for the addition of public + * WebSocket-level functionality. + *

    + * Using {@link WebSocketFactory} instance, application developers can set + * {@link Parameter}s that will be inherited by all the + * {@link WebSocket} instances created from the factory. Application developers + * can override the {@link Parameter}s at the individual + * {@link WebSocket} level too. + */ +public abstract class WebSocketFactory { + + protected WebSocketFactory() { + } + + /** + * Creates and returns a new instance of the default implementation of the + * {@link WebSocketFactory}. + * + * @return WebSocketFactory + */ + public static WebSocketFactory createWebSocketFactory() { + Class clazz = WebSocketFactory.class; + ServiceLoader loader = ServiceLoader.load(clazz); + return loader.iterator().next(); + } + + /** + * Creates a {@link WebSocket} to establish a full-duplex connection to the + * target location. + *

    + * The default extension parameters that were set on the + * {@link WebSocketFactory} prior to this call are inherited by the newly + * newly created {@link WebSocket} instance. + * + * @param location URI of the WebSocket service for the connection + * @throws URISyntaxException + */ + public abstract WebSocket createWebSocket(URI location) + throws URISyntaxException; + + /** + * Creates a {@link WebSocket} to establish a full-duplex connection to the + * target location with one of the specified protocols on a supported + * WebSocket provider. + *

    + * The default extension parameters that were set on the + * {@link WebSocketFactory} prior to this call are inherited by the newly + * newly created {@link WebSocket} instance. + * + * @param location URI of the WebSocket service for the connection + * @param protocols protocols to be negotiated over the WebSocket, or + * null for any protocol + * @throws URISyntaxException + */ + public abstract WebSocket createWebSocket(URI location, + String... protocols) + throws URISyntaxException; + + /** + * Gets the default {@link ChallengeHandler} that is used during + * authentication both at the connect-time as well as at subsequent + * revalidation-time that occurs at regular intervals. + * + * @return the default ChallengeHandler + */ + public abstract ChallengeHandler getDefaultChallengeHandler(); + + /** + * Gets the default connect timeout in milliseconds. Default value of the + * default connect timeout is zero -- which means no timeout. + * + * @return connect timeout value in milliseconds + */ + public abstract int getDefaultConnectTimeout(); + + /** + * Gets the names of the default enabled extensions that will be inherited + * by all the {@link WebSocket}s created using this factory. These + * extensions are negotiated between the client and the server during the + * WebSocket handshake only if all the required parameters belonging to the + * extension have been set as enabled parameters. An empty Collection is + * returned if no extensions have been enabled for this factory. + * + * @return Collection names of the enabled extensions for + */ + public abstract Collection getDefaultEnabledExtensions(); + + /** + * Returns the default {@link HttpRedirectPolicy} that was specified at + * on the factory. The default options is {@link HttpRedirectPolicy.ALWAYS}. + * + * @return HttpRedirectOption + */ + public abstract HttpRedirectPolicy getDefaultRedirectPolicy(); + + /** + * Returns the default value of the specified {@link Parameter}. + * + * @param parameter type + * @param parameter extension parameter + * @return T parameter value of type + */ + public abstract T getDefaultParameter(Parameter parameter); + + /** + * Returns the names of supported extensions that have been discovered. An + * empty Collection is returned if no extensions were discovered. + * + * @return Collection extension names discovered for this factory + */ + public abstract Collection getSupportedExtensions(); + + /** + * Sets the default {@link ChallengeHandler} that is used during + * authentication both at the connect-time as well as at subsequent + * revalidation-time that occurs at regular intervals. All the + * {@link WebSocket}s created using this factory will inherit the default + * ChallengeHandler. + * + * @param challengeHandler default ChallengeHandler + */ + public abstract void setDefaultChallengeHandler(ChallengeHandler challengeHandler); + + /** + * Sets the default connect timeout in milliseconds. The specified + * timeout is inherited by all the WebSocket instances that are created + * using this WebSocketFactory instance. The timeout will expire if there is + * no exchange of packets(for example, 100% packet loss) while establishing + * the connection. A timeout value of zero indicates no timeout. + * + * @param connectTimeout timeout value in milliseconds + * @throws IllegalArgumentException if connectTimeout is negative + */ + public abstract void setDefaultConnectTimeout(int connectTimeout); + + /** + * Registers the names of all the default enabled extensions to be inherited + * by all the {@link WebSocket}s created using this factory. The extensions + * will be negotiated between the client and the server during the WebSocket + * handshake if all the required parameters belonging to the extension have + * been set. The default enabled extensions should be a subset of the + * supported extensions. + *

    + * If an enabled extension is not in the list of supported extensions, then + * IllegalStateException is thrown. + *

    + * @param extensions list of extensions to be inherited by all the + * WebSockets created using this factory + * @throws IllegalStateException if an extension is not in the list of the + * supported extensions. + */ + public abstract void setDefaultEnabledExtensions(Collection extensions); + + /** + * Sets the default {@link HttpRedirectPolicy} that is to be inherited by + * all the {@link WebSocket}s created using this factory instance. + * + * @param option HttpRedirectOption + */ + public abstract void setDefaultRedirectPolicy(HttpRedirectPolicy option); + + /** + * Sets the default value of the specified {@link Parameter} + * that will be inherited by all the {@link WebSocket}s that are created + * using this factory instance. {@link WebSocket}s that were created before + * setting the {@link Parameter} using this API will not + * be able to inherit the default value of the parameter. + * + * @param parameter type + * @param parameter extension parameter whose default value is to be set + * @param value default value of type + */ + public abstract void setDefaultParameter(Parameter parameter, + T value); +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageReader.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageReader.java new file mode 100644 index 0000000..fee5725 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageReader.java @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.io.IOException; + +import java.nio.ByteBuffer; + +/** + * {@link WebSocketMessageReader} is used to receive binary and text messages. + * A reference to {@link WebSocketMessageReader} can be obtained by using + * {@link WebSocket#getMessageReader()} or + * {@link WsURLConnection#getMessageReader()} methods only after a + * connection has been successfully established. {@link WebSocketMessageReader} + * allows looking at {@link WebSocketMessageType} to figure out whether it's a + * text or a binary message so that appropriate getter methods can be + * subsequently invoked to retrieve the message. + *

    + * Trying to get a reference to {@link WebSocketMessageReader} before the + * connection is established will result in an IOException. + *

    + * Once the connection is closed, a new {@link WebSocketMessageReader} should + * be obtained using the aforementioned methods after the connection has been + * established. Using the old reader will result in IOException. + */ +public abstract class WebSocketMessageReader { + /** + * Returns the payload of the last received message. This method should + * be invoked after {@link #next()} only if the type of the received + * message is {@link WebSocketMessageType#BINARY}. This is not a blocking + * call. + *

    + * A null is returned if this method is invoked before invoking + * {@link #next()} method. + *

    + * If the type of the last received message is not + * {@link WebSocketMessageType#BINARY}, then invoking this method to obtain + * the payload of the message as ByteBuffer will result in an IOException. + *

    + * @return ByteBuffer binary payload of the message + * @throws IOException if the type of the last received message is not + * {@link WebSocketMessageType#BINARY} + */ + public abstract ByteBuffer getBinary() throws IOException; + + /** + * Returns the payload of the last received message. This method should + * be invoked after {@link #next()} only if the type of the received + * message is {@link WebSocketMessageType#TEXT}. This is not a blocking + * call. + *

    + * A null is returned if this method is invoked before invoking + * {@link #next()} method. + *

    + * If the type of the last received message is not + * {@link WebSocketMessageType#TEXT}, then invoking this method to obtain + * the payload of the message as CharSequence will result in an IOException. + *

    + * @return CharSequence text payload of the message + * @throws IOException if the type of the last received message is not + * {@link WebSocketMessageType#TEXT} + */ + public abstract CharSequence getText() throws IOException; + + /** + * Returns the {@link WebSocketMessageType} of the already received message. + * This method returns a null until the first message is received. Note + * that this is not a blocking call. When connected, if this method is + * invoked immediately after {@link #next()}, then they will return the same + * value. + *

    + * Based on the returned {@link WebSocketMessageType}, appropriate read + * methods can be used to receive the message. This method will continue to + * return the same {@link WebSocketMessageType} till the next message + * arrives. When the next message arrives, this method will return the + * the {@link WebSocketMessageType} associated with that message. + *

    + + * @return WebSocketMessageType WebSocketMessageType.TEXT for a text + * message; WebSocketMessageType.BINARY + * for a binary message; WebSocketMessageType.EOS + * if the connection is closed; null before + * the first message + */ + public abstract WebSocketMessageType getType(); + + /** + * Invoking this method will cause the thread to block until a message is + * received. When the message is received, this method returns the type of + * the newly received message. Based on the returned + * {@link WebSocketMessageType}, appropriate getter methods can be used to + * retrieve the binary or text message. When the connection is closed, this + * method returns {@link WebSocketMessageType#EOS}. + *

    + * An IOException is thrown if this method is invoked before the connection + * has been established. + *

    + * @return WebSocketMessageType WebSocketMessageType.TEXT for a text + * message; WebSocketMessageType.BINARY + * for a binary message; WebSocketMessageType.EOS + * if the connection is closed + * @throws IOException if invoked before the connection is established + */ + public abstract WebSocketMessageType next() throws IOException; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageType.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageType.java new file mode 100644 index 0000000..eb70948 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageType.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +/** + * {@link WebSocketMessageType} represents the types of the messages that are + * received by the {@link WebSocketMessageReader}. WebSocketMessageType.EOS + * represents end-of-stream. + */ +public enum WebSocketMessageType { + EOS, TEXT, BINARY; +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageWriter.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageWriter.java new file mode 100644 index 0000000..f04e736 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageWriter.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.io.IOException; + +import java.nio.ByteBuffer; + +/** + * {@link WebSocketMessageWriter} is used to send binary and text messages. A + * reference to {@link WebSocketMessageWriter} is obtained by invoking either + * {@link WebSocket#getMessageWriter()} or + * {@link WsURLConnection#getMessageWriter() methods after the connection has + * been established. Trying to get a reference to {@link WebSocketMessageWriter} + * before the connection is established will result in an IOException. + *

    + * Once the connection is closed, a new {@link WebSocketMessageReader} should + * be obtained using the aforementioned methods after the connection has been + * established. Using the old reader will result in IOException. + */ +public abstract class WebSocketMessageWriter { + + /** + * Sends a text message using the specified payload. Trying to write + * after the underlying connection has been closed will result in an + * IOException. + * + * @param src CharSequence payload of the message + * @throws IOException if the connection is not open or if the connection + * has been closed + */ + public abstract void writeText(CharSequence src) throws IOException; + + /** + * Sends a binary message using the specified payload. + * + * @param src ByteBuffer payload of the message + * @throws IOException if the connection is not open or if the connection + * has been closed + */ + public abstract void writeBinary(ByteBuffer src) throws IOException; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/WsURLConnection.java b/cloudsdk/src/main/java/org/kaazing/net/ws/WsURLConnection.java new file mode 100644 index 0000000..4351cc2 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/WsURLConnection.java @@ -0,0 +1,425 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.net.URL; +import java.net.URLConnection; +import java.util.Collection; + +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.ws.WebSocketExtension.Parameter; + +/** + * A URLConnection with support for WebSocket-specific features. See + * {@link http://www.w3.org/TR/websockets/} for details. + *

    + * Each WsURLConnection provides bi-directional communications for text and + * binary messaging via the Kaazing Gateway. + *

    + * An instance of {@link WsURLConnection} is created as shown below: + *

    + * {@code 
    + *     URL location = URLFactory.create("ws://:/");
    + *     URLConnection connection = location.openConnection();
    + *     WsURLConnection wsConnection = (WsURLConnection)connection;
    + * }
    + * 
    + */ +public abstract class WsURLConnection extends URLConnection { + + protected WsURLConnection(URL url) { + super(url); + } + + /** + * Disconnects with the server. This is a blocking call that returns only + * when the shutdown is complete. + * + * @throws IOException if the disconnect did not succeed + */ + public abstract void close() throws IOException; + + /** + * Disconnects with the server with code. This is a blocking + * call that returns only when the shutdown is complete. + * + * @param code the error code for closing + * @throws IOException if the disconnect did not succeed + * @throws IllegalArgumentException if the code isn't 1000 or out of + * range 3000 - 4999. + */ + public abstract void close(int code) throws IOException; + + /** + * Disconnects with the server with code and reason. This is a blocking + * call that returns only when the shutdown is complete. + * + * @param code the error code for closing + * @param reason the reason for closing + * @throws IOException if the disconnect did not succeed + * @throws IllegalArgumentException if the code isn't 1000 or out of + * range 3000 - 4999 OR if the reason + * is more than 123 bytes + */ + public abstract void close(int code, String reason) + throws IOException; + + /** + * Connects with the server using an end-point. This is a blocking call. The + * thread invoking this method will be blocked till a successful connection + * is established. If the connection cannot be established, then an + * IOException is thrown and the thread is unblocked. + * + * @throws IOException if the connection cannot be established + */ + @Override + public abstract void connect() throws IOException; + + /** + * Gets the {@link ChallengeHandler} that is used during authentication + * both at the connect-time as well as at subsequent revalidation-time that + * occurs at regular intervals. + * + * @return ChallengeHandler + */ + public abstract ChallengeHandler getChallengeHandler(); + + /** + * Gets the connect timeout in milliseconds. Default connect timeout is + * zero milliseconds. + * + * @return connect timeout value in milliseconds + */ + @Override + public abstract int getConnectTimeout(); + + /** + * Gets the names of all the extensions that have been enabled for this + * connection. The enabled extensions are negotiated between the client + * and the server during the handshake. The names of the negotiated + * extensions can be obtained using {@link #getNegotiatedExtensions()} API. + * An empty Collection is returned if no extensions have been enabled for + * this connection. The enabled extensions will be a subset of the + * supported extensions. + * + * @return Collection names of the enabled extensions for this + * connection + */ + public abstract Collection getEnabledExtensions(); + + /** + * Gets the value of the specified {@link Parameter} defined in an enabled + * extension. If the parameter is not defined for this connection but a + * default value for the parameter is set using the method + * {@link WebSocketFactory#setDefaultParameter(Parameter, Object)}, + * then the default value is returned. + *

    + * Setting the parameter value when the connection is successfully + * established will result in an IllegalStateException. + *

    + * @param Generic type of the value of the Parameter + * @param parameter Parameter whose value needs to be set + * @return the value of the specified parameter + * @throw IllegalStateException if this method is invoked after connect() + */ + public abstract T getEnabledParameter(Parameter parameter); + + /** + * Gets the names of all the protocols that are enabled for this + * connection. Returns an empty Collection if protocols are not enabled. + * + * @return Collection supported protocols by this connection + */ + public abstract Collection getEnabledProtocols(); + + /** + * Returns {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). + * + * @return HttpRedirectOption indicating the + */ + public abstract HttpRedirectPolicy getRedirectPolicy(); + + /** + * Returns the {@link InputStream} to receive binary messages. The + * methods on {@link InputStream} will block till the message arrives. The + * {@link InputStream} must be used to only receive binary + * messages. + *

    + * An IOException is thrown if this method is invoked when the connection + * has not been established. Receiving a text message using the + * {@link InputStream} will result in an IOException. + *

    + * Once the connection is closed, a new {@link InputStream} should be + * obtained using this method after the connection has been established. + * Using the old InputStream will result in an IOException. + *

    + * @return InputStream to receive binary messages + * @throws IOException if the method is invoked before the connection is + * successfully opened; if a text message is being + * read using the InputStream + */ + @Override + public abstract InputStream getInputStream() throws IOException; + + /** + * Returns a {@link WebSocketMessageReader} that can be used to receive + * binary and text messages based on the + * {@link WebSocketMessageType}. + *

    + * If this method is invoked before a connection is established successfully, + * then an IOException is thrown. + *

    + * Once the connection is closed, a new {@link WebSocketMessageReader} + * should be obtained using this method after the connection has been + * established. Using the old WebSocketMessageReader will result in an + * IOException. + *

    + * @return WebSocketMessageReader to receive binary and text messages + * @throws IOException if invoked before the connection is opened + */ + public abstract WebSocketMessageReader getMessageReader() throws IOException; + + /** + * Returns a {@link WebSocketMessageWriter} that can be used to send + * binary and text messages. + *

    + * If this method is invoked before a connection is established + * successfully, then an IOException is thrown. + *

    + * Once the connection is closed, a new {@link WebSocketMessageWriter} + * should be obtained using this method after the connection has been + * established. Using the old WebSocketMessageWriter will result in an + * IOException. + *

    + * @return WebSocketMessageWriter to send binary and text messages + * @throws IOException if invoked before the connection is opened + */ + public abstract WebSocketMessageWriter getMessageWriter() throws IOException; + + /** + * Gets names of all the enabled extensions that have been successfully + * negotiated between the client and the server during the initial + * handshake. + *

    + * Returns an empty Collection if no extensions were negotiated between the + * client and the server. The negotiated extensions will be a subset of the + * enabled extensions. + *

    + * If this method is invoked before a connection is successfully established, + * an IllegalStateException is thrown. + * + * @return Collection successfully negotiated using this + * connection + * @throws IllegalStateException if invoked before the {@link #connect()} + * completes + */ + public abstract Collection getNegotiatedExtensions(); + + /** + * Returns the value of the specified {@link Parameter} of a negotiated + * extension. + *

    + * If this method is invoked before the connection is successfully + * established, an IllegalStateException is thrown. + * + * @param parameter type + * @param parameter parameter of a negotiated extension + * @return T value of the specified parameter + * @throws IllegalStateException if invoked before the {@link #connect()} + * completes + */ + public abstract T getNegotiatedParameter(Parameter parameter); + + /** + * Gets the protocol that the client and the server have successfully + * negotiated. + *

    + * If this method is invoked before the connection is successfully + * established, an IllegalStateException is thrown. + *

    + * @return protocol negotiated by the client and the server + * @throws IllegalStateException if invoked before the {@link #connect()} + * completes + */ + public abstract String getNegotiatedProtocol(); + + /** + * Returns the {@link OutputStream} to send binary messages. The + * message is put on the wire only when {@link OutputStream#flush()} is + * invoked. + *

    + * If this method is invoked before {@link #connect()} is complete, an + * IOException is thrown. + *

    + * Once the connection is closed, a new {@link OutputStream} should + * be obtained using this method after the connection has been + * established. Using the old OutputStream will result in IOException. + *

    + * @return OutputStream to send binary messages + * @throws IOException if the method is invoked before the connection is + * successfully opened + */ + @Override + public abstract OutputStream getOutputStream() throws IOException; + + /** + * Returns a {@link Reader} to receive text messages from this + * connection. This method should be used to only to receive text + * messages. Methods on {@link Reader} will block till a message arrives. + *

    + * If the Reader is used to receive binary messages, then an + * IOException is thrown. + *

    + * If this method is invoked before a connection is established + * successfully, then an IOException is thrown. + *

    + * Once the connection is closed, a new {@link Reader} should be obtained + * using this method after the connection has been established. Using the + * old Reader will result in an IOException. + *

    + * @return Reader used to receive text messages from this connection + * @throws IOException if the method is invoked before the connection is + * successfully opened + */ + public abstract Reader getReader() throws IOException; + + /** + * Returns the names of extensions that have been discovered for this + * connection. An empty Collection is returned if no extensions were + * discovered for this connection. + * + * @return Collection extension names discovered for this + * connection + */ + public abstract Collection getSupportedExtensions(); + + /** + * Returns a {@link Writer} to send text messages from this + * connection. The message is put on the wire only when + * {@link Writer#flush()} is invoked. + *

    + * An IOException is thrown if this method is invoked when the connection + * has not been established. + *

    + * Once the connection is closed, a new {@link Writer} should be obtained + * using this method after the connection has been established. Using the + * old Writer will result in an IOException. + *

    + * @return Writer used to send text messages from this connection + * @throws IOException if the method is invoked before the connection is + * successfully opened + */ + public abstract Writer getWriter() throws IOException; + + /** + * Sets the connect timeout in milliseconds. The timeout will expire if + * there is no exchange of packets(for example, 100% packet loss) while + * establishing the connection. A timeout value of zero indicates + * no timeout. + * + * @param connectTimeout timeout value in milliseconds + * @throws IllegalStateException if the connection timeout is being set + * after the connection has been established + * @throws IllegalArgumentException if connectTimeout is negative + */ + @Override + public abstract void setConnectTimeout(int connectTimeout); + + /** + * Sets the {@link ChallengeHandler} that is used during authentication + * both at the connect-time as well as at subsequent revalidation-time that + * occurs at regular intervals. + * + * @param challengeHandler ChallengeHandler used for authentication + */ + public abstract void setChallengeHandler(ChallengeHandler challengeHandler); + + /** + * Registers the names of all the extensions that must be negotiated between + * the client and the server during the handshake. This method must be + * invoked before invoking the {@link #connect()} method. The + * enabled extensions should be a subset of the supported extensions. Only + * the extensions that are explicitly enabled are put on the wire even + * though there could be more supported extensions on this connection. + *

    + * If this method is invoked after connection is successfully established, + * an IllegalStateException is thrown. If an enabled extension is not + * discovered as a supported extension, then IllegalStateException is thrown. + *

    + * @param extensions list of extensions to be negotiated with the server + * during the handshake + * @throw IllegalStateException if this method is invoked after successful + * connection or any of the specified + * extensions is not a supported extension + */ + public abstract void setEnabledExtensions(Collection extensions); + + /** + * Sets the value of the specified {@link Parameter} defined in an enabled + * extension. The application developer should set the extension parameters + * of the enabled extensions before invoking the {@link #connect()} method. + *

    + * Setting the parameter value when the connection is successfully + * established will result in an IllegalStateException. + *

    + * If the parameter has a default value that was specified using + * {@link WebSocketFactory#setDefaultParameter(Parameter, Object)}, + * then setting the same parameter using this method will override the + * default value. + *

    + * @param extension parameter type + * @param parameter Parameter whose value needs to be set + * @param value of the specified parameter + * @throw IllegalStateException if this method is invoked after connect() + */ + public abstract void setEnabledParameter(Parameter parameter, T value); + + /** + * Registers the protocols to be negotiated with the server during the + * handshake. This method must be invoked before {@link #connect()} is + * called. + *

    + * If this method is invoked after a connection has been successfully + * established, an IllegalStateException is thrown. + *

    + * @param extensions list of extensions to be negotiated with the server + * during the handshake + * @throw IllegalStateException if this method is invoked after connect() + */ + public abstract void setEnabledProtocols(Collection protocols); + + /** + * Sets {@link HttpRedirectPolicy} indicating the policy for + * following HTTP redirects (3xx). + * + * @param option HttpRedirectOption to used for following the + * redirects + */ + public abstract void setRedirectPolicy(HttpRedirectPolicy option); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/DefaultWebSocketFactory.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/DefaultWebSocketFactory.java new file mode 100644 index 0000000..fa1e973 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/DefaultWebSocketFactory.java @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl; + +import static java.util.Collections.unmodifiableCollection; +import static java.util.Collections.unmodifiableMap; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.ServiceLoader; + +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.ws.WebSocket; +import org.kaazing.net.ws.WebSocketExtension.Parameter; +import org.kaazing.net.ws.WebSocketFactory; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionFactorySpi; + +public final class DefaultWebSocketFactory extends WebSocketFactory { + private static final Map _extensionFactories; + + private final Map _parameters; + private HttpRedirectPolicy _redirectOption; + private Collection _supportedExtensions; + private Collection _enabledExtensions; + private ChallengeHandler _challengeHandler; + private int _connectTimeout = 0; // milliseconds + + static { + Class clazz = WebSocketExtensionFactorySpi.class; + ServiceLoader loader = ServiceLoader.load(clazz); + Map factories = new HashMap(); + + for (WebSocketExtensionFactorySpi factory: loader) { + String extensionName = factory.getExtensionName(); + + if (extensionName != null) + { + factories.put(extensionName, factory); + } + } + _extensionFactories = unmodifiableMap(factories); + } + + public DefaultWebSocketFactory() { + _parameters = new HashMap(); + + _supportedExtensions = new HashSet(); + _supportedExtensions.addAll(_extensionFactories.keySet()); + + // ### TODO: Should _redirectOption be null or + // HttpRedirectOption.ALWAYS by default. Note that in + // HttpURLConnection, followRedirects is true by default. + _redirectOption = HttpRedirectPolicy.ALWAYS; + + } + + @Override + public WebSocket createWebSocket(URI location) + throws URISyntaxException { + return createWebSocket(location, (String[]) null); + } + + @Override + public WebSocket createWebSocket(URI location, String... protocols) + throws URISyntaxException { + Collection enabledProtocols = null; + Collection enabledExtensions = null; + + // Clone enabled protocols maintained at the WebSocketFactory level to + // pass into the WebSocket instance. + if (protocols != null) { + enabledProtocols = new HashSet(Arrays.asList(protocols)); + } + + // Clone enabled extensions maintained at the WebSocketFactory level to + // pass into the WebSocket instance. + if (_enabledExtensions != null) { + enabledExtensions = new ArrayList(_enabledExtensions); + } + + // Clone the map of default parameters maintained at the + // WebSocketFactory level to pass into the WebSocket instance. + Map enabledParams = + new HashMap(); + enabledParams.putAll(_parameters); + + // Create a WebSocket instance that inherits the enabled protocols, + // enabled extensions, enabled parameters, the HttpRedirectOption, + // the extension factories(ie. the supported extensions). + WebSocketImpl ws = new WebSocketImpl(location, + _extensionFactories, + _redirectOption, + enabledExtensions, + enabledProtocols, + enabledParams, + _challengeHandler, + _connectTimeout); + return ws; + } + + @Override + public int getDefaultConnectTimeout() { + return _connectTimeout; + } + + @Override + public ChallengeHandler getDefaultChallengeHandler() { + return _challengeHandler; + } + + + + @Override + public Collection getDefaultEnabledExtensions() { + return (_enabledExtensions == null) ? Collections.emptySet() : + unmodifiableCollection(_enabledExtensions); + } + + + @Override + public HttpRedirectPolicy getDefaultRedirectPolicy() { + return _redirectOption; + } + + @Override + public T getDefaultParameter(Parameter parameter) { + String extName = parameter.extension().name(); + WsExtensionParameterValuesSpiImpl paramValues = _parameters.get(extName); + + if (paramValues == null) { + return null; + } + + return paramValues.getParameterValue(parameter); + } + + @Override + public Collection getSupportedExtensions() { + return (_supportedExtensions == null) ? Collections.emptySet() : + unmodifiableCollection(_supportedExtensions); + } + + @Override + public void setDefaultChallengeHandler(ChallengeHandler challengeHandler) { + _challengeHandler = challengeHandler; + } + + @Override + public void setDefaultConnectTimeout(int connectTimeout) { + _connectTimeout = connectTimeout; + } + + @Override + public void setDefaultEnabledExtensions(Collection extensions) { + if (extensions == null) { + _enabledExtensions = extensions; + return; + } + + Collection supportedExtns = getSupportedExtensions(); + for (String extension : extensions) { + if (!supportedExtns.contains(extension)) { + String s = String.format("'%s' is not a supported extension", extension); + throw new IllegalStateException(s); + } + + if (_enabledExtensions == null) { + _enabledExtensions = new ArrayList(); + } + + _enabledExtensions.add(extension); + } + } + + @Override + public void setDefaultRedirectPolicy(HttpRedirectPolicy redirectOption) { + _redirectOption = redirectOption; + } + + @Override + public void setDefaultParameter(Parameter parameter, T value) { + String extensionName = parameter.extension().name(); + + WsExtensionParameterValuesSpiImpl parameterValues = _parameters.get(extensionName); + if (parameterValues == null) { + parameterValues = new WsExtensionParameterValuesSpiImpl(); + _parameters.put(extensionName, parameterValues); + } + + parameterValues.setParameterValue(parameter, value); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WebSocketImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WebSocketImpl.java new file mode 100644 index 0000000..d950290 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WebSocketImpl.java @@ -0,0 +1,1188 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl; + +import static java.util.Collections.unmodifiableCollection; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.io.Writer; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.gateway.client.impl.CommandMessage; +import org.kaazing.gateway.client.impl.WebSocketChannel; +import org.kaazing.gateway.client.impl.WebSocketHandlerListener; +import org.kaazing.gateway.client.impl.util.WSCompositeURI; +import org.kaazing.gateway.client.impl.util.WSURI; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeChannel; +import org.kaazing.gateway.client.impl.ws.WebSocketCompositeHandler; +import org.kaazing.gateway.client.impl.ws.WebSocketSelectedChannel; +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.impl.util.BlockingQueueImpl; +import org.kaazing.net.impl.util.ResumableTimer; +import org.kaazing.net.ws.WebSocket; +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketExtension; +import org.kaazing.net.ws.WebSocketExtension.Parameter; +import org.kaazing.net.ws.WebSocketExtension.Parameter.Metadata; +import org.kaazing.net.ws.WebSocketMessageReader; +import org.kaazing.net.ws.WebSocketMessageWriter; +import org.kaazing.net.ws.impl.io.WsInputStreamImpl; +import org.kaazing.net.ws.impl.io.WsMessageReaderAdapter; +import org.kaazing.net.ws.impl.io.WsMessageReaderImpl; +import org.kaazing.net.ws.impl.io.WsMessageWriterImpl; +import org.kaazing.net.ws.impl.io.WsOutputStreamImpl; +import org.kaazing.net.ws.impl.io.WsReaderImpl; +import org.kaazing.net.ws.impl.io.WsWriterImpl; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionFactorySpi; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionHandlerSpi; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionParameterValuesSpi; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionSpi; + +public class WebSocketImpl extends WebSocket { + private static final String _CLASS_NAME = WebSocketImpl.class.getName(); + private static final Logger _LOG = Logger.getLogger(_CLASS_NAME); + + // These member variables are final as they will not change once they are + // created/set. + private final Map _enabledParameters; + private final Map _negotiatedParameters; + private final Map _extensionFactories; + private final WSURI _location; + private final WebSocketCompositeHandler _handler; + private final WebSocketCompositeChannel _channel; + + private Collection _enabledExtensions; + private Collection _negotiatedExtensions; + private Collection _supportedExtensions; + private Collection _enabledProtocols; + private String _negotiatedProtocol; + private WsInputStreamImpl _inputStream; + private WsOutputStreamImpl _outputStream; + private WsReaderImpl _reader; + private WsWriterImpl _writer; + private WsMessageReaderImpl _messageReader; + private WsMessageWriterImpl _messageWriter; + private BlockingQueueImpl _sharedQueue; + private HttpRedirectPolicy _followRedirect; + private ChallengeHandler _challengeHandler; + private int _connectTimeout = 0; + + private ReadyState _readyState; + private Exception _exception; + + /** + * Values are CONNECTING = 0, OPEN = 1, CLOSING = 2, and CLOSED = 3; + */ + enum ReadyState { + CONNECTING, OPEN, CLOSING, CLOSED; + } + + /** + * Creates a WebSocket that opens up a full-duplex connection to the target + * location on a supported WebSocket provider. Call connect() to establish + * the location after adding event listeners. + * + * @param location URI of the WebSocket service for the connection + * @throws Exception if connection could not be established + */ + public WebSocketImpl(URI location, + Map extensionFactories) + throws URISyntaxException { + this(location, + extensionFactories, + HttpRedirectPolicy.ALWAYS, + null, + null, + new HashMap(), + null, + 0); + } + + public WebSocketImpl(URI location, + Map extensionFactories, + HttpRedirectPolicy followRedirect, + Collection enabledExtensions, + Collection enabledProtocols, + Map enabledParameters, + ChallengeHandler challengeHandler, + int connectTimeout) + throws URISyntaxException { + WSCompositeURI compUri = new WSCompositeURI(location); + + _readyState = ReadyState.CLOSED; + _location = compUri.getWSEquivalent(); + + _followRedirect = followRedirect; + _enabledParameters = enabledParameters; + _negotiatedParameters = new HashMap(); + _extensionFactories = extensionFactories; + _challengeHandler = challengeHandler; + _connectTimeout = connectTimeout; + + // Set up the WebCompositeHandler with the listener. Methods on the + // listener will be invoked from the pipeline. This will allow us to + // manage the lifecycle of the WebSocket. + _handler = WebSocketCompositeHandler.COMPOSITE_HANDLER; + _handler.setListener(handlerListener); + + // Setup the channel that will represent this instance of the WebSocket. + _channel = new WebSocketCompositeChannel(compUri); + _channel.setWebSocket(this); + + if ((_extensionFactories != null) && (_extensionFactories.size() > 0)) + { + _supportedExtensions = new HashSet(); + _supportedExtensions.addAll(_extensionFactories.keySet()); + } + + setEnabledExtensions(enabledExtensions); + setEnabledProtocols(enabledProtocols); + } + + + @Override + public synchronized void close() throws IOException { + close(0, null); + } + + @Override + public synchronized void close(int code) throws IOException { + close(code, null); + } + + @Override + public synchronized void close(int code, String reason) throws IOException { + String args = String.format("code = '%d', reason = '%s'", code, reason); + _LOG.entering(_CLASS_NAME, "close", args); + + if (code != 0) { + //verify code and reason agaist RFC 6455 + //if code is present, it must equal to 1000 or in range 3000 to 4999 + if (code != 1000 && (code < 3000 || code > 4999)) { + throw new IllegalArgumentException("code must equal to 1000 or in range 3000 to 4999"); + } + + //if reason is present, it must not be longer than 123 bytes + if (reason != null && reason.length() > 0) { + //convert reason to UTF8 string + try { + byte[] reasonBytes = reason.getBytes("UTF8"); + if (reasonBytes.length > 123) { + throw new IllegalArgumentException("Reason is longer than 123 bytes"); + } + reason = new String(reasonBytes, "UTF8"); + + } catch (UnsupportedEncodingException e) { + _LOG.log(Level.FINEST, e.getMessage(), e); + throw new IllegalArgumentException("Reason must be encodable to UTF8"); + } + } + } + + if ((_readyState == ReadyState.CLOSED) || (_readyState == ReadyState.CLOSING)) { + // Since the WebSocket is already closed/closing, we just bail. + _LOG.log(Level.FINE, "WebSocket is closed or closing"); + return; + } + + if (_readyState == ReadyState.CONNECTING) { + _LOG.log(Level.FINE, "WebSocket is connecting"); + _readyState = ReadyState.CLOSED; + cleanupAfterClose(); + + // If the close() is called and connection is still being established + // with WebSocket.connect(), inform the application that the connection + // failed. + setException(new WebSocketException("Connection Failed")); + notifyAll(); + return; + } + + setException(null); + _readyState = ReadyState.CLOSING; + _handler.processClose(_channel, code, reason); + + // Block till the WebSocket is closed completely. + // Sometimes the thread can have a spurious wakeup without getting + // notified, interrupted, or timing out. So, we should guard with + // the WHILE loop. + while ((_readyState != ReadyState.CLOSED) && (_exception == null)) { + try { + wait(); + if (getException() != null) { + break; + } + } + catch (InterruptedException e) { + throw new WebSocketException(e); + } + } + + // Check if there is any exception that needs to be reported. + Exception exception = getException(); + if (exception != null) { + throw new WebSocketException(exception); + } + + // At this point, the cleanup after close has already been performed. + } + + @Override + public void connect() throws IOException { + _LOG.entering(_CLASS_NAME, "connect"); + + ResumableTimer connectTimer = null; + String[] enabledProtocols = null; + + synchronized (this) { + if (_readyState == ReadyState.OPEN) { + return; + } + else if (_readyState == ReadyState.CONNECTING){ + String s = "WebSocket connection is in progress"; + throw new IllegalStateException(s); + } + else if (_readyState == ReadyState.CLOSING) { + String s = "WebSocket is not in a state to connect at this time"; + throw new IllegalStateException(s); + } + + // Prepare for connecting. + _readyState = ReadyState.CONNECTING; + setException(null); + + int len = getEnabledProtocols().size(); + + if (len > 0) { + enabledProtocols = getEnabledProtocols().toArray(new String[len]); + } + // Used by the producer(i.e. the handlerListener) and the + // consumer(i.e. the WebSocketMessageReader). + _sharedQueue = new BlockingQueueImpl(); + + // ### TODO: This might be temporary till we install extensions' + // handler directly in the pipeline. + _channel.setChallengeHandler(_challengeHandler); + + // Setup the channel with the specified characteristics. + String extensionsHeader = rfc3864FormattedString(); + _channel.setEnabledExtensions(extensionsHeader); + _channel.setFollowRedirect(_followRedirect); + + // If _connectTimeout == 0, then it means there is no timeout. + if (_connectTimeout > 0) { + // Create connect timer that is scheduled to run after _connectTimeout + // milliseconds once it is started. If the connection is not created + // before the timer expires, then an exception is thrown. + connectTimer = new ResumableTimer(new Runnable() { + @Override + public void run() { + if (_readyState == ReadyState.CONNECTING) { + SocketTimeoutException ex = new SocketTimeoutException("Connection timeout"); + // Inform the app by raising the CLOSE event. + _handler.doClose(_channel, ex); + + // Try closing the connection all the way down. This may + // block when there is a network loss. That's why we are + // first informing the application about the connection + // timeout. + _handler.processClose(_channel, 0, "Connection timeout"); + } + } + }, _connectTimeout, false); + + _channel.setConnectTimer(connectTimer); + + // Start the connect timer just before we connect to the end-point. + connectTimer.start(); + } + } + + // Connect to the end-point. Keep this out of the synchronized blocks + // that are above and below to avoid deadlock(between connect-timer + // thread and this thread) when there is a network loss. + _handler.processConnect(_channel, _location, enabledProtocols); + + synchronized (this) { + // Block till the WebSocket is opened. + // Sometimes the thread can have a spurious wakeup without getting + // notified, interrupted, or timing out. So, we should guard with + // the WHILE loop. + while ((_readyState != ReadyState.OPEN) && (_exception == null)) { + try { + wait(); + + if (getException() != null) { + break; + } + } + catch (InterruptedException e) { + throw new WebSocketException(e); + } + } + } + + if (connectTimer != null) { + // Cancel the timer and clear the timer in the channel. + connectTimer.cancel(); + _channel.setConnectTimer(null); + } + + // Check if there is any exception that needs to be reported. + Exception exception = getException(); + if (exception != null) { + String s = "Connection failed"; + throw new WebSocketException(s, exception); + } + + // At this point, the _negotiatedProtocol and the _negotiatedExtensions + // should be set. + + // ### TODO: If an enabled extension is successfully negotiated, then + // add the corresponding handler to the pipeline. + } + + @Override + public ChallengeHandler getChallengeHandler() { + return _challengeHandler; + } + + @Override + public int getConnectTimeout() { + return _connectTimeout; + } + + @Override + public Collection getEnabledExtensions() { + return (_enabledExtensions == null) ? Collections.emptySet() : + unmodifiableCollection(_enabledExtensions); + } + + @Override + public T getEnabledParameter(Parameter parameter) { + String extName = parameter.extension().name(); + WsExtensionParameterValuesSpiImpl paramValues = _enabledParameters.get(extName); + + if (paramValues == null) { + return null; + } + + return paramValues.getParameterValue(parameter); + } + + @Override + public Collection getEnabledProtocols() { + return (_enabledProtocols == null) ? Collections.emptySet() : + unmodifiableCollection(_enabledProtocols); + } + + @Override + public HttpRedirectPolicy getRedirectPolicy() { + return _followRedirect; + } + + @Override + public InputStream getInputStream() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot create InputStream as the WebSocket is not connected"; + throw new IOException(s); + } + + synchronized (this) { + if ((_inputStream != null) && !_inputStream.isClosed()) { + return _inputStream; + } + + WsMessageReaderAdapter adapter = null; + adapter = new WsMessageReaderAdapter(getMessageReader()); + _inputStream = new WsInputStreamImpl(adapter); + } + + return _inputStream; + } + + @Override + public WebSocketMessageReader getMessageReader() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot create MessageReader as the WebSocket is not connected"; + throw new IOException(s); + } + + synchronized (this) { + if ((_messageReader != null) && !_messageReader.isClosed()) { + return _messageReader; + } + + if (_sharedQueue == null) { + // Used by the producer(i.e. the handlerListener) and the + // consumer(i.e. the WebSocketMessageReader). + _sharedQueue = new BlockingQueueImpl(); + } + + _messageReader = new WsMessageReaderImpl(this, _sharedQueue); + } + + return _messageReader; + } + + @Override + public WebSocketMessageWriter getMessageWriter() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot create MessageWriter as the WebSocket is not connected"; + throw new IOException(s); + } + + synchronized (this) { + if ((_messageWriter != null) && !_messageWriter.isClosed()) { + return _messageWriter; + } + + _messageWriter = new WsMessageWriterImpl(this); + } + return _messageWriter; + } + + @Override + public Collection getNegotiatedExtensions() { + if (_readyState != ReadyState.OPEN) { + String s = "Extensions have not been negotiated as the webSocket " + + "is not yet connected"; + throw new IllegalStateException(s); + } + + return (_negotiatedExtensions == null) ? Collections.emptySet() : + unmodifiableCollection(_negotiatedExtensions); + } + + @Override + public T getNegotiatedParameter(Parameter parameter) { + if (_readyState != ReadyState.OPEN) { + String s = "Extensions have not been negotiated as the webSocket " + + "is not yet connected"; + throw new IllegalStateException(s); + } + + String extName = parameter.extension().name(); + WsExtensionParameterValuesSpiImpl paramValues = _negotiatedParameters.get(extName); + + if (paramValues == null) { + return null; + } + + return paramValues.getParameterValue(parameter); + } + + @Override + public String getNegotiatedProtocol() { + if (_readyState != ReadyState.OPEN) { + String s = "Protocols have not been negotiated as the webSocket " + + "is not yet connected"; + throw new IllegalStateException(s); + } + + return _negotiatedProtocol; + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot get the OutputStream as the WebSocket is not yet connected"; + throw new IOException(s); + } + + synchronized (this) + { + if ((_outputStream != null) && !_outputStream.isClosed()) { + return _outputStream; + } + + _outputStream = new WsOutputStreamImpl(getMessageWriter()); + } + + return _outputStream; + } + + @Override + public Reader getReader() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot create Reader as the WebSocket is not connected"; + throw new IOException(s); + } + + synchronized (this) { + if ((_reader != null) && !_reader.isClosed()) { + return _reader; + } + + WsMessageReaderAdapter adapter = null; + adapter = new WsMessageReaderAdapter(getMessageReader()); + _reader = new WsReaderImpl(adapter); + } + + return _reader; + } + + @Override + public Collection getSupportedExtensions() { + return (_supportedExtensions == null) ? Collections.emptySet() : + unmodifiableCollection(_supportedExtensions); + } + + @Override + public Writer getWriter() throws IOException { + if (_readyState != ReadyState.OPEN) { + String s = "Cannot create Writer as the WebSocket is not yet connected"; + throw new IOException(s); + } + + synchronized (this) + { + if ((_writer != null) && !_writer.isClosed()) { + return _writer; + } + + _writer = new WsWriterImpl(getMessageWriter()); + } + + return _writer; + } + + @Override + public void setChallengeHandler(ChallengeHandler challengeHandler) { + _challengeHandler = challengeHandler; + } + + @Override + public void setConnectTimeout(int connectTimeout) { + if (_readyState != ReadyState.CLOSED) { + String s = "Connection timeout can be set only when the WebSocket is closed"; + throw new IllegalStateException(s); + } + + if (connectTimeout < 0) { + throw new IllegalArgumentException("Connect timeout cannot be negative"); + } + + _connectTimeout = connectTimeout; + } + + @Override + public void setEnabledExtensions(Collection extensions) { + if (_readyState != ReadyState.CLOSED) { + String s = "Extensions can be enabled only when the WebSocket is closed"; + throw new IllegalStateException(s); + } + + if (extensions == null) { + _enabledExtensions = extensions; + return; + } + + Collection supportedExtns = getSupportedExtensions(); + for (String extension : extensions) { + if (!supportedExtns.contains(extension)) { + String s = String.format("'%s' is not a supported extension", extension); + throw new IllegalStateException(s); + } + + if (_enabledExtensions == null) { + _enabledExtensions = new ArrayList(); + } + + _enabledExtensions.add(extension); + } + } + + @Override + public void setEnabledParameter(Parameter parameter, T value) { + if (_readyState != ReadyState.CLOSED) { + String s = "Parameters can be set only when the WebSocket is closed"; + throw new IllegalStateException(s); + } + + String extensionName = parameter.extension().name(); + + WsExtensionParameterValuesSpiImpl parameterValues = _enabledParameters.get(extensionName); + if (parameterValues == null) { + parameterValues = new WsExtensionParameterValuesSpiImpl(); + _enabledParameters.put(extensionName, parameterValues); + } + + parameterValues.setParameterValue(parameter, value); + } + + @Override + public void setEnabledProtocols(Collection protocols) { + if (_readyState != ReadyState.CLOSED) { + String s = "Protocols can be enabled only when the WebSocket is closed"; + throw new IllegalStateException(s); + } + + if ((protocols == null) || protocols.isEmpty()) { + _enabledProtocols = protocols; + return; + } + + _enabledProtocols = new ArrayList(); + + for (String protocol : protocols) { + _enabledProtocols.add(protocol); + } + } + + @Override + public void setRedirectPolicy(HttpRedirectPolicy option) { + _followRedirect = option; + } + + // --------------------- Internal Implementation ------------------ + + public WebSocketCompositeChannel getCompositeChannel() { + return _channel; + } + + public boolean isConnected() { + return (_readyState == ReadyState.OPEN); + } + + public boolean isDisconnected() { + return (_readyState == ReadyState.CLOSED); + } + + public Exception getException() { + return _exception; + } + + public void setException(Exception exception) { + _exception = exception; + } + + public synchronized void send(ByteBuffer buf) throws IOException { + _LOG.entering(_CLASS_NAME, "send", buf); + + if (_readyState != ReadyState.OPEN) { + String s = "Messages can be sent only when the WebSocket is connected"; + throw new WebSocketException(s); + } + + _handler.processBinaryMessage(_channel, new WrappedByteBuffer(buf)); + } + + public synchronized void send(String message) throws IOException { + _LOG.entering(_CLASS_NAME, "send", message); + + if (_readyState != ReadyState.OPEN) { + String s = "Messages can be sent only when the WebSocket is connected"; + throw new WebSocketException(s); + } + + _handler.processTextMessage(_channel, message); + } + + // --------------------- Private Implementation -------------------------- + + private synchronized void connectionOpened(String protocol, + String extensionsHeader) { + // ### TODO: Currently, the Gateway is not sending the negotiated + // protocol. + setNegotiatedProtocol(protocol); + + // Parse the negotiated extensions and parameters. This can result in + // _exception to be setup indicating that there is something wrong + // while parsing the negotiated extensions and parameters. + setNegotiatedExtensions(extensionsHeader); + + if ((getException() == null) && (_readyState == ReadyState.CONNECTING)) { + _readyState = ReadyState.OPEN; + } + else { + // The exception can be caused either while parsing the negotiated + // extensions and parameters or the expiry of the connection timeout. + // The parsing of negotiated extension can cause an exception if -- + // 1) a negotiated extension is not an enabled extension or + // 2) the type of a negotiated parameter is not String. + _readyState = ReadyState.CLOSED; + + // Inform the Gateway to close the WebSocket. + _handler.processClose(_channel, 0, null); + } + + // Unblock the connect() call so that it can proceed. + notifyAll(); + } + + private synchronized void connectionClosed(boolean wasClean, + int code, + String reason) { + if (_readyState == ReadyState.CLOSED) { + return; + } + + _readyState = ReadyState.CLOSED; + + if (!wasClean) { + if (reason == null) { + reason = "Connection Failed"; + } + + setException(new WebSocketException(code, reason)); + } + + cleanupAfterClose(); + + // Unblock the close() call so that it can proceed. + notifyAll(); + } + + private synchronized void connectionClosed(Exception ex) { + if (_readyState == ReadyState.CLOSED) { + return; + } + + setException(ex); + + _readyState = ReadyState.CLOSED; + + cleanupAfterClose(); + + // Unblock the close() call so that it can proceed. + notifyAll(); + } + + private synchronized void connectionFailed(Exception ex) { + if (_readyState == ReadyState.CLOSED) { + return; + } + + if (ex == null) { + ex = new WebSocketException("Connection Failed"); + } + + setException(ex); + + _readyState = ReadyState.CLOSED; + + cleanupAfterClose(); + + // Unblock threads so that they can proceed. + notifyAll(); + } + + private synchronized void cleanupAfterClose() { + setNegotiatedExtensions(null); + setNegotiatedProtocol(null); + _negotiatedParameters.clear(); + + // ### TODO: + // 1. WsExtensionHandlerSpis that were been added to the pipeline based + // on negotiated extensions for this connection should be removed. + + if (_messageReader != null) { + // Notify the waiting consumers that the connection is closing. + try { + _messageReader.close(); + } + catch (IOException ex) { + _LOG.log(Level.FINE, ex.getMessage(), ex); + } + } + + if (_sharedQueue != null) { + _sharedQueue.done(); + } + + if (_inputStream != null) { + try { + _inputStream.close(); + } + catch (Exception ex) { + _LOG.log(Level.FINE, ex.getMessage(), ex); + } + } + + if (_outputStream != null) { + try { + _outputStream.close(); + } + catch (Exception ex) { + _LOG.log(Level.FINE, ex.getMessage(), ex); + } + } + + if (_reader != null) { + try { + _reader.close(); + } + catch (Exception ex) { + _LOG.log(Level.FINE, ex.getMessage(), ex); + } + } + + if (_writer != null) { + try { + _writer.close(); + } + catch (Exception ex) { + _LOG.log(Level.FINE, ex.getMessage(), ex); + } + } + + _messageReader = null; + _sharedQueue = null; + _messageWriter = null; + _inputStream = null; + _outputStream = null; + _reader = null; + _writer = null; + } + + private String formattedExtension(String extensionName, + WebSocketExtensionParameterValuesSpi paramValues) { + if (extensionName == null) { + return ""; + } + + WebSocketExtension extension = + WebSocketExtension.getWebSocketExtension(extensionName); + Collection> extnParameters = extension.getParameters(); + StringBuffer buffer = new StringBuffer(extension.name()); + + // We are using extnParameters to iterate as we want the ordered list + // of parameters. + for (Parameter param : extnParameters) { + if (param.required()) { + // Required parameter is not enabled/set. + String s = String.format("Extension '%s': Required parameter " + + "'%s' must be set", extension.name(), param.name()); + if ((paramValues == null) || + (paramValues.getParameterValue(param) == null)) { + throw new IllegalStateException(s); + } + } + + if (paramValues == null) { + // We should continue so that we can throw an exception if + // any of the required parameters has not been set. + continue; + } + + Object value = paramValues.getParameterValue(param); + + if (value == null) { + // Non-required parameter has not been set. So, let's continue + // to the next one. + continue; + } + + if (param.temporal()) { + // Temporal/transient parameters, even if they are required, + // are not put on the wire. + continue; + } + + if (param.anonymous()) { + // If parameter is anonymous, then only it's value is put + // on the wire. + buffer.append(";").append(value); + continue; + } + + // Otherwise, append the name=value pair. + buffer.append(";").append(param.name()).append("=").append(value); + } + + return buffer.toString(); + } + + private BlockingQueueImpl getSharedQueue() { + return _sharedQueue; + } + + private String rfc3864FormattedString() { + // Iterate over enabled extensions. Using WebSocketExtensionFactorySpi + // for each extension, create a WebSocketExtensionSpi instance for each + // of the enabled extensions and pass WebSocketExtensionParameterValuesSpi + // that should contain the values of the enabled parameters. + StringBuffer extensionsHeader = new StringBuffer(""); + Map handlers = + new HashMap(); + for (String extensionName : getEnabledExtensions()) { + WebSocketExtensionFactorySpi extensionFactory = + _extensionFactories.get(extensionName); + WebSocketExtensionParameterValuesSpi paramValues = + _enabledParameters.get(extensionName); + + // ### TODO: We are not setting up the extensions' handler in the + // pipeline at this point. + WebSocketExtensionSpi extension = extensionFactory.createWsExtension(paramValues); + WebSocketExtensionHandlerSpi extHandler = extension.createHandler(); + handlers.put(extensionName, extHandler); + + // Get the RFC-3864 formatted string representation of the + // WebSocketExtension. + String formatted = formattedExtension(extensionName, paramValues); + + if (formatted.length() > 0) { + if (extensionsHeader.length() > 0) { + // Add the ',' separator between strings representing + // different extensions. + extensionsHeader.append(","); + } + + extensionsHeader.append(formatted); + } + } + + return extensionsHeader.toString(); + } + + // Comma separated list of negotiated extensions and parameters based on + // RFC 3864 format. + private void setNegotiatedExtensions(String extensionsHeader) { + if ((extensionsHeader == null) || + (extensionsHeader.trim().length() == 0)) { + _negotiatedExtensions = null; + return; + } + + String[] extns = extensionsHeader.split(","); + List extnNames = new ArrayList(); + + for (String extn : extns) { + String[] properties = extn.split(";"); + String extnName = properties[0].trim(); + + if (!getEnabledExtensions().contains(extnName)) { + String s = String.format("Extension '%s' is not an enabled " + + "extension so it should not have been negotiated", extnName); + setException(new WebSocketException(s)); + return; + } + + WebSocketExtension extension = + WebSocketExtension.getWebSocketExtension(extnName); + WsExtensionParameterValuesSpiImpl paramValues = + _negotiatedParameters.get(extnName); + Collection> anonymousParams = + extension.getParameters(Metadata.ANONYMOUS); + + // Start from the second(0-based) property to parse the name-value + // pairs as the first(or 0th) is the extension name. + for (int i = 1; i < properties.length; i++) { + String property = properties[i].trim(); + String[] pair = property.split("="); + Parameter parameter = null; + String paramValue = null; + + if (pair.length == 1) { + // We are dealing with an anonymous parameter. Since the + // Collection is actually an ArrayList, we are guaranteed to + // iterate the parameters in the definition/creation order. + // As there is no parameter name, we will just get the next + // anonymous Parameter instance and use it for setting the + // value. The onus is on the extension implementor to either + // use only named parameters or ensure that the anonymous + // parameters are defined in the order in which the server + // will send them back during negotiation. + parameter = anonymousParams.iterator().next(); + paramValue = pair[0].trim(); + } + else { + parameter = extension.getParameter(pair[0].trim()); + paramValue = pair[1].trim(); + } + + if (parameter.type() != String.class) { + String paramName = parameter.name(); + String s = String.format("Negotiated Extension '%s': " + + "Type of parameter '%s' should be String", + extnName, paramName); + setException(new WebSocketException(s)); + return; + } + + if (paramValues == null) { + paramValues = new WsExtensionParameterValuesSpiImpl(); + _negotiatedParameters.put(extnName, paramValues); + } + + paramValues.setParameterValue(parameter, paramValue); + + } + extnNames.add(extnName); + } + + HashSet extnsSet = new HashSet(extnNames); + _negotiatedExtensions = unmodifiableCollection(extnsSet); + } + + private void setNegotiatedProtocol(String protocol) { + _negotiatedProtocol = protocol; + } + + private static final WebSocketHandlerListener handlerListener = new WebSocketHandlerListener() { + + @Override + public void connectionOpened(WebSocketChannel channel, String protocol) { + _LOG.entering(_CLASS_NAME, "connectionOpened"); + + WebSocketCompositeChannel cc = (WebSocketCompositeChannel)channel; + WebSocketImpl webSocket = (WebSocketImpl) cc.getWebSocket(); + WebSocketSelectedChannel selChan = ((WebSocketCompositeChannel)channel).selectedChannel; + + synchronized (webSocket) { + // ### TODO: Currently, Gateway is not returning the negotiated + // protocol. + // Try parsing the negotiated extensions in the + // connectionOpened() method. Only when everything looks good, + // mark the connection as opened. If a negotiated extension is + // not in the list of enabled extensions, then we will setup an + // exception and close down. + webSocket.connectionOpened(protocol, + selChan.getNegotiatedExtensions()); + } + } + + @Override + public void binaryMessageReceived(WebSocketChannel channel, WrappedByteBuffer buf) { + _LOG.entering(_CLASS_NAME, "binaryMessageReceived"); + + WebSocketCompositeChannel cc = (WebSocketCompositeChannel)channel; + WebSocketImpl webSocket = (WebSocketImpl) cc.getWebSocket(); + + synchronized (webSocket) { + BlockingQueueImpl sharedQueue = webSocket.getSharedQueue(); + if (sharedQueue != null) { + synchronized (sharedQueue) { + try { + ByteBuffer payload = buf.getNioByteBuffer(); + sharedQueue.put(payload); + } + catch (InterruptedException ex) { + _LOG.log(Level.INFO, ex.getMessage(), ex); + } + } + } + } + } + + @Override + public void textMessageReceived(WebSocketChannel channel, String text) { + _LOG.entering(_CLASS_NAME, "textMessageReceived", text); + + WebSocketCompositeChannel cc = (WebSocketCompositeChannel)channel; + WebSocketImpl webSocket = (WebSocketImpl) cc.getWebSocket(); + + synchronized (webSocket) { + BlockingQueueImpl sharedQueue = webSocket.getSharedQueue(); + if (sharedQueue != null) { + synchronized (sharedQueue) { + try { + sharedQueue.put(text); + } + catch (InterruptedException ex) { + _LOG.log(Level.INFO, ex.getMessage(), ex); + } + } + } + } + } + + @Override + public void connectionClosed(WebSocketChannel channel, + boolean wasClean, + int code, + String reason) { + _LOG.entering(_CLASS_NAME, "connectionClosed"); + + WebSocketCompositeChannel cc = (WebSocketCompositeChannel)channel; + WebSocketImpl webSocket = (WebSocketImpl) cc.getWebSocket(); + + // Since close() is a blocking call, if there is any thread + // waiting then we should call webSocket.connectionClosed() to + // unblock it. + synchronized (webSocket) { + webSocket.connectionClosed(wasClean, code, reason); + } + } + + @Override + public void connectionClosed(WebSocketChannel channel, Exception ex) { + _LOG.entering(_CLASS_NAME, "onError"); + + WebSocketCompositeChannel cc = (WebSocketCompositeChannel)channel; + WebSocketImpl webSocket = (WebSocketImpl) cc.getWebSocket(); + + synchronized (webSocket) { + webSocket.connectionClosed(ex); + } + } + + @Override + public void connectionFailed(WebSocketChannel channel, Exception ex) { + _LOG.entering(_CLASS_NAME, "onError"); + + WebSocketCompositeChannel cc = (WebSocketCompositeChannel)channel; + WebSocketImpl webSocket = (WebSocketImpl) cc.getWebSocket(); + + synchronized (webSocket) { + webSocket.connectionFailed(ex); + } + } + + @Override + public void authenticationRequested(WebSocketChannel channel, + String location, + String challenge) { + // Should never be fired from WebSocketCompositeHandler + } + + @Override + public void redirected(WebSocketChannel channel, String location) { + // Should never be fired from WebSocketCompositeHandler + } + + @Override + public void commandMessageReceived(WebSocketChannel channel, + CommandMessage message) { + // ignore + } + }; +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsExtensionParameterValuesSpiImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsExtensionParameterValuesSpiImpl.java new file mode 100644 index 0000000..8d2e022 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsExtensionParameterValuesSpiImpl.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl; + +import static java.util.Collections.unmodifiableSet; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.kaazing.net.ws.WebSocketExtension.Parameter; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionParameterValuesSpi; + +public final class WsExtensionParameterValuesSpiImpl extends WebSocketExtensionParameterValuesSpi { + + private final Map, Object> values; + + WsExtensionParameterValuesSpiImpl() { + values = new HashMap, Object>(); + } + + @Override + public Collection> getParameters() { + if (values.isEmpty()) { + return unmodifiableSet(Collections.>emptySet()); + } + + Set> keys = values.keySet(); + return unmodifiableSet(keys); + } + + @Override + public T getParameterValue(Parameter parameter) { + return parameter.type().cast(values.get(parameter)); + } + + public void setParameterValue(Parameter parameter, T value) { + values.put(parameter, value); + } + + // This is used to set value of a negotiated parameter. At that time, we + // only have the string representation of the parameter. So, it's important + // that negotiated parameters be of type String. + public void setParameterValue(Parameter parameter, String value) { + values.put(parameter, value); + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsURLConnectionImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsURLConnectionImpl.java new file mode 100644 index 0000000..654fb34 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsURLConnectionImpl.java @@ -0,0 +1,367 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.Permission; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.kaazing.net.auth.ChallengeHandler; +import org.kaazing.net.http.HttpRedirectPolicy; +import org.kaazing.net.ws.WebSocketExtension.Parameter; +import org.kaazing.net.ws.WebSocketMessageReader; +import org.kaazing.net.ws.WebSocketMessageWriter; +import org.kaazing.net.ws.WsURLConnection; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionFactorySpi; + +public class WsURLConnectionImpl extends WsURLConnection { + private WebSocketImpl _webSocket; + + + public WsURLConnectionImpl(URL location, + Map extensionFactories) { + super(location); + + try { + _webSocket = new WebSocketImpl(location.toURI(), extensionFactories); + } + catch (URISyntaxException e) { + e.printStackTrace(); + } + } + + // -------------------------- WsURLConnection Methods ------------------- + @Override + public void close() throws IOException { + _webSocket.close(); + } + + @Override + public void close(int code) throws IOException { + _webSocket.close(code); + } + + @Override + public void close(int code, String reason) throws IOException { + _webSocket.close(code, reason); + } + + @Override + public void connect() throws IOException { + _webSocket.connect(); + } + + @Override + public ChallengeHandler getChallengeHandler() { + return _webSocket.getChallengeHandler(); + } + + @Override + public int getConnectTimeout() { + return _webSocket.getConnectTimeout(); + } + + @Override + public Collection getEnabledExtensions() { + return _webSocket.getEnabledExtensions(); + } + + @Override + public T getEnabledParameter(Parameter parameter) { + return _webSocket.getEnabledParameter(parameter); + } + + @Override + public Collection getEnabledProtocols() { + return _webSocket.getEnabledExtensions(); + } + + @Override + public HttpRedirectPolicy getRedirectPolicy() { + return _webSocket.getRedirectPolicy(); + } + + @Override + public InputStream getInputStream() throws IOException { + return _webSocket.getInputStream(); + } + + @Override + public WebSocketMessageReader getMessageReader() throws IOException { + return _webSocket.getMessageReader(); + } + + @Override + public WebSocketMessageWriter getMessageWriter() throws IOException { + return _webSocket.getMessageWriter(); + } + + @Override + public Collection getNegotiatedExtensions() { + return _webSocket.getNegotiatedExtensions(); + } + + @Override + public T getNegotiatedParameter(Parameter parameter) { + return _webSocket.getNegotiatedParameter(parameter); + } + + @Override + public String getNegotiatedProtocol() { + return _webSocket.getNegotiatedProtocol(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return _webSocket.getOutputStream(); + } + + @Override + public Reader getReader() throws IOException { + return _webSocket.getReader(); + } + + @Override + public Collection getSupportedExtensions() { + return _webSocket.getSupportedExtensions(); + } + + @Override + public Writer getWriter() throws IOException { + return _webSocket.getWriter(); + } + + @Override + public void setChallengeHandler(ChallengeHandler challengeHandler) { + _webSocket.setChallengeHandler(challengeHandler); + } + + @Override + public void setConnectTimeout(int timeout) { + _webSocket.setConnectTimeout(timeout); + } + + @Override + public void setEnabledExtensions(Collection extensions) { + _webSocket.setEnabledExtensions(extensions); + } + + @Override + public void setEnabledProtocols(Collection protocols) { + _webSocket.setEnabledProtocols(protocols); + } + + @Override + public void setEnabledParameter(Parameter parameter, T value) { + _webSocket.setEnabledParameter(parameter, value); + } + + @Override + public void setRedirectPolicy(HttpRedirectPolicy option) { + _webSocket.setRedirectPolicy(option); + } + + // --------------- Unsupported URLConnection Methods ---------------------- + @Override + public void addRequestProperty(String key, String value) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getReadTimeout() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setReadTimeout(int timeout) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + // @Override -- Not available in JDK 6. + public long getContentLengthLong() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getContentLength() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getContentType() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getContentEncoding() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getExpiration() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getDate() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getLastModified() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getHeaderField(String name) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Map> getHeaderFields() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public int getHeaderFieldInt(String name, int Default) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + // @Override -- Not available in JDK 6. + public long getHeaderFieldLong(String name, long Default) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getHeaderFieldDate(String name, long Default) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getHeaderFieldKey(int n) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getHeaderField(int n) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Object getContent() throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @SuppressWarnings("rawtypes") + @Override + public Object getContent(Class[] classes) throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Permission getPermission() throws IOException { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getDoInput() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setDoInput(boolean doinput) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getDoOutput() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setDoOutput(boolean dooutput) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getAllowUserInteraction() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getUseCaches() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setUseCaches(boolean usecaches) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public long getIfModifiedSince() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setIfModifiedSince(long ifmodifiedsince) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public boolean getDefaultUseCaches() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public String getRequestProperty(String key) { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public Map> getRequestProperties() { + throw new UnsupportedOperationException("Unsupported Operation"); + } + + @Override + public void setRequestProperty(String key, String value) { + throw new UnsupportedOperationException("Unsupported Operation"); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsInputStreamImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsInputStreamImpl.java new file mode 100644 index 0000000..d45eb4a --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsInputStreamImpl.java @@ -0,0 +1,172 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +import org.kaazing.gateway.client.util.WrappedByteBuffer; +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketMessageType; + +public class WsInputStreamImpl extends InputStream { + + private WsMessageReaderAdapter _adapter; + private WrappedByteBuffer _buffer; + private boolean _closed = false; + + public WsInputStreamImpl(WsMessageReaderAdapter adapter) throws IOException { + _adapter = adapter; + } + + @Override + public synchronized int available() throws IOException { + checkStreamClosed(); + + if (_buffer == null) { + return 0; + } + + return _buffer.remaining(); + } + + @Override + public void close() throws IOException { + if (_closed) { + return; + } + + if (_buffer != null) { + _buffer.clear(); + } + + _buffer = null; + _closed = true; + } + + @Override + public void mark(int readLimit) { + throw new UnsupportedOperationException("Not supported"); + } + + @Override + public boolean markSupported() { + return false; + } + + @Override + public synchronized int read() throws IOException { + checkStreamClosed(); + + try { + prepareBuffer(); + } + catch (IOException ex) { + WebSocketMessageType type = _adapter.getType(); + if ((type == WebSocketMessageType.EOS) || (type == null)) { + // End of stream. Return -1 as per the javadoc. + return -1; + } + + // InputStream is used to read a text message. + String s = "InvalidMessageType: InputStream must be used to only " + + "receive binary messages"; + throw new WebSocketException(s, ex); + } + + return _buffer.get(); + } + + @Override + public synchronized int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public synchronized int read(byte[] b, int off, int len) throws IOException { + checkStreamClosed(); + + try { + prepareBuffer(); + } + catch (IOException ex) { + WebSocketMessageType type = _adapter.getType(); + if ((type == WebSocketMessageType.EOS) || (type == null)) { + // End of stream. Return -1 as per the javadoc. + return -1; + } + + // InputStream is used to read a text message. + String s = "InvalidMessageType: InputStream must be used to only " + + "receive binary messages"; + throw new WebSocketException(s, ex); + } + + int remaining = _buffer.remaining(); + int retval = (remaining < len) ? remaining : len; + _buffer.get(b, off, retval); + + return retval; + } + + @Override + public void reset() throws IOException { + checkStreamClosed(); + _buffer.clear(); + _buffer = null; + } + + // ---------------------- Internal Implementation ----------------------- + public boolean isClosed() { + return _closed; + } + + // ---------------------- Private Methods ------------------------------- + private void checkStreamClosed() throws IOException { + if (!_closed) { + return; + } + + String s = "Cannot perform the operation as the InputStream is closed"; + throw new WebSocketException(s); + } + + private void prepareBuffer() throws IOException { + if ((_buffer == null) || (!_buffer.hasRemaining())) { + ByteBuffer byteBuf = _adapter.readBinary(); + + if (_buffer == null) { + _buffer = new WrappedByteBuffer(byteBuf); + } + else { + int pos = _buffer.position(); + int remaining = byteBuf.remaining(); + byte[] bytes = new byte[remaining]; + byteBuf.get(bytes); + _buffer.putBytes(bytes); + _buffer.limit(_buffer.position()); + _buffer.position(pos); + } + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessagePullParser.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessagePullParser.java new file mode 100644 index 0000000..ac1578d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessagePullParser.java @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.kaazing.net.ws.WebSocketMessageReader; +import org.kaazing.net.ws.WebSocketMessageType; + +/** + * The inspiration of this class is the XmlPullParser. Eventually, we may + * decide to expose this class as part of our public APIs. At that time, we + * should rename it to WebSocketMessagePullParser to conform with the + * naming convention for publicly exposed classes. + */ +public class WsMessagePullParser { + private WebSocketMessageReader _messageReader; + + public WsMessagePullParser(WebSocketMessageReader messageReader) { + _messageReader = messageReader; + } + + /** + * Returns the next text message received on this connection. This method + * will block till a text message is received. Any binary messages that may + * arrive will be ignored. A null is returned when the connection is closed. + *

    + * An IOException is thrown if the connection has not been established + * before invoking this method. + * + * @return CharSequence the payload of the text message + * @throws IOException if the connection has not been established + */ + public CharSequence nextText() throws IOException { + WebSocketMessageType msgType = null; + + while ((msgType = _messageReader.next()) != WebSocketMessageType.EOS) { + if (msgType == WebSocketMessageType.TEXT) { + return _messageReader.getText(); + } + } + + return null; + } + + /** + * Returns the next binary message received on this connection. This method + * will block till a binar message is received. Any text messages that may + * arrive will be ignored. A null is returned when the connection is closed. + *

    + * An IOException is thrown if the connection has not been established + * before invoking this method. + * + * @return ByteBuffer the payload of the binary message + * @throws IOException if the connection has not been established + */ + public ByteBuffer nextBinary() throws IOException { + WebSocketMessageType msgType = null; + + while ((msgType = _messageReader.next()) != WebSocketMessageType.EOS) { + if (msgType == WebSocketMessageType.BINARY) { + return _messageReader.getBinary(); + } + } + + return null; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderAdapter.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderAdapter.java new file mode 100644 index 0000000..1a89a33 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderAdapter.java @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.kaazing.net.ws.WebSocketMessageReader; +import org.kaazing.net.ws.WebSocketMessageType; + +/** + * This is an internal adapter that will be used by our {@link InputStream} and + * {@link Reader}implementations to just invoke {@link #readBinary()} and + * {@link #readText()} methods to retrieve binary and text messages + * respectively. + */ +public class WsMessageReaderAdapter { + private WebSocketMessageReader _messageReader; + + public WsMessageReaderAdapter(WebSocketMessageReader messageReader) { + if (messageReader == null) { + String s = "Null WebSocketMessageReader passed in"; + throw new IllegalArgumentException(s); + } + + _messageReader = messageReader; + } + + /** + * Returns the {@link WebSocketMessageType} of the last received message. + * + * @return WebSocketMessageType + */ + public WebSocketMessageType getType() { + return _messageReader.getType(); + } + + /** + * This method will be used our InputStream implementation to continually + * retrieve binary messages. + * + * @return ByteBuffer payload of a binary message + * @throws IOException if this method is used to retrieve a text + * message or the connection is closed + */ + public ByteBuffer readBinary() throws IOException { + _messageReader.next(); + return _messageReader.getBinary(); + } + + /** + * This method will be used our Reader implementation to continually + * retrieve text messages. + * + * @return CharSequence payload of a text message + * @throws IOException if this method is used to retrieve a binary + * message or the connection is closed + */ + public CharSequence readText() throws IOException { + _messageReader.next(); + return _messageReader.getText(); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderImpl.java new file mode 100644 index 0000000..3cfe49d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderImpl.java @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.kaazing.net.impl.util.BlockingQueueImpl; +import org.kaazing.net.ws.WebSocketMessageType; +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketMessageReader; +import org.kaazing.net.ws.impl.WebSocketImpl; + +public class WsMessageReaderImpl extends WebSocketMessageReader { + private static final String _CLASS_NAME = WsMessageReaderImpl.class.getName(); + private static final Logger _LOG = Logger.getLogger(_CLASS_NAME); + + private final BlockingQueueImpl _sharedQueue; + private final WebSocketImpl _webSocket; + private Object _payload; + private WebSocketMessageType _messageType; + private boolean _closed = false; + + public WsMessageReaderImpl(WebSocketImpl webSocket, + BlockingQueueImpl sharedQueue) { + if (webSocket == null) { + String s = "Null webSocket passed in"; + throw new IllegalArgumentException(s); + } + + if (sharedQueue == null) { + String s = "Null sharedQueue passed in"; + throw new IllegalArgumentException(s); + } + + _webSocket = webSocket; + _sharedQueue = sharedQueue; + } + + // --------------------- WebSocketMessageReader Implementation ----------- + @Override + public ByteBuffer getBinary() throws IOException { + if (_messageType == null) { + return null; + } + + if (_messageType == WebSocketMessageType.EOS){ + String s = "End of stream has reached as the connection has been closed"; + throw new WebSocketException(s); + } + + if (_messageType != WebSocketMessageType.BINARY) { + String s = "Invalid WebSocketMessageType: Cannot decode the payload " + + "as a binary message"; + throw new WebSocketException(s); + } + + return ByteBuffer.wrap(((ByteBuffer)_payload).array()); + } + + @Override + public CharSequence getText() throws IOException { + if (_messageType == null) { + return null; + } + + if (_messageType == WebSocketMessageType.EOS){ + String s = "End of stream has reached as the connection has been closed"; + throw new WebSocketException(s); + } + + if (_messageType != WebSocketMessageType.TEXT) { + String s = "Invalid WebSocketMessageType: Cannot decode the payload " + + "as a text message"; + throw new WebSocketException(s); + } + + return String.valueOf(((String)_payload).toCharArray()); + } + + @Override + public WebSocketMessageType getType() { + return _messageType; + } + + @Override + public WebSocketMessageType next() throws IOException { + if (isClosed()) { + String s = "Cannot read as the MessageReader is closed"; + throw new WebSocketException(s); + } + + synchronized (this) { + if ((_sharedQueue.size() == 0) && !_webSocket.isConnected()) { + _messageType = WebSocketMessageType.EOS; + return _messageType; + } + + try { + _payload = null; + _webSocket.setException(null); + _payload = _sharedQueue.take(); + } + catch (InterruptedException ex) { + _LOG.log(Level.FINE, ex.getMessage()); + } + + if (_payload == null) { + String s = "MessageReader has been interrupted maybe the " + + "connection is closed"; + // throw new WebSocketException(s); + _LOG.log(Level.FINE, _CLASS_NAME, s); + + _messageType = WebSocketMessageType.EOS; + return _messageType; + } + + if (_payload.getClass() == String.class) { + _messageType = WebSocketMessageType.TEXT; + } + else { + _messageType = WebSocketMessageType.BINARY; + } + } + + return _messageType; + } + + // ------------------ Package-Private Implementation ---------------------- + // These methods are called from other classes in this package. They are + // not part of the public API. + public void close() throws IOException { + if (isClosed()) { + return; + } + + if (!_webSocket.isDisconnected()) { + String s = "Can't close the MessageReader if the WebSocket is " + + "still connected"; + throw new WebSocketException(s); + } + + _sharedQueue.done(); + _closed = true; + } + + public void reset() throws IOException { + if (isClosed()) { + return; + } + + if (!_webSocket.isDisconnected()) { + String s = "Can't reset the MessageReader if the WebSocket is " + + "still connected"; + throw new WebSocketException(s); + } + + _sharedQueue.reset(); + _payload = null; + _messageType = null; + } + + public boolean isClosed() { + return _closed; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageWriterImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageWriterImpl.java new file mode 100644 index 0000000..716fb8a --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageWriterImpl.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; + +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketMessageWriter; +import org.kaazing.net.ws.impl.WebSocketImpl; + +public class WsMessageWriterImpl extends WebSocketMessageWriter { + private WebSocketImpl _webSocket; + private boolean _closed = false; + + public WsMessageWriterImpl(WebSocketImpl webSocket) { + _webSocket = webSocket; + } + + @Override + public void writeText(CharSequence src) throws IOException { + if (isClosed()) { + String s = "Cannot write as the MessageWriter is closed"; + throw new WebSocketException(s); + } + try { + src.toString().getBytes("UTF-8"); + } + catch (UnsupportedEncodingException e) { + String s = "The platform must support UTF-8 encoded text per RFC 6455"; + throw new IOException(s); + } + + _webSocket.send(src.toString()); + } + + @Override + public void writeBinary(ByteBuffer src) throws IOException { + if (isClosed()) { + String s = "Cannot write as the MessageWriter is closed"; + throw new WebSocketException(s); + } + + _webSocket.send(src); + } + + // ----------------- Internal Implementation ---------------------------- + public void close() { + _closed = true; + _webSocket = null; + } + + public boolean isClosed() { + return _closed; + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsOutputStreamImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsOutputStreamImpl.java new file mode 100644 index 0000000..763aa15 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsOutputStreamImpl.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; + +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketMessageWriter; + +public class WsOutputStreamImpl extends OutputStream { + private WsMessageWriterImpl _writer; + private ByteArrayOutputStream _byteStream; + private boolean _streamClosed; + + public WsOutputStreamImpl(WebSocketMessageWriter writer) { + _writer = (WsMessageWriterImpl) writer; + _byteStream = new ByteArrayOutputStream(); + _streamClosed = false; + } + + @Override + public void close() throws IOException { + synchronized (this) { + if (isClosed()) { + return; + } + + _streamClosed = true; + _byteStream.close(); + _byteStream = null; + } + } + + @Override + public void flush() throws IOException { + synchronized (this) { + _checkStreamClosed(); + + if (_byteStream.size() > 0) { + byte[] bytes = _byteStream.toByteArray(); + _writer.writeBinary(ByteBuffer.wrap(bytes)); + + _byteStream.reset(); + } + } + } + + @Override + public void write(int b) throws IOException { + synchronized (this) { + _checkStreamClosed(); + + // The general contract for write(int) is that one byte is written to + // the output stream. The byte to be written is the eight low-order + // bits of the argument b. The 24 high-order bits of b are ignored. + byte a = (byte)(b & 0xff); + _byteStream.write(a); + } + } + + // ------------------------ Internal Methods ----------------------------- + public boolean isClosed() { + return _streamClosed; + } + + // ------------------------ Private Methods ------------------------------ + private void _checkStreamClosed() throws IOException { + String s = "Cannot perform the operation on the OutputStream as it " + + "is closed"; + if (_streamClosed ) { + throw new WebSocketException(s); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsReaderImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsReaderImpl.java new file mode 100644 index 0000000..84a3117 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsReaderImpl.java @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.io.Reader; +import java.nio.CharBuffer; + +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketMessageType; + +public class WsReaderImpl extends Reader { + private WsMessageReaderAdapter _adapter; + private CharBuffer _charBuffer; + private boolean _closed = false; + + public WsReaderImpl(WsMessageReaderAdapter adapter) throws IOException { + _adapter = adapter; + } + + @Override + public void close() throws IOException { + if (_closed) { + return; + } + + if (_charBuffer != null) { + _charBuffer.clear(); + } + + _charBuffer = null; + _closed = true; + } + + @Override + public void mark(int readAheadLimit) throws IOException { + checkStreamClosed(); + super.mark(readAheadLimit); + } + + @Override + public int read() throws IOException { + checkStreamClosed(); + return super.read(); + } + + @Override + public int read(char[] cbuf) throws IOException { + checkStreamClosed(); + return super.read(cbuf); + } + + @Override + public int read(CharBuffer target) throws IOException { + checkStreamClosed(); + return super.read(target); + } + + @Override + public synchronized int read(char[] cbuf, int off, int len) + throws IOException { + checkStreamClosed(); + + // If the buffer doesn't have data, then get the message and populate + // the buffer with it. + if ((_charBuffer == null) || (_charBuffer.remaining() == 0)) { + try { + CharSequence text = _adapter.readText(); + _charBuffer = CharBuffer.wrap(((String)text).toCharArray()); + } + catch (IOException ex) { + WebSocketMessageType type = _adapter.getType(); + if ((type == WebSocketMessageType.EOS) || (type == null)) { + // End of stream. Return -1 as per the javadoc. + return -1; + } + + // Reader is used to read a binary message. + String s = "Invalid message type: Reader must be used to " + + "only receive text messages"; + throw new WebSocketException(s, ex); + } + } + + // Use the remaining and the passed in length to decide how much can + // be copied over into the passed in array. + int remaining = _charBuffer.remaining(); + int retval = (remaining < len) ? remaining : len; + + _charBuffer.get(cbuf, off, retval); + return retval; + } + + @Override + public boolean ready() throws IOException { + checkStreamClosed(); + + if ((_charBuffer == null) || !_charBuffer.hasRemaining()) { + return false; + } + + return true; + } + + @Override + public void reset() throws IOException { + checkStreamClosed(); + super.reset(); + } + + @Override + public long skip(long n) throws IOException { + checkStreamClosed(); + return super.skip(n); + } + + // -------------------- Internal Implementation ------------------------- + public boolean isClosed() { + return _closed; + } + + // ---------------------- Private Methods ------------------------------- + private void checkStreamClosed() throws IOException { + if (!_closed) { + return; + } + String s = "Cannot perform the operation as the Reader is closed"; + throw new WebSocketException(s); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsWriterImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsWriterImpl.java new file mode 100644 index 0000000..f54b5f4 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsWriterImpl.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.io; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.io.Writer; + +import org.kaazing.net.ws.WebSocketException; +import org.kaazing.net.ws.WebSocketMessageWriter; + +public class WsWriterImpl extends Writer { + private WsMessageWriterImpl _writer; + private StringBuffer _stringBuffer; + private boolean _closed; + + public WsWriterImpl(WebSocketMessageWriter writer) { + _writer = (WsMessageWriterImpl) writer; + _stringBuffer = new StringBuffer(""); + _closed = false; + } + + @Override + public void close() throws IOException { + if (isClosed()) { + return; + } + + synchronized (this) { + if (isClosed()) { + return; + } + + _closed = true; + _stringBuffer = null; + _writer = null; + } + } + + @Override + public void write(char[] cbuf, int offset, int length) throws IOException { + if (cbuf == null) { + throw new IllegalArgumentException("Null char array passed to write()"); + } + + if (offset < 0) { + throw new StringIndexOutOfBoundsException(offset); + } + + if (length < 0) { + throw new StringIndexOutOfBoundsException(length); + } + + if (offset > (cbuf.length - length)) { + throw new StringIndexOutOfBoundsException(offset + length); + } + + synchronized (this) { + _checkWriterClosed(); + _stringBuffer.append(cbuf, offset, length); + } + } + + @Override + public void flush() throws IOException { + synchronized (this) { + _checkWriterClosed(); + + if (_stringBuffer.length() > 0) { + try { + _stringBuffer.toString().getBytes("UTF-8"); + } + catch (UnsupportedEncodingException e) { + String s = "The platform must support UTF-8 encoded text per RFC 6455"; + throw new IOException(s); + } + _writer.writeText(_stringBuffer.toString()); + } + + // We don't want to overwrite the buffer that is making it's way + // through the pipeline. So, let's create a brand new instance + // of StringBuffer to deal with future write() invocations. + _stringBuffer = new StringBuffer(""); + } + } + + // ------------------------ Internal Methods ----------------------------- + public boolean isClosed() { + return _closed; + } + + // ------------------------ Private Methods ------------------------------ + private void _checkWriterClosed() throws IOException { + String s = "Cannot perform the operation on the Writer as it " + + "is closed"; + if (_closed ) { + throw new WebSocketException(s); + } + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionFactorySpi.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionFactorySpi.java new file mode 100644 index 0000000..a8b1638 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionFactorySpi.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.spi; + + +/** + * {@link WebSocketExtensionFactorySpi} is part of Service Provider Interface + * (SPI) for admins/implementors. + *

    + * As part of implementing an extension, the admins/implementors should + * implement the following: + *

      + *
    • a sub-class of {@link WebSocketExtensionFactorySpi} + *
    • a sub-class of {@link WebSocketExtensionSpi} + *
    • a public class with {@link Parameter}s defined as + * constants + *
    + *

    + * In {@link WebSocket#connect()} and {@WsURLConnection#connect()} methods, for + * each of the enabled extensions, the corresponding {@link WebSocketExtensionFactorySpi} + * instance will be used to create a {@link WebSocketExtensionSpi} instance. + *

    + * The extensions that are successfully negotiated between the client and the + * server become part of the WebSocket message processing pipeline. + */ +public abstract class WebSocketExtensionFactorySpi { + + /** + * Returns the name of the extension that this factory will create. + * + * @return String name of the extension + */ + public abstract String getExtensionName(); + + /** + * Creates and returns the singleton{@link WebSocketExtensionSpi} instance for the + * extension that this factory is responsible for. The parameters for the + * extension are specified so that the formatted string that can be put on + * the wire can be supplied by the extension implementor. + * + * @param parameters name-value pairs + * @return WebSocketExtensionSpi singleton instance for the extension + */ + public abstract WebSocketExtensionSpi createWsExtension(WebSocketExtensionParameterValuesSpi parameters); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionHandlerSpi.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionHandlerSpi.java new file mode 100644 index 0000000..06fccae --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionHandlerSpi.java @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.spi; + +/** + * ### TODO: This will be an abstract class. Changed it to get it to compile. + */ +public abstract class WebSocketExtensionHandlerSpi { + public abstract void filterSendHandshakeRequest(NextHandler nextHandler, WsHandshakeRequest message); + public abstract void filterSendBinaryMessage(NextHandler nextHandler, WsBinaryMessage message); + public abstract void filterSendTextMessage(NextHandler nextHandler, WsTextMessage message); + public abstract void filterSendClose(NextHandler nextHandler, WsCloseMessage message); + + public abstract void filterReceiveHandshakeResponse(NextHandler nextHandler, WsHandshakeResponse message); + public abstract void filterReceiveBinaryMessage(NextHandler nextHandler, WsBinaryMessage message); + public abstract void filterReceiveTextMessage(NextHandler nextHandler, WsTextMessage message); + public abstract void filterReceiveClose(NextHandler nextHandler, WsCloseMessage message); + + public static abstract class NextHandler { + public abstract void filterSendHandshakeRequest(WsHandshakeRequest message); + public abstract void filterSendBinaryMessage(WsBinaryMessage message); + public abstract void filterSendTextMessage(WsTextMessage message); + public abstract void filterSendClose(WsCloseMessage message); + + public abstract void filterReceiveHandshakeResponse(WsHandshakeResponse message); + public abstract void filterReceiveBinaryMessage(WsBinaryMessage message); + public abstract void filterReceiveTextMessage(WsTextMessage message); + public abstract void filterReceiveClose(WsCloseMessage message); + } + + // TODO: fix these classes + static class WsHandshakeRequest {} + static class WsHandshakeResponse {} + static class WsBinaryMessage {} + static class WsTextMessage {} + static class WsCloseMessage {} + + public static abstract class Adapter extends WebSocketExtensionHandlerSpi { + + @Override + public void filterSendHandshakeRequest(NextHandler nextHandler, + WsHandshakeRequest message) { + nextHandler.filterSendHandshakeRequest(message); + } + + @Override + public void filterSendBinaryMessage(NextHandler nextHandler, + WsBinaryMessage message) { + nextHandler.filterSendBinaryMessage(message); + } + + @Override + public void filterSendTextMessage(NextHandler nextHandler, + WsTextMessage message) { + nextHandler.filterSendTextMessage(message); + } + + @Override + public void filterSendClose(NextHandler nextHandler, + WsCloseMessage message) { + nextHandler.filterSendClose(message); + } + + @Override + public void filterReceiveHandshakeResponse(NextHandler nextHandler, + WsHandshakeResponse message) { + nextHandler.filterReceiveHandshakeResponse(message); + } + + @Override + public void filterReceiveBinaryMessage(NextHandler nextHandler, + WsBinaryMessage message) { + nextHandler.filterReceiveBinaryMessage(message); + } + + @Override + public void filterReceiveTextMessage(NextHandler nextHandler, + WsTextMessage message) { + nextHandler.filterReceiveTextMessage(message); + } + + @Override + public void filterReceiveClose(NextHandler nextHandler, + WsCloseMessage message) { + nextHandler.filterReceiveClose(message); + } + + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionParameterValuesSpi.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionParameterValuesSpi.java new file mode 100644 index 0000000..1ea8e69 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionParameterValuesSpi.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.spi; + +import java.util.Collection; + +import org.kaazing.net.ws.WebSocket; +import org.kaazing.net.ws.WebSocketExtension.Parameter; +import org.kaazing.net.ws.WsURLConnection; + +/** + * WsExtensionParameterValues is part of Service Provider Interface + * (SPI) for admins/implementors. + *

    + * WsExtensionParameterValues is used to cache extension parameters as + * name-value pairs in a very generic type-safe way. The implementations of + * {@link WebSocket#connect()} and {@link WsURLConnection#connect()} invoke + * {@link WebSocketExtensionFactorySpi#createWsExtension(WebSocketExtensionParameterValuesSpi)} + * method and pass in all the extension parameters that have been earlier set + * by the developer for the enabled extensions. + */ +public abstract class WebSocketExtensionParameterValuesSpi { + /** + * Returns the collection of {@link Parameter} objects of a + * {@link WebSocketExtension} that have been set. Returns an empty + * Collection if no parameters belonging to the extension have been set. + * + * @return Collection> + */ + public abstract Collection> getParameters(); + + /** + * Returns the value of type T of the specified parameter. A null is + * returned if value is not set. + * + * @param Generic type T of the parameter's value + * @param parameter extension parameter + * @return value of type T of the specified extension parameter + */ + public abstract T getParameterValue(Parameter parameter); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionSpi.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionSpi.java new file mode 100644 index 0000000..39ceace --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionSpi.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.spi; + + +/** + * WebSocketExtensionSpi is part of Service Provider Interface (SPI) + * for admins/implementors. + *

    + * A WebSocket Extension implementation consists of the following: + *

      + *
    • a sub-class of WebSocketExtensionFactorySpi + *
    • a sub-class of WebSocketExtensionSpi + *
    • a sub-class of WebSocketExtension with + * {@link Parameter}s defined as constants + *
    + *

    + * Every supported extension will require implementing the aforementioned + * classes. A subset of the supported extensions will be enabled by the + * application developer. + *

    + * The enabled extensions are included on the wire during the handshake for + * the client and the server to negotiate. + *

    + * The successfully negotiated extensions are then added to the WebSocket + * message processing pipeline. + * + * @see RevalidateExtension + */ +public abstract class WebSocketExtensionSpi { + + public abstract WebSocketExtensionHandlerSpi createHandler(); +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerFactorySpiImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerFactorySpiImpl.java new file mode 100644 index 0000000..b45c3e8 --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerFactorySpiImpl.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.url; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; +import static java.util.Collections.unmodifiableMap; + +import java.net.URLStreamHandler; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; + +import org.kaazing.net.URLStreamHandlerFactorySpi; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionFactorySpi; + +public class WsURLStreamHandlerFactorySpiImpl extends URLStreamHandlerFactorySpi { + private static final Collection _supportedProtocols = unmodifiableList(asList("ws", "wse", "wsn")); + private static final Map _extensionFactories; + + static { + Class clazz = WebSocketExtensionFactorySpi.class; + ServiceLoader loader = ServiceLoader.load(clazz); + Map factories = new HashMap(); + + for (WebSocketExtensionFactorySpi factory: loader) { + String extensionName = factory.getExtensionName(); + + if (extensionName != null) + { + factories.put(extensionName, factory); + } + } + _extensionFactories = unmodifiableMap(factories); + } + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if (!_supportedProtocols.contains(protocol)) { + throw new IllegalArgumentException(String.format("Protocol not supported '%s'", protocol)); + } + + return new WsURLStreamHandlerImpl(_extensionFactories); + } + + @Override + public Collection getSupportedProtocols() { + return _supportedProtocols; + } + + // ----------------- Package Private Methods ----------------------------- + Map getExtensionFactories() { + return _extensionFactories; + } + + + // ----------------- Private Methods ------------------------------------- +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerImpl.java new file mode 100644 index 0000000..820840e --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerImpl.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.url; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLStreamHandler; +import java.util.Map; + +import org.kaazing.net.ws.WsURLConnection; +import org.kaazing.net.ws.impl.WsURLConnectionImpl; +import org.kaazing.net.ws.impl.spi.WebSocketExtensionFactorySpi; + +class WsURLStreamHandlerImpl extends URLStreamHandler { + + private final Map _extensionFactories; + + private String _scheme; + + public WsURLStreamHandlerImpl(Map extensionFactories) { + _extensionFactories = extensionFactories; + } + + @Override + protected int getDefaultPort() { + return 80; + } + + @Override + protected WsURLConnection openConnection(URL location) throws IOException { + return new WsURLConnectionImpl(location, _extensionFactories); + } + + @Override + protected void parseURL(URL location, String spec, int start, int limit) { + _scheme = spec.substring(0, spec.indexOf("://")); + + // start needs to be adjusted for schemes that include a ':' such as + // java:wse, etc. + // int index = spec.indexOf(":/"); + // start = index + 1; + + URI specURI = _getSpecURI(spec); + String host = specURI.getHost(); + int port = specURI.getPort(); + String authority = specURI.getAuthority(); + String userInfo = specURI.getUserInfo(); + String path = specURI.getPath(); + String query = specURI.getQuery(); + + setURL(location, _scheme, host, port, authority, userInfo, path, query, null); + // super.parseURL(location, spec, start, limit); + } + + // ### TODO: + // This method is no longer needed as we are not supporting 'java:' + // prefixes. Keeping it for time being. + @Override + protected String toExternalForm(URL location) { + return super.toExternalForm(location); + } + + // ----------------- Private Methods ----------------------------------- + // Creates a URI that can be used to retrieve various parts such as host, + // port, etc. Based on whether the scheme includes ':', the method returns + // the appropriate URI that can be used to retrieve the needed parts. + private URI _getSpecURI(String spec) { + URI specURI = URI.create(spec); + + if (_scheme.indexOf(':') == -1) { + return specURI; + } + + String schemeSpecificPart = specURI.getSchemeSpecificPart(); + return URI.create(schemeSpecificPart); + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerFactorySpiImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerFactorySpiImpl.java new file mode 100644 index 0000000..7681c3c --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerFactorySpiImpl.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.url; + +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; + +import java.net.URLStreamHandler; +import java.util.Collection; + +public class WssURLStreamHandlerFactorySpiImpl extends WsURLStreamHandlerFactorySpiImpl { + private static final Collection _supportedProtocols = unmodifiableList(asList("wss", "wse+ssl", "wssn")); + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if (!_supportedProtocols.contains(protocol)) { + throw new IllegalArgumentException(String.format("Protocol not supported '%s'", protocol)); + } + + return new WssURLStreamHandlerImpl(getExtensionFactories()); + } + + @Override + public Collection getSupportedProtocols() { + return _supportedProtocols; + } +} diff --git a/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerImpl.java b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerImpl.java new file mode 100644 index 0000000..f67b92d --- /dev/null +++ b/cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerImpl.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2007-2014 Kaazing Corporation. All rights reserved. + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kaazing.net.ws.impl.url; + +import java.util.Map; + +import org.kaazing.net.ws.impl.spi.WebSocketExtensionFactorySpi; + +public class WssURLStreamHandlerImpl extends WsURLStreamHandlerImpl { + + public WssURLStreamHandlerImpl( + Map extensionFactories) { + super(extensionFactories); + } + + @Override + protected int getDefaultPort() { + return 443; + } +} diff --git a/cloudsdk/src/main/res/values/config.xml b/cloudsdk/src/main/res/values/config.xml new file mode 100644 index 0000000..572764a --- /dev/null +++ b/cloudsdk/src/main/res/values/config.xml @@ -0,0 +1,14 @@ + + + + http://iot.xlight.io + + + NONE + + diff --git a/cloudsdk/src/main/res/values/oauth_client_creds.xml b/cloudsdk/src/main/res/values/oauth_client_creds.xml new file mode 100644 index 0000000..b8bacd3 --- /dev/null +++ b/cloudsdk/src/main/res/values/oauth_client_creds.xml @@ -0,0 +1,8 @@ + + + + spark-android-app-1234 + d34db33f164e458fdb8daffe48b4ffe9d34db33f + diff --git a/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.URLStreamHandlerFactorySpi b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.URLStreamHandlerFactorySpi new file mode 100644 index 0000000..383208a --- /dev/null +++ b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.URLStreamHandlerFactorySpi @@ -0,0 +1,2 @@ +org.kaazing.net.ws.impl.url.WsURLStreamHandlerFactorySpiImpl +org.kaazing.net.ws.impl.url.WssURLStreamHandlerFactorySpiImpl diff --git a/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.BasicChallengeHandler b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.BasicChallengeHandler new file mode 100644 index 0000000..41deca8 --- /dev/null +++ b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.BasicChallengeHandler @@ -0,0 +1 @@ +org.kaazing.net.impl.auth.DefaultBasicChallengeHandler diff --git a/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.DispatchChallengeHandler b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.DispatchChallengeHandler new file mode 100644 index 0000000..b51cebe --- /dev/null +++ b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.DispatchChallengeHandler @@ -0,0 +1 @@ +org.kaazing.net.impl.auth.DefaultDispatchChallengeHandler diff --git a/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.sse.SseEventSourceFactory b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.sse.SseEventSourceFactory new file mode 100644 index 0000000..60951a7 --- /dev/null +++ b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.sse.SseEventSourceFactory @@ -0,0 +1 @@ +org.kaazing.net.sse.impl.DefaultEventSourceFactory \ No newline at end of file diff --git a/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.ws.WebSocketFactory b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.ws.WebSocketFactory new file mode 100644 index 0000000..ecb91e3 --- /dev/null +++ b/cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.ws.WebSocketFactory @@ -0,0 +1 @@ +org.kaazing.net.ws.impl.DefaultWebSocketFactory \ No newline at end of file diff --git a/pom_generator_v1.gradle b/pom_generator_v1.gradle new file mode 100644 index 0000000..46a0978 --- /dev/null +++ b/pom_generator_v1.gradle @@ -0,0 +1,44 @@ +// lifted from: https://raw.githubusercontent.com/nuuneoi/JCenter/master/installv1.gradle +// and copied into this repo for safety/stability +apply plugin: 'com.github.dcendents.android-maven' + +group = publishedGroupId // Maven Group ID for the artifact + +install { + repositories.mavenInstaller { + // This generates POM.xml with proper parameters + pom { + project { + packaging 'aar' + groupId publishedGroupId + artifactId artifact + + // Add your description here + name libraryName + description libraryDescription + url siteUrl + + // Set your license + licenses { + license { + name licenseName + url licenseUrl + } + } + developers { + developer { + id developerId + name developerName + email developerEmail + } + } + scm { + connection gitUrl + developerConnection gitUrl + url siteUrl + + } + } + } + } +} diff --git a/settings.gradle b/settings.gradle index e7b4def..7a4194f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,2 @@ +include ':cloudsdk' include ':app' From 1abcefbb94e2dc072c1a683852120df71d85ddb9 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sat, 29 Apr 2017 23:47:37 -0400 Subject: [PATCH 17/25] update SDK by following Xlight SmartController API V1.5 --- app/build.gradle | 4 +- .../SDK/Cloud/CloudBridge.java | 231 ++++++++++-------- .../xlightcompanion/SDK/xltDevice.java | 121 ++++++++- .../control/ControlFragment.java | 17 +- .../scenario/ScenarioFragment.java | 4 +- app/src/main/res/layout/fragment_control.xml | 2 +- build.gradle | 2 +- cloudsdk/src/main/res/values/config.xml | 8 +- 8 files changed, 277 insertions(+), 112 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index de8800a..58141fd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.umarbhutta.xlightcompanion" minSdkVersion 19 targetSdkVersion 25 - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "2.0" } buildTypes { release { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java index 5f46ac2..9712c6f 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java @@ -73,8 +73,8 @@ public void run() { int power = state ? xltDevice.STATE_ON : xltDevice.STATE_OFF; // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_POWER + ",\"node_id\":" + getNodeID() + ",\"state\":" + power + "}"; - //String json = "{'cmd':" + VALUE_POWER + ",'node_id':" + nodeId + ",'state':" + power + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_POWER + ",\"nd\":" + getNodeID() + ",\"state\":" + power + "}"; + //String json = "{'cmd':" + VALUE_POWER + ",'nd':" + nodeId + ",'state':" + power + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -94,7 +94,7 @@ public int JSONCommandBrightness(final int value) { @Override public void run() { // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_BRIGHTNESS + ",\"node_id\":" + getNodeID() + ",\"value\":" + value + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_BRIGHTNESS + ",\"nd\":" + getNodeID() + ",\"value\":" + value + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -114,7 +114,7 @@ public int JSONCommandCCT(final int value) { @Override public void run() { // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_CCT + ",\"node_id\":" + getNodeID() + ",\"value\":" + value + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_CCT + ",\"nd\":" + getNodeID() + ",\"value\":" + value + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -136,7 +136,7 @@ public void run() { // Make the Particle call here int power = state ? 1 : 0; - String json = "{\"cmd\":" + xltDevice.CMD_COLOR + ",\"node_id\":" + getNodeID() + ",\"ring\":[" + ring + "," + power + "," + br + "," + ww + "," + r + "," + g + "," + b + "]}"; + String json = "{\"cmd\":" + xltDevice.CMD_COLOR + ",\"nd\":" + getNodeID() + ",\"ring\":[" + ring + "," + power + "," + br + "," + ww + "," + r + "," + g + "," + b + "]}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -160,7 +160,7 @@ public void run() { //hence the parameter of position is good to go in this function as is - doesn't need to be incremented by 1 for the uid for scenario // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_SCENARIO + ",\"node_id\":" + getNodeID() + ",\"SNT_id\":" + scenario + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_SCENARIO + ",\"nd\":" + getNodeID() + ",\"SNT_id\":" + scenario + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -175,12 +175,32 @@ public void run() { return resultCode; } + public int JSONCommandSpecialEffect(final int filter) { + new Thread() { + @Override + public void run() { + // Make the Particle call here + String json = "{\"cmd\":" + xltDevice.CMD_EFFECT + ",\"nd\":" + getNodeID() + ",\"filter\":" + filter + "}"; + ArrayList message = new ArrayList<>(); + message.add(json); + try { + Log.e(TAG, "JSONCommandSpecialEffect " + message.get(0)); + resultCode = currDevice.callFunction("JSONCommand", message); + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + } + }.start(); + return resultCode; + } + public int JSONCommandQueryDevice() { new Thread() { @Override public void run() { // Make the Particle call here - String json = "{\"cmd\":" + xltDevice.CMD_QUERY + ",\"node_id\":" + getNodeID() + "}"; + String json = "{\"cmd\":" + xltDevice.CMD_QUERY + ",\"nd\":" + getNodeID() + "}"; ArrayList message = new ArrayList<>(); message.add(json); try { @@ -300,7 +320,7 @@ public void run() { boolean x[] = {false, false}; //construct first part of string input, and store it in arraylist (of size 1) - String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + ruleId + "\",\"node_id\":" + getNodeID() + ", '}"; + String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + ruleId + "\",\"nd\":" + getNodeID() + ", '}"; ArrayList message = new ArrayList<>(); message.add(json); //send in first part of string @@ -383,21 +403,50 @@ public void onEvent(String eventName, ParticleEvent event) { Log.i(TAG, "Received event: " + eventName + " with payload: " + event.dataPayload); // Notes: due to bug of SDK 0.3.4, the eventName is not correct /// We work around by specifying eventName + /* if( event.dataPayload.contains("DHTt") || event.dataPayload.contains("ALS") || event.dataPayload.contains("PIR") ) { eventName = xltDevice.eventSensorData; } else { eventName = xltDevice.eventDeviceStatus; - } + }*/ if( m_parentDevice != null ) { // Demo option: use handler & sendMessage to inform activities - if( m_parentDevice.getEnableEventSendMessage() ) { - InformActivities(eventName, event.dataPayload); - } - - // Demo Option: use broadcast & receivers to publish events - if( m_parentDevice.getEnableEventBroadcast() ) { - BroadcastEvent(eventName, event.dataPayload); + // Parsing Event + Bundle bdlEventData = new Bundle(); + if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { + int nodeId = ParseDeviceStatusEvent(event.dataPayload, bdlEventData); + if( nodeId > 0 ) { + if( m_parentDevice.getEnableEventSendMessage() ) { + m_parentDevice.sendDeviceStatusMessage(bdlEventData); + } + if( m_parentDevice.getEnableEventBroadcast() ) { + Intent devStatus = new Intent(xltDevice.bciDeviceStatus); + devStatus.putExtra("nd", nodeId); + m_parentContext.sendBroadcast(devStatus); + } + } + } else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { + if( ParseSensorDataEvent(event.dataPayload, bdlEventData) > 0 ) { + if( m_parentDevice.getEnableEventSendMessage() ) { + m_parentDevice.sendSensorDataMessage(bdlEventData); + } + if( m_parentDevice.getEnableEventBroadcast() ) { + m_parentContext.sendBroadcast(new Intent(xltDevice.bciSensorData)); + } + } + } else if (eventName.equalsIgnoreCase(xltDevice.eventAlarm)) { + // ToDo: parse alarm message and Send alarm message + //... + if( m_parentDevice.getEnableEventBroadcast() ) { + m_parentContext.sendBroadcast(new Intent(xltDevice.bciAlarm)); + } + } else if (eventName.equalsIgnoreCase(xltDevice.eventDeviceConfig)) { + // ToDo: parse 3 formats and send device config message + //... + if (m_parentDevice.getEnableEventBroadcast()) { + m_parentContext.sendBroadcast(new Intent(xltDevice.bciDeviceConfig)); + } } } } @@ -429,133 +478,113 @@ public void run() { }.start(); } - // Use handler & sendMessage to inform activities - private void InformActivities(final String eventName, final String dataPayload) { - if (m_parentDevice == null) return; - try { - JSONObject jObject = new JSONObject(dataPayload); - if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { - if (jObject.has("nd")) { - int nodeId = jObject.getInt("nd"); - int ringId = xltDevice.RING_ID_ALL; - if (nodeId == m_parentDevice.getDeviceID() || m_parentDevice.findNodeFromDeviceList(nodeId) >= 0) { - Bundle bdlControl = new Bundle(); - bdlControl.putInt("nd", nodeId); - if (jObject.has("Ring")) { - ringId = jObject.getInt("Ring"); - } - bdlControl.putInt("Ring", ringId); - if (jObject.has("State")) { - m_parentDevice.setState(nodeId, jObject.getInt("State")); - bdlControl.putInt("State", jObject.getInt("State")); - } - if (jObject.has("BR")) { - m_parentDevice.setBrightness(nodeId, jObject.getInt("BR")); - bdlControl.putInt("BR", jObject.getInt("BR")); - } - if (jObject.has("CCT")) { - m_parentDevice.setCCT(nodeId, jObject.getInt("CCT")); - bdlControl.putInt("CCT", jObject.getInt("CCT")); - } - if (jObject.has("W")) { - m_parentDevice.setWhite(nodeId, ringId, jObject.getInt("W")); - bdlControl.putInt("W", jObject.getInt("W")); - } - if (jObject.has("R")) { - m_parentDevice.setRed(nodeId, ringId, jObject.getInt("R")); - bdlControl.putInt("R", jObject.getInt("R")); - } - if (jObject.has("G")) { - m_parentDevice.setGreen(nodeId, ringId, jObject.getInt("G")); - bdlControl.putInt("G", jObject.getInt("G")); - } - if (jObject.has("B")) { - m_parentDevice.setBlue(nodeId, ringId, jObject.getInt("B")); - bdlControl.putInt("B", jObject.getInt("B")); - } - m_parentDevice.sendDeviceStatusMessage(bdlControl); - } - } - } else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { - Bundle bdlData = new Bundle(); - if (jObject.has("DHTt")) { - m_parentDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); - bdlData.putInt("DHTt", (int)m_parentDevice.m_Data.m_RoomTemp); - } - if (jObject.has("DHTh")) { - m_parentDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); - bdlData.putInt("DHTh", m_parentDevice.m_Data.m_RoomHumidity); - } - if (jObject.has("ALS")) { - m_parentDevice.m_Data.m_RoomBrightness = jObject.getInt("ALS"); - bdlData.putInt("ALS", m_parentDevice.m_Data.m_RoomBrightness); - } - m_parentDevice.sendSensorDataMessage(bdlData); - } - } catch (final JSONException e) { - Log.e(TAG, "Json parsing error: " + e.getMessage()); - } - } - - // Demo Option: use broadcast & receivers to publish events - private void BroadcastEvent(final String eventName, String dataPayload) { + private int ParseDeviceStatusEvent(final String dataPayload, Bundle bdlControl) { int nodeId = -1; - try { JSONObject jObject = new JSONObject(dataPayload); if (jObject.has("nd")) { nodeId = jObject.getInt("nd"); int ringId = xltDevice.RING_ID_ALL; - // search device - if (nodeId == m_parentDevice.getDeviceID() || m_parentDevice.findNodeFromDeviceList(nodeId) >= 0) { + if (nodeId == m_parentDevice.getDeviceID() || m_parentDevice.findNodeFromDeviceList(nodeId) >= 0) { + bdlControl.putInt("nd", nodeId); + if (jObject.has("up")) { + m_parentDevice.setNodeAlive(nodeId, jObject.getInt("up") > 0); + bdlControl.putInt("up", jObject.getInt("up")); + } else { + m_parentDevice.setNodeAlive(nodeId, true); + } + if (jObject.has("tp")) { + m_parentDevice.setDeviceType(nodeId, jObject.getInt("tp")); + bdlControl.putInt("type", jObject.getInt("tp")); + } + if (jObject.has("filter")) { + m_parentDevice.setFilter(nodeId, jObject.getInt("filter")); + bdlControl.putInt("filter", jObject.getInt("filter")); + } if (jObject.has("Ring")) { ringId = jObject.getInt("Ring"); } + bdlControl.putInt("Ring", ringId); if (jObject.has("State")) { m_parentDevice.setState(nodeId, jObject.getInt("State")); + bdlControl.putInt("State", jObject.getInt("State")); } if (jObject.has("BR")) { m_parentDevice.setBrightness(nodeId, jObject.getInt("BR")); + bdlControl.putInt("BR", jObject.getInt("BR")); } if (jObject.has("CCT")) { m_parentDevice.setCCT(nodeId, jObject.getInt("CCT")); + bdlControl.putInt("CCT", jObject.getInt("CCT")); } if (jObject.has("W")) { m_parentDevice.setWhite(nodeId, ringId, jObject.getInt("W")); + bdlControl.putInt("W", jObject.getInt("W")); } if (jObject.has("R")) { m_parentDevice.setRed(nodeId, ringId, jObject.getInt("R")); + bdlControl.putInt("R", jObject.getInt("R")); } if (jObject.has("G")) { m_parentDevice.setGreen(nodeId, ringId, jObject.getInt("G")); + bdlControl.putInt("G", jObject.getInt("G")); } if (jObject.has("B")) { m_parentDevice.setBlue(nodeId, ringId, jObject.getInt("B")); + bdlControl.putInt("B", jObject.getInt("B")); } } } + } catch (final JSONException e) { + Log.e(TAG, "Json ParseDeviceStatusEvent error: " + e.getMessage()); + return -1; + } + return nodeId; + } + + private int ParseSensorDataEvent(final String dataPayload, Bundle bdlData) { + try { + JSONObject jObject = new JSONObject(dataPayload); if (jObject.has("DHTt")) { m_parentDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); + bdlData.putInt("DHTt", (int)m_parentDevice.m_Data.m_RoomTemp); } if (jObject.has("DHTh")) { m_parentDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); + bdlData.putInt("DHTh", m_parentDevice.m_Data.m_RoomHumidity); } if (jObject.has("ALS")) { m_parentDevice.m_Data.m_RoomBrightness = jObject.getInt("ALS"); + bdlData.putInt("ALS", m_parentDevice.m_Data.m_RoomBrightness); } - //} - } catch (final JSONException e) { - Log.e(TAG, "Json parsing error: " + e.getMessage()); - } - - if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { - if( nodeId >= 0 ) { - Intent devStatus = new Intent(xltDevice.bciDeviceStatus); - devStatus.putExtra("nd", nodeId); - m_parentContext.sendBroadcast(devStatus); + if (jObject.has("MIC")) { + m_parentDevice.m_Data.m_Mic = jObject.getInt("MIC"); + bdlData.putInt("MIC", m_parentDevice.m_Data.m_Mic); + } + if (jObject.has("PIR")) { + m_parentDevice.m_Data.m_PIR = jObject.getInt("PIR"); + bdlData.putInt("PIR", m_parentDevice.m_Data.m_PIR); + } + if (jObject.has("GAS")) { + m_parentDevice.m_Data.m_GAS = jObject.getInt("GAS"); + bdlData.putInt("GAS", m_parentDevice.m_Data.m_GAS); + } + if (jObject.has("SMK")) { + m_parentDevice.m_Data.m_Smoke = jObject.getInt("SMK"); + bdlData.putInt("SMK", m_parentDevice.m_Data.m_Smoke); } - } else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { - m_parentContext.sendBroadcast(new Intent(xltDevice.bciSensorData)); + if (jObject.has("PM25")) { + m_parentDevice.m_Data.m_PM25 = jObject.getInt("PM25"); + bdlData.putInt("PM25", m_parentDevice.m_Data.m_PM25); + } + if (jObject.has("NOS")) { + m_parentDevice.m_Data.m_Noise = jObject.getInt("NOS"); + bdlData.putInt("NOS", m_parentDevice.m_Data.m_Noise); + } + } catch (final JSONException e) { + Log.e(TAG, "Json ParseSensorDataEvent error: " + e.getMessage()); + return -1; } + return 1; } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index a1a9890..acf1b51 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -49,12 +49,16 @@ public class xltDevice { public static final int DEFAULT_FILTER_ID = 0; // Event Names + public static final String eventAlarm = "xlc-event-alarm"; + public static final String eventDeviceConfig = "xlc-config-device"; public static final String eventDeviceStatus = "xlc-status-device"; public static final String eventSensorData = "xlc-data-sensor"; // Broadcast Intent - public static final String bciDeviceStatus = "ca.xlight.SDK." + eventDeviceStatus; - public static final String bciSensorData = "ca.xlight.SDK." + eventSensorData; + public static final String bciAlarm = "io.xlight.SDK." + eventAlarm; + public static final String bciDeviceConfig = "io.xlight.SDK." + eventDeviceConfig; + public static final String bciDeviceStatus = "io.xlight.SDK." + eventDeviceStatus; + public static final String bciSensorData = "io.xlight.SDK." + eventSensorData; // Timeout constants private static final int TIMEOUT_CLOUD_LOGIN = 15; @@ -82,6 +86,7 @@ public class xltDevice { public static final int CMD_SCENARIO = 4; public static final int CMD_CCT = 5; public static final int CMD_QUERY = 6; + public static final int CMD_EFFECT = 7; // Device (lamp) type public static final int devtypUnknown = 0; @@ -98,6 +103,13 @@ public class xltDevice { public static final int DEFAULT_DEVICE_TYPE = devtypWRing3; + // Filter (special effect) + public static final int FILTER_SP_EF_NONE = 0; // Disable special effect + public static final int FILTER_SP_EF_BREATH = 1; // Normal breathing light + public static final int FILTER_SP_EF_FAST_BREATH = 2; // Fast breathing light + public static final int FILTER_SP_EF_FLORID = 3; // Randomly altering color + public static final int FILTER_SP_EF_FAST_FLORID = 4; // Fast randomly altering color + public enum BridgeType { NONE, Cloud, @@ -144,6 +156,12 @@ public class SensorData { public float m_RoomTemp = 24; // Room temperature public int m_RoomHumidity = 40; // Room humidity public int m_RoomBrightness = 0; // ALS value + public int m_Mic = 0; // MIC value + public int m_PIR = 0; // PIR value + public int m_GAS = 0; // Gas value + public int m_Smoke = 0; // Smoke value + public int m_PM25 = 0; // PM2.5 value + public int m_Noise = 0; // Noise value public float m_OutsideTemp = 23; // Local outside temperature public int m_OutsideHumidity = 30; // Local outside humidity @@ -155,6 +173,8 @@ public class SensorData { public class xltNodeInfo { public int m_ID = 0; public int m_Type; + public boolean m_isUp; + public int m_filter; public String m_Name; // Rings public xltRing[] m_Ring = new xltRing[MAX_RING_NUM]; @@ -339,6 +359,8 @@ public int addNodeToDeviceList(final int devID, final int devType, final String xltNodeInfo lv_node = new xltNodeInfo(); lv_node.m_ID = devID; lv_node.m_Type = devType; + lv_node.m_isUp = false; + lv_node.m_filter = FILTER_SP_EF_NONE; lv_node.m_Name = devName; for(int i = 0; i < MAX_RING_NUM; i++) { lv_node.m_Ring[i] = new xltRing(); @@ -406,10 +428,83 @@ public int getDeviceType() { return(m_currentNode != null ? m_currentNode.m_Type : devtypDummy); } + public int getDeviceType(final int nodeID) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_Type; + } + return(devtypUnknown); + } + + public void setDeviceType(final int type) { + setDeviceType(m_DevID, type); + } + + public void setDeviceType(final int nodeID, final int type) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + m_lstNodes.get(lv_dev).m_Type = type; + } + } + public String getDeviceName() { return(m_currentNode != null ? m_currentNode.m_Name : ""); } + public String getDeviceName(final int nodeID) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_Name; + } + return(""); + } + + public boolean getNodeAlive() { + return getNodeAlive(m_DevID); + } + + public boolean getNodeAlive(final int nodeID) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_isUp; + } + return(false); + } + + public void setNodeAlive(final boolean isUp) { + setNodeAlive(m_DevID, isUp); + } + + public void setNodeAlive(final int nodeID, final boolean isUp) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + m_lstNodes.get(lv_dev).m_isUp = isUp; + } + } + + public int getFilter() { + return getFilter(m_DevID); + } + + public int getFilter(final int nodeID) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_filter; + } + return(FILTER_SP_EF_NONE); + } + + public void setFilter(final int filter) { + setFilter(m_DevID, filter); + } + + public void setFilter(final int nodeID, final int filter) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + m_lstNodes.get(lv_dev).m_filter = filter; + } + } + public int getState() { return(getState(m_DevID)); } @@ -926,6 +1021,28 @@ public int ChangeScenario(final int scenario) { return rc; } + // Set Special Effect + public int SetSpecialEffect(final int filter) { + int rc = -1; + + // Select Bridge + selectBridge(); + if( isBridgeOK(m_currentBridge) ) { + switch(m_currentBridge) { + case Cloud: + rc = cldBridge.JSONCommandSpecialEffect(filter); + break; + case BLE: + // ToDo: call BLE API + break; + case LAN: + // ToDo: call LAN API + break; + } + } + return rc; + } + //------------------------------------------------------------------------- // Event Handler Interfaces //------------------------------------------------------------------------- diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index c119c29..0c79d99 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -337,6 +337,9 @@ public void onItemSelected(AdapterView parent, View view, int position, long if (parent.getItemAtPosition(position).toString() == "None") { //scenarioNoneLL.animate().alpha(1).setDuration(600).start(); + // Clear Special Effect + MainActivity.m_mainDevice.SetSpecialEffect(xltDevice.FILTER_SP_EF_NONE); + //enable all views below spinner disableEnableControls(true); } else { @@ -352,9 +355,19 @@ public void onItemSelected(AdapterView parent, View view, int position, long // For demonstration if (parent.getItemAtPosition(position).toString() == "Dinner") { - MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 70, 197, 136, 33, 0); + if( MainActivity.m_mainDevice.isSunny() ) { + MainActivity.m_mainDevice.ChangeScenario(1); + } else { + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 70, 197, 136, 33, 0); + } } else if (parent.getItemAtPosition(position).toString() == "Sleep") { - MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 10, 26, 254, 52, 0); + if( MainActivity.m_mainDevice.isSunny() ) { + MainActivity.m_mainDevice.ChangeScenario(2); + } else { + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 10, 26, 254, 52, 0); + } + } else if (parent.getItemAtPosition(position).toString() == "Breathe") { + MainActivity.m_mainDevice.SetSpecialEffect(xltDevice.FILTER_SP_EF_BREATH); } else if (parent.getItemAtPosition(position).toString() == "Dance") { if (isStop) { startTimer(); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java index 5075e0e..b63df35 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java @@ -28,8 +28,8 @@ public class ScenarioFragment extends Fragment { public static String SCENARIO_NAME = "SCENARIO_NAME"; public static String SCENARIO_INFO = "SCENARIO_INFO"; - public static ArrayList name = new ArrayList<>(Arrays.asList("Dinner", "Sleep", "Dance")); - public static ArrayList info = new ArrayList<>(Arrays.asList("A bright, party room preset", "A relaxed atmosphere with yellow tones", "Random breathing color")); + public static ArrayList name = new ArrayList<>(Arrays.asList("Dinner", "Sleep", "Breathe", "Dance")); + public static ArrayList info = new ArrayList<>(Arrays.asList("A bright, party room preset", "A relaxed atmosphere with yellow tones", "Breathing light", "Random breathing color")); ScenarioListAdapter scenarioListAdapter; RecyclerView scenarioRecyclerView; diff --git a/app/src/main/res/layout/fragment_control.xml b/app/src/main/res/layout/fragment_control.xml index 5edea51..7b58d17 100644 --- a/app/src/main/res/layout/fragment_control.xml +++ b/app/src/main/res/layout/fragment_control.xml @@ -106,7 +106,7 @@ android:layout_weight="1" android:fontFamily="sans-serif-light" android:gravity="left|start" - android:text="Scenario" + android:text="Scenario and Effect" android:textAppearance="?android:attr/textAppearanceMedium" android:textColor="@color/textColorPrimary" /> diff --git a/build.gradle b/build.gradle index d17194e..969ecf4 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.0' + classpath 'com.android.tools.build:gradle:2.3.1' classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.7.3' classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5' classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.6.2' diff --git a/cloudsdk/src/main/res/values/config.xml b/cloudsdk/src/main/res/values/config.xml index 572764a..e392a7c 100644 --- a/cloudsdk/src/main/res/values/config.xml +++ b/cloudsdk/src/main/res/values/config.xml @@ -1,7 +1,13 @@ - http://iot.xlight.io + + + https://api.particle.io - https://api.particle.io + https://iot.xlight.io - https://iot.xlight.io + https://api.particle.io + Hello blank fragment + AddNewDevice From c907dc9fefa8eb495a32ec82c45ed53c0322fe47 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 20 Dec 2017 01:58:48 -0500 Subject: [PATCH 25/25] add, update & delect node from device list --- .idea/misc.xml | 15 +- .../java/ca/xlight/demoapp/SDK/xltDevice.java | 11 + .../demoapp/control/DevicesListAdapter.java | 101 ++- .../xlight/demoapp/glance/AddNewDevice.java | 25 + .../xlight/demoapp/glance/GlanceFragment.java | 75 +- .../swipeitemlayout/SwipeItemLayout.java | 775 ++++++++++++++++++ .../main/res/drawable-xxhdpi/bkg_press.png | Bin 0 -> 468 bytes .../main/res/drawable-xxhdpi/btn_delete.xml | 16 + app/src/main/res/drawable-xxhdpi/btn_mark.xml | 16 + .../main/res/drawable/ic_delete_red_24dp.xml | 9 + .../main/res/drawable/ic_delete_red_48dp.xml | 9 + .../res/drawable/ic_highlight_off_24dp.xml | 9 + .../res/drawable/ic_info_outline_24dp.xml | 9 + .../res/drawable/ic_settings_blue_48dp.xml | 4 + .../res/layout/activity_add_new_device.xml | 2 +- app/src/main/res/layout/devices_list_item.xml | 37 +- app/src/main/res/layout/fragment_glance.xml | 2 - .../main/res/layout/scenario_list_item.xml | 2 +- app/src/main/res/values/strings.xml | 2 - 19 files changed, 1041 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/ca/xlight/demoapp/swipeitemlayout/SwipeItemLayout.java create mode 100644 app/src/main/res/drawable-xxhdpi/bkg_press.png create mode 100644 app/src/main/res/drawable-xxhdpi/btn_delete.xml create mode 100644 app/src/main/res/drawable-xxhdpi/btn_mark.xml create mode 100644 app/src/main/res/drawable/ic_delete_red_24dp.xml create mode 100644 app/src/main/res/drawable/ic_delete_red_48dp.xml create mode 100644 app/src/main/res/drawable/ic_highlight_off_24dp.xml create mode 100644 app/src/main/res/drawable/ic_info_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_settings_blue_48dp.xml diff --git a/.idea/misc.xml b/.idea/misc.xml index fbb6828..635999d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,8 +1,5 @@ - - - - - - - - - - - - - - + diff --git a/app/src/main/java/ca/xlight/demoapp/SDK/xltDevice.java b/app/src/main/java/ca/xlight/demoapp/SDK/xltDevice.java index f65f22f..42ae128 100644 --- a/app/src/main/java/ca/xlight/demoapp/SDK/xltDevice.java +++ b/app/src/main/java/ca/xlight/demoapp/SDK/xltDevice.java @@ -657,6 +657,17 @@ public String getDeviceName(final int nodeID) { return(""); } + public void setDeviceName(final String name) { + setDeviceName(m_DevID, name); + } + + public void setDeviceName(final int nodeID, final String name) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + m_lstNodes.get(lv_dev).m_Name = name; + } + } + public boolean getNodeAlive() { return getNodeAlive(m_DevID); } diff --git a/app/src/main/java/ca/xlight/demoapp/control/DevicesListAdapter.java b/app/src/main/java/ca/xlight/demoapp/control/DevicesListAdapter.java index c678bbf..b9578dc 100644 --- a/app/src/main/java/ca/xlight/demoapp/control/DevicesListAdapter.java +++ b/app/src/main/java/ca/xlight/demoapp/control/DevicesListAdapter.java @@ -14,11 +14,14 @@ import android.widget.ImageView; import android.widget.Switch; import android.widget.TextView; +import android.widget.Toast; import java.util.ArrayList; import ca.xlight.demoapp.SDK.xltDevice; import ca.xlight.demoapp.Tools.StatusReceiver; +import ca.xlight.demoapp.glance.AddNewDevice; +import ca.xlight.demoapp.glance.GlanceFragment; import ca.xlight.demoapp.main.MainActivity; import ca.xlight.demoapp.R; @@ -142,7 +145,7 @@ public int getItemCount() { return MainActivity.deviceNodeIDs.size(); } - private class DevicesListViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + private class DevicesListViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { private TextView mDeviceName; private Switch mDeviceSwitch; private ImageView mStatusIcon; @@ -150,43 +153,22 @@ private class DevicesListViewHolder extends RecyclerView.ViewHolder implements V public DevicesListViewHolder(View itemView) { super(itemView); + + View main = itemView.findViewById(R.id.main); + main.setOnClickListener(this); + main.setOnLongClickListener(this); + + View mark = itemView.findViewById(R.id.mark); + mark.setOnClickListener(this); + View delete = itemView.findViewById(R.id.delete); + delete.setOnClickListener(this); + mDeviceName = (TextView) itemView.findViewById(R.id.deviceName); mDeviceSwitch = (Switch) itemView.findViewById(R.id.deviceSwitch); mStatusIcon = (ImageView) itemView.findViewById(R.id.statusIcon); - //itemView.setOnClickListener(this); - itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - // Change Current Device/Node - MainActivity.m_mainDevice.setDeviceID(mDeviceID); - } - }); - - itemView.setOnLongClickListener(new View.OnLongClickListener() { - @Override - public boolean onLongClick(View v) { - // Change Current Device/Node - MainActivity.m_mainDevice.setDeviceID(mDeviceID); - int nType = MainActivity.m_mainDevice.getDeviceType(mDeviceID); - if ( !MainActivity.m_mainDevice.isSwitch(nType) ) { - // Bring to Control Activity only if node is not switch device - /// ToDo: may bring to multiple switch control screen, so far we only consider single switch - /// ToDo: Therefore, we don't need sub-screen. - if (MainActivity.m_eventHandler != null) { - Message msg = MainActivity.m_eventHandler.obtainMessage(); - if (msg != null) { - Bundle bdlData = new Bundle(); - bdlData.putInt("cmd", 1); // Menu - bdlData.putInt("item", R.id.nav_control); // Item - msg.setData(bdlData); - MainActivity.m_eventHandler.sendMessage(msg); - } - } - } - return false; - } - }); + itemView.setOnClickListener(this); + itemView.setOnLongClickListener(this); mDeviceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener(){ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { @@ -229,6 +211,57 @@ public void bindView (int position) { @Override public void onClick(View v) { + int pos; + switch (v.getId()) { + case R.id.main: + MainActivity.m_mainDevice.setDeviceID(mDeviceID); + break; + + case R.id.mark: + pos = getAdapterPosition(); + if (GlanceFragment.wndHandler != null) { + (GlanceFragment.wndHandler).showDeivceInfoUpdate(MainActivity.deviceNodeIDs.get(pos), MainActivity.deviceNames.get(pos), MainActivity.deviceNodeTypeIDs.get(pos)); + } + notifyItemChanged(pos); + break; + + case R.id.delete: + pos = getAdapterPosition(); + MainActivity.m_mainDevice.removeNodeFromDeviceList(Integer.parseInt(MainActivity.deviceNodeIDs.get(pos))); + MainActivity.deviceNames.remove(pos); + MainActivity.deviceNodeIDs.remove(pos); + MainActivity.deviceNodeTypeIDs.remove(pos); + notifyItemRemoved(pos); + Toast.makeText(v.getContext(), "Device has been removed", Toast.LENGTH_SHORT).show(); + break; + } + } + + @Override + public boolean onLongClick(View v) { + switch (v.getId()) { + case R.id.main: + // Change Current Device/Node + MainActivity.m_mainDevice.setDeviceID(mDeviceID); + int nType = MainActivity.m_mainDevice.getDeviceType(mDeviceID); + if ( !MainActivity.m_mainDevice.isSwitch(nType) ) { + // Bring to Control Activity only if node is not switch device + /// ToDo: may bring to multiple switch control screen, so far we only consider single switch + /// ToDo: Therefore, we don't need sub-screen. + if (MainActivity.m_eventHandler != null) { + Message msg = MainActivity.m_eventHandler.obtainMessage(); + if (msg != null) { + Bundle bdlData = new Bundle(); + bdlData.putInt("cmd", 1); // Menu + bdlData.putInt("item", R.id.nav_control); // Item + msg.setData(bdlData); + MainActivity.m_eventHandler.sendMessage(msg); + } + } + } + break; + } + return false; } } } diff --git a/app/src/main/java/ca/xlight/demoapp/glance/AddNewDevice.java b/app/src/main/java/ca/xlight/demoapp/glance/AddNewDevice.java index c3e263d..6346eb5 100644 --- a/app/src/main/java/ca/xlight/demoapp/glance/AddNewDevice.java +++ b/app/src/main/java/ca/xlight/demoapp/glance/AddNewDevice.java @@ -25,10 +25,34 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_add_new_device); + TextView m_txtTitle = (TextView) findViewById(R.id.lblTitleDevInfo); m_txtName = (TextView) findViewById(R.id.editDeviceName); m_txtNodeID = (TextView) findViewById(R.id.editNodeID); m_btnDone = (Button) findViewById(R.id.btnAddDone); + Intent data = getIntent(); + String incomingID = data.getStringExtra(GlanceFragment.DEVICE_NODE_ID); + String w_title, incomingName, incomingType; + int selectedType = -1; + if (incomingID.length() > 0) { + incomingName = data.getStringExtra(GlanceFragment.DEVICE_NAME); + incomingType = data.getStringExtra(GlanceFragment.DEVICE_NODE_TYPE); + for (int i = 0; i < MainActivity.mDeviceTypeIDs.length; i++ ) { + if (MainActivity.mDeviceTypeIDs[i].equalsIgnoreCase(incomingType) ) { + selectedType = i; + break; + } + } + w_title = "Update Device"; + m_txtNodeID.setKeyListener(null); + } else { + incomingName = ""; + w_title = "Add Device"; + } + m_txtTitle.setText(w_title); + m_txtNodeID.setText(incomingID); + m_txtName.setText(incomingName); + //initialize device type spinner m_typeSpinner = (Spinner) findViewById(R.id.DeviceTypeSpinner); // Create an ArrayAdapter using the string array and a default spinner layout @@ -37,6 +61,7 @@ protected void onCreate(Bundle savedInstanceState) { deviceAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); // Apply the scenarioAdapter to the spinner m_typeSpinner.setAdapter(deviceAdapter); + m_typeSpinner.setSelection(selectedType, true); //on click for add button m_btnDone.setOnClickListener(new View.OnClickListener() { diff --git a/app/src/main/java/ca/xlight/demoapp/glance/GlanceFragment.java b/app/src/main/java/ca/xlight/demoapp/glance/GlanceFragment.java index 928ffed..ae172f7 100644 --- a/app/src/main/java/ca/xlight/demoapp/glance/GlanceFragment.java +++ b/app/src/main/java/ca/xlight/demoapp/glance/GlanceFragment.java @@ -7,6 +7,7 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Color; import android.graphics.Matrix; import android.net.ConnectivityManager; import android.net.NetworkInfo; @@ -15,6 +16,7 @@ import android.os.Message; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; +import android.support.v4.widget.SwipeRefreshLayout; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; @@ -38,6 +40,7 @@ import ca.xlight.demoapp.R; import ca.xlight.demoapp.main.SimpleDividerItemDecoration; import ca.xlight.demoapp.control.DevicesListAdapter; +import ca.xlight.demoapp.swipeitemlayout.SwipeItemLayout; import org.json.JSONException; import org.json.JSONObject; @@ -48,7 +51,9 @@ * Created by Umar Bhutta. */ public class GlanceFragment extends Fragment { + private View root; private com.github.clans.fab.FloatingActionButton fab; + public static GlanceFragment wndHandler; TextView txtLocation, outsideTemp, degreeSymbol, roomTemp, roomHumidity, roomBrightness, outsideHumidity, apparentTemp; ImageView imgWeather; @@ -84,6 +89,7 @@ public void onReceive(Context context, Intent intent) { @Override public void onDestroyView() { + wndHandler = null; devicesRecyclerView.setAdapter(null); MainActivity.m_mainDevice.removeDataEventHandler(m_handlerGlance); if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { @@ -95,21 +101,22 @@ public void onDestroyView() { @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_glance, container, false); - - fab = (com.github.clans.fab.FloatingActionButton) view.findViewById(R.id.fab); - txtLocation = (TextView) view.findViewById(R.id.location); - outsideTemp = (TextView) view.findViewById(R.id.outsideTemp); - degreeSymbol = (TextView) view.findViewById(R.id.degreeSymbol); - outsideHumidity = (TextView) view.findViewById(R.id.valLocalHumidity); - apparentTemp = (TextView) view.findViewById(R.id.valApparentTemp); - roomTemp = (TextView) view.findViewById(R.id.valRoomTemp); + wndHandler = this; + root = inflater.inflate(R.layout.fragment_glance, container, false); + + fab = (com.github.clans.fab.FloatingActionButton) root.findViewById(R.id.fab); + txtLocation = (TextView) root.findViewById(R.id.location); + outsideTemp = (TextView) root.findViewById(R.id.outsideTemp); + degreeSymbol = (TextView) root.findViewById(R.id.degreeSymbol); + outsideHumidity = (TextView) root.findViewById(R.id.valLocalHumidity); + apparentTemp = (TextView) root.findViewById(R.id.valApparentTemp); + roomTemp = (TextView) root.findViewById(R.id.valRoomTemp); roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); - roomHumidity = (TextView) view.findViewById(R.id.valRoomHumidity); + roomHumidity = (TextView) root.findViewById(R.id.valRoomHumidity); roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); - roomBrightness = (TextView) view.findViewById(R.id.valRoomBrightness); + roomBrightness = (TextView) root.findViewById(R.id.valRoomBrightness); roomBrightness.setText(MainActivity.m_mainDevice.m_Data.m_RoomBrightness + "\u0025"); - imgWeather = (ImageView) view.findViewById(R.id.weatherIcon); + imgWeather = (ImageView) root.findViewById(R.id.weatherIcon); Resources res = getResources(); Bitmap weatherIcons = decodeResource(res, R.drawable.weather_icons_1, 420, 600); @@ -159,7 +166,7 @@ public void handleMessage(Message msg) { } //setup recycler view - devicesRecyclerView = (RecyclerView) view.findViewById(R.id.devicesRecyclerView); + devicesRecyclerView = (RecyclerView) root.findViewById(R.id.devicesRecyclerView); //create list adapter devicesListAdapter = new DevicesListAdapter(); //attach adapter to recycler view @@ -171,6 +178,8 @@ public void handleMessage(Message msg) { //divider lines devicesRecyclerView.addItemDecoration(new SimpleDividerItemDecoration(getActivity())); + devicesRecyclerView.addOnItemTouchListener(new SwipeItemLayout.OnSwipeItemTouchListener(getContext())); + // Get ControllerID int controllerId = getContext().getSharedPreferences(MainActivity.keySettings, Activity.MODE_PRIVATE).getInt(MainActivity.keyControllerID, 0); if( controllerId == 2 ) { @@ -245,7 +254,7 @@ public void run() { Toast.makeText(getActivity(), "Please connect to the network before continuing.", Toast.LENGTH_SHORT).show(); } - return view; + return root; } private void updateDisplay() { @@ -352,19 +361,43 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { String incomingID = data.getStringExtra(DEVICE_NODE_ID); String incomingType = data.getStringExtra(DEVICE_NODE_TYPE); - MainActivity.deviceNames.add(incomingName); - MainActivity.deviceNodeIDs.add(incomingID); - MainActivity.deviceNodeTypeIDs.add(incomingType); - MainActivity.m_mainDevice.addNodeToDeviceList(Integer.parseInt(incomingID), Integer.parseInt(incomingType), incomingName); - - devicesListAdapter.notifyDataSetChanged(); - Toast.makeText(getActivity(), "Device has been successfully added", Toast.LENGTH_SHORT).show(); + int pos = searchDeviceID(incomingID); + if( pos >= 0 ) { + // Update + MainActivity.deviceNames.set(pos, incomingName); + MainActivity.deviceNodeTypeIDs.set(pos, incomingType); + MainActivity.m_mainDevice.setDeviceType(Integer.parseInt(incomingID), Integer.parseInt(incomingType)); + MainActivity.m_mainDevice.setDeviceName(Integer.parseInt(incomingID), incomingName); + devicesListAdapter.notifyItemChanged(pos); + } else { + // Add new + MainActivity.deviceNames.add(incomingName); + MainActivity.deviceNodeIDs.add(incomingID); + MainActivity.deviceNodeTypeIDs.add(incomingType); + MainActivity.m_mainDevice.addNodeToDeviceList(Integer.parseInt(incomingID), Integer.parseInt(incomingType), incomingName); + devicesListAdapter.notifyDataSetChanged(); + Toast.makeText(getActivity(), "Device has been successfully added", Toast.LENGTH_SHORT).show(); + } } } } private void onFabPressed(View view) { + showDeivceInfoUpdate("", "", ""); + } + + public int searchDeviceID(String nid) { + for (int pos = 0; pos < MainActivity.deviceNodeIDs.size(); pos++) { + if (MainActivity.deviceNodeIDs.get(pos).equalsIgnoreCase(nid) ) return pos; + } + return -1; + } + + public void showDeivceInfoUpdate(String nid, String name, String type) { Intent intent = new Intent(getContext(), AddNewDevice.class); + intent.putExtra(DEVICE_NODE_ID, nid); + intent.putExtra(DEVICE_NAME, name); + intent.putExtra(DEVICE_NODE_TYPE, type); startActivityForResult(intent, 1); } } diff --git a/app/src/main/java/ca/xlight/demoapp/swipeitemlayout/SwipeItemLayout.java b/app/src/main/java/ca/xlight/demoapp/swipeitemlayout/SwipeItemLayout.java new file mode 100644 index 0000000..0870e0f --- /dev/null +++ b/app/src/main/java/ca/xlight/demoapp/swipeitemlayout/SwipeItemLayout.java @@ -0,0 +1,775 @@ +package ca.xlight.demoapp.swipeitemlayout; + +import android.content.Context; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.animation.Interpolator; +import android.widget.Scroller; + +/** + * Author: liyi + * Date: 2017/2/16. + */ +public class SwipeItemLayout extends ViewGroup { + enum Mode{ + RESET,DRAG,FLING,TAP + } + private Mode mTouchMode; + + private ViewGroup mMainView; + private ViewGroup mSideView; + + private ScrollRunnable mScrollRunnable; + private int mScrollOffset; + private int mMaxScrollOffset; + + private boolean mInLayout; + private boolean mIsLaidOut; + + public SwipeItemLayout(Context context) { + this(context,null); + } + + public SwipeItemLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + mTouchMode = Mode.RESET; + mScrollOffset = 0; + mIsLaidOut = false; + + mScrollRunnable = new ScrollRunnable(context); + } + + public boolean isOpen(){ + return mScrollOffset !=0; + } + + Mode getTouchMode(){ + return mTouchMode; + } + + void setTouchMode(Mode mode){ + switch (mTouchMode){ + case FLING: + mScrollRunnable.abort(); + break; + case RESET: + break; + } + + mTouchMode = mode; + } + + public void open(){ + if(mScrollOffset!=-mMaxScrollOffset){ + //正在open,不需要处理 + if(mTouchMode== Mode.FLING && mScrollRunnable.isScrollToLeft()) + return; + + //当前正在向右滑,abort + if(mTouchMode== Mode.FLING /*&& !mScrollRunnable.mScrollToLeft*/) + mScrollRunnable.abort(); + + mScrollRunnable.startScroll(mScrollOffset,-mMaxScrollOffset); + } + } + + public void close(){ + if(mScrollOffset!=0){ + //正在close,不需要处理 + if(mTouchMode== Mode.FLING && !mScrollRunnable.isScrollToLeft()) + return; + + //当前正向左滑,abort + if(mTouchMode== Mode.FLING /*&& mScrollRunnable.mScrollToLeft*/) + mScrollRunnable.abort(); + + mScrollRunnable.startScroll(mScrollOffset,0); + } + } + + void fling(int xVel){ + mScrollRunnable.startFling(mScrollOffset,xVel); + } + + void revise(){ + if(mScrollOffset<-mMaxScrollOffset/2) + open(); + else + close(); + } + + boolean trackMotionScroll(int deltaX){ + if(deltaX==0) + return false; + + boolean over = false; + int newLeft = mScrollOffset+deltaX; + if((deltaX>0 && newLeft>0) || (deltaX<0 && newLeft<-mMaxScrollOffset)){ + over = true; + newLeft = Math.min(newLeft,0); + newLeft = Math.max(newLeft,-mMaxScrollOffset); + } + + offsetChildrenLeftAndRight(newLeft-mScrollOffset); + mScrollOffset = newLeft; + return over; + } + + private boolean ensureChildren(){ + int childCount = getChildCount(); + + if(childCount!=2) + return false; + + View childView = getChildAt(0); + if(!(childView instanceof ViewGroup)) + return false; + mMainView = (ViewGroup) childView; + + childView = getChildAt(1); + if(!(childView instanceof ViewGroup)) + return false; + mSideView = (ViewGroup) childView; + return true; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if(!ensureChildren()) + throw new RuntimeException("SwipeItemLayout的子视图不符合规定"); + + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + MarginLayoutParams lp = null; + int horizontalMargin,verticalMargin; + int horizontalPadding = getPaddingLeft()+getPaddingRight(); + int verticalPadding = getPaddingTop()+getPaddingBottom(); + + lp = (MarginLayoutParams) mMainView.getLayoutParams(); + horizontalMargin = lp.leftMargin+lp.rightMargin; + verticalMargin = lp.topMargin+lp.bottomMargin; + measureChildWithMargins(mMainView, + widthMeasureSpec,horizontalMargin+horizontalPadding, + heightMeasureSpec,verticalMargin+verticalPadding); + + if(widthMode==MeasureSpec.AT_MOST) + widthSize = Math.min(widthSize,mMainView.getMeasuredWidth()+horizontalMargin+horizontalPadding); + else if(widthMode==MeasureSpec.UNSPECIFIED) + widthSize = mMainView.getMeasuredWidth()+horizontalMargin+horizontalPadding; + + if(heightMode==MeasureSpec.AT_MOST) + heightSize = Math.min(heightSize,mMainView.getMeasuredHeight()+verticalMargin+verticalPadding); + else if(heightMode==MeasureSpec.UNSPECIFIED) + heightSize = mMainView.getMeasuredHeight()+verticalMargin+verticalPadding; + + setMeasuredDimension(widthSize,heightSize); + + //side layout大小为自身实际大小 + lp = (MarginLayoutParams) mSideView.getLayoutParams(); + verticalMargin = lp.topMargin+lp.bottomMargin; + mSideView.measure(MeasureSpec.makeMeasureSpec(0,MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(getMeasuredHeight()-verticalMargin-verticalPadding,MeasureSpec.EXACTLY)); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + if(!ensureChildren()) + throw new RuntimeException("SwipeItemLayout的子视图不符合规定"); + + mInLayout = true; + + int pl = getPaddingLeft(); + int pt = getPaddingTop(); + int pr = getPaddingRight(); + int pb = getPaddingBottom(); + + MarginLayoutParams mainLp = (MarginLayoutParams) mMainView.getLayoutParams(); + MarginLayoutParams sideParams = (MarginLayoutParams) mSideView.getLayoutParams(); + + int childLeft = pl+mainLp.leftMargin; + int childTop = pt+mainLp.topMargin; + int childRight = getWidth()-(pr+mainLp.rightMargin); + int childBottom = getHeight()-(mainLp.bottomMargin+pb); + mMainView.layout(childLeft,childTop,childRight,childBottom); + + childLeft = childRight+sideParams.leftMargin; + childTop = pt+sideParams.topMargin; + childRight = childLeft+sideParams.leftMargin+sideParams.rightMargin+mSideView.getMeasuredWidth(); + childBottom = getHeight()-(sideParams.bottomMargin+pb); + mSideView.layout(childLeft,childTop,childRight,childBottom); + + mMaxScrollOffset = mSideView.getWidth()+sideParams.leftMargin+sideParams.rightMargin; + mScrollOffset = mScrollOffset<-mMaxScrollOffset/2 ? -mMaxScrollOffset:0; + + offsetChildrenLeftAndRight(mScrollOffset); + mInLayout = false; + mIsLaidOut = true; + } + + void offsetChildrenLeftAndRight(int delta){ + ViewCompat.offsetLeftAndRight(mMainView,delta); + ViewCompat.offsetLeftAndRight(mSideView,delta); + } + + @Override + public void requestLayout() { + if (!mInLayout) { + super.requestLayout(); + } + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + } + + @Override + protected LayoutParams generateLayoutParams(LayoutParams p) { + return p instanceof MarginLayoutParams ? p : new MarginLayoutParams(p); + } + + @Override + protected boolean checkLayoutParams(LayoutParams p) { + return p instanceof MarginLayoutParams && super.checkLayoutParams(p); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new MarginLayoutParams(getContext(), attrs); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if(mScrollOffset!=0 && mIsLaidOut){ + offsetChildrenLeftAndRight(-mScrollOffset); + mScrollOffset = 0; + }else + mScrollOffset = 0; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if(mScrollOffset!=0 && mIsLaidOut){ + offsetChildrenLeftAndRight(-mScrollOffset); + mScrollOffset = 0; + }else + mScrollOffset = 0; + removeCallbacks(mScrollRunnable); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + //click main view,但是它处于open状态,所以,不需要点击效果,直接拦截不调用click listener + switch (action) { + case MotionEvent.ACTION_DOWN: { + final int x = (int) ev.getX(); + final int y = (int) ev.getY(); + View pointView = findTopChildUnder(this,x,y); + if(pointView!=null && pointView==mMainView && mScrollOffset !=0) + return true; + break; + } + + case MotionEvent.ACTION_MOVE: + case MotionEvent.ACTION_CANCEL: + break; + + case MotionEvent.ACTION_UP:{ + final int x = (int) ev.getX(); + final int y = (int) ev.getY(); + View pointView = findTopChildUnder(this,x,y); + if(pointView!=null && pointView==mMainView && mTouchMode== Mode.TAP && mScrollOffset !=0) + return true; + } + } + + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = ev.getActionMasked(); + //click main view,但是它处于open状态,所以,不需要点击效果,直接拦截不调用click listener + switch (action) { + case MotionEvent.ACTION_DOWN: { + final int x = (int) ev.getX(); + final int y = (int) ev.getY(); + View pointView = findTopChildUnder(this,x,y); + if(pointView!=null && pointView==mMainView && mScrollOffset !=0) + return true; + break; + } + + case MotionEvent.ACTION_MOVE: + case MotionEvent.ACTION_CANCEL: + break; + + case MotionEvent.ACTION_UP:{ + final int x = (int) ev.getX(); + final int y = (int) ev.getY(); + View pointView = findTopChildUnder(this,x,y); + if(pointView!=null && pointView==mMainView && mTouchMode== Mode.TAP && mScrollOffset !=0) { + close(); + return true; + } + } + } + + return false; + } + + @Override + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + if(getVisibility()!=View.VISIBLE){ + mScrollOffset = 0; + invalidate(); + } + } + + private static final Interpolator sInterpolator = new Interpolator() { + @Override + public float getInterpolation(float t) { + t -= 1.0f; + return t * t * t * t * t + 1.0f; + } + }; + + class ScrollRunnable implements Runnable{ + private static final int FLING_DURATION = 200; + private Scroller mScroller; + private boolean mAbort; + private int mMinVelocity; + private boolean mScrollToLeft; + + ScrollRunnable(Context context){ + mScroller = new Scroller(context,sInterpolator); + mAbort = false; + mScrollToLeft = false; + + ViewConfiguration configuration = ViewConfiguration.get(context); + mMinVelocity = configuration.getScaledMinimumFlingVelocity(); + } + + void startScroll(int startX,int endX){ + if(startX!=endX){ + Log.e("scroll - startX - endX",""+startX+" "+endX); + setTouchMode(Mode.FLING); + mAbort = false; + mScrollToLeft = endXmMinVelocity && startX!=0) { + startScroll(startX, 0); + return; + } + + if(xVel<-mMinVelocity && startX!=-mMaxScrollOffset) { + startScroll(startX, -mMaxScrollOffset); + return; + } + + startScroll(startX,startX>-mMaxScrollOffset/2 ? 0:-mMaxScrollOffset); + } + + void abort(){ + if(!mAbort){ + mAbort = true; + if(!mScroller.isFinished()){ + mScroller.abortAnimation(); + removeCallbacks(this); + } + } + } + + //是否正在滑动需要另外判断 + boolean isScrollToLeft(){ + return mScrollToLeft; + } + + @Override + public void run() { + Log.e("abort",Boolean.toString(mAbort)); + if(!mAbort){ + boolean more = mScroller.computeScrollOffset(); + int curX = mScroller.getCurrX(); + Log.e("curX",""+curX); + + boolean atEdge = trackMotionScroll(curX-mScrollOffset); + if(more && !atEdge) { + ViewCompat.postOnAnimation(SwipeItemLayout.this, this); + return; + } + + if(atEdge){ + removeCallbacks(this); + if(!mScroller.isFinished()) + mScroller.abortAnimation(); + setTouchMode(Mode.RESET); + } + + if(!more){ + setTouchMode(Mode.RESET); + //绝对不会出现这种意外的!!!可以注释掉 + if(mScrollOffset!=0){ + if(Math.abs(mScrollOffset)>mMaxScrollOffset/2) + mScrollOffset = -mMaxScrollOffset; + else + mScrollOffset = 0; + ViewCompat.postOnAnimation(SwipeItemLayout.this,this); + } + } + } + } + } + + public static class OnSwipeItemTouchListener implements RecyclerView.OnItemTouchListener { + private SwipeItemLayout mCaptureItem; + private float mLastMotionX; + private float mLastMotionY; + private VelocityTracker mVelocityTracker; + + private int mActivePointerId; + + private int mTouchSlop; + private int mMaximumVelocity; + + private boolean mDealByParent; + private boolean mIsProbeParent; + + public OnSwipeItemTouchListener(Context context){ + ViewConfiguration configuration = ViewConfiguration.get(context); + mTouchSlop = configuration.getScaledTouchSlop(); + mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); + mActivePointerId = -1; + mDealByParent = false; + mIsProbeParent = false; + } + + @Override + public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent ev) { + if(mIsProbeParent) + return false; + + boolean intercept = false; + final int action = ev.getActionMasked(); + + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + mVelocityTracker.addMovement(ev); + + switch (action){ + case MotionEvent.ACTION_DOWN:{ + mActivePointerId = ev.getPointerId(0); + final float x = ev.getX(); + final float y = ev.getY(); + mLastMotionX = x; + mLastMotionY = y; + + boolean pointOther = false; + SwipeItemLayout pointItem = null; + //首先知道ev针对的是哪个item + View pointView = findTopChildUnder(rv,(int)x,(int)y); + if(pointView==null || !(pointView instanceof SwipeItemLayout)){ + //可能是head view或bottom view + pointOther = true; + }else + pointItem = (SwipeItemLayout) pointView; + + //此时的pointOther=true,意味着点击的view为空或者点击的不是item + //还没有把点击的是item但是不是capture item给过滤出来 + if(!pointOther && (mCaptureItem==null || mCaptureItem!=pointItem)) + pointOther = true; + + //点击的是capture item + if(!pointOther){ + Mode touchMode = mCaptureItem.getTouchMode(); + + //如果它在fling,就转为drag + //需要拦截,并且requestDisallowInterceptTouchEvent + boolean disallowIntercept = false; + if(touchMode== Mode.FLING){ + mCaptureItem.setTouchMode(Mode.DRAG); + disallowIntercept = true; + intercept = true; + }else {//如果是expand的,就不允许parent拦截 + mCaptureItem.setTouchMode(Mode.TAP); + if(mCaptureItem.isOpen()) + disallowIntercept = true; + } + + if(disallowIntercept){ + final ViewParent parent = rv.getParent(); + if (parent!= null) + parent.requestDisallowInterceptTouchEvent(true); + } + }else{//capture item为null或者与point item不一样 + //直接将其close掉 + if(mCaptureItem!=null && mCaptureItem.isOpen()) { + mCaptureItem.close(); + mCaptureItem = null; + intercept = true; + } + + if(pointItem!=null) { + mCaptureItem = pointItem; + mCaptureItem.setTouchMode(Mode.TAP); + }else + mCaptureItem = null; + } + + //如果parent处于fling状态,此时,parent就会转为drag。此时,应该将后续move都交给parent处理 + mIsProbeParent = true; + mDealByParent = rv.onInterceptTouchEvent(ev); + mIsProbeParent = false; + if(mDealByParent) + intercept = false; + break; + } + + case MotionEvent.ACTION_POINTER_DOWN: { + final int actionIndex = ev.getActionIndex(); + mActivePointerId = ev.getPointerId(actionIndex); + + mLastMotionX = ev.getX(actionIndex); + mLastMotionY = ev.getY(actionIndex); + break; + } + + case MotionEvent.ACTION_POINTER_UP: { + final int actionIndex = ev.getActionIndex(); + final int pointerId = ev.getPointerId(actionIndex); + if (pointerId == mActivePointerId) { + final int newIndex = actionIndex == 0 ? 1 : 0; + mActivePointerId = ev.getPointerId(newIndex); + + mLastMotionX = ev.getX(newIndex); + mLastMotionY = ev.getY(newIndex); + } + break; + } + + //down时,已经将capture item定下来了。所以,后面可以安心考虑event处理 + case MotionEvent.ACTION_MOVE: { + final int activePointerIndex = ev.findPointerIndex(mActivePointerId); + if (activePointerIndex == -1) + break; + + //在down时,就被认定为parent的drag,所以,直接交给parent处理即可 + if(mDealByParent) { + if(mCaptureItem!=null && mCaptureItem.isOpen()) + mCaptureItem.close(); + return false; + } + + final int x = (int) (ev.getX(activePointerIndex)+.5f); + final int y = (int) ((int) ev.getY(activePointerIndex)+.5f); + + int deltaX = (int) (x - mLastMotionX); + int deltaY = (int)(y-mLastMotionY); + final int xDiff = Math.abs(deltaX); + final int yDiff = Math.abs(deltaY); + + if(mCaptureItem!=null && !mDealByParent){ + Mode touchMode = mCaptureItem.getTouchMode(); + + if(touchMode== Mode.TAP ){ + //如果capture item是open的,下拉有两种处理方式: + // 1、下拉后,直接close item + // 2、只要是open的,就拦截所有它的消息,这样如果点击open的,就只能滑动该capture item + //网易邮箱,在open的情况下,下拉直接close + //QQ,在open的情况下,下拉也是close。但是,做的不够好,没有达到该效果。 + if(xDiff>mTouchSlop && xDiff>yDiff){ + mCaptureItem.setTouchMode(Mode.DRAG); + final ViewParent parent = rv.getParent(); + parent.requestDisallowInterceptTouchEvent(true); + + deltaX = deltaX>0 ? deltaX-mTouchSlop:deltaX+mTouchSlop; + }else{// if(yDiff>mTouchSlop){ + mIsProbeParent = true; + boolean isParentConsume = rv.onInterceptTouchEvent(ev); + mIsProbeParent = false; + if(isParentConsume){ + //表明不是水平滑动,即不判定为SwipeItemLayout的滑动 + //但是,可能是下拉刷新SwipeRefreshLayout或者RecyclerView的滑动 + //一般的下拉判定,都是yDiff>mTouchSlop,所以,此处这么写不会出问题 + //这里这么做以后,如果判定为下拉,就直接close + mDealByParent = true; + mCaptureItem.close(); + } + } + } + + touchMode = mCaptureItem.getTouchMode(); + if(touchMode== Mode.DRAG){ + intercept = true; + mLastMotionX = x; + mLastMotionY = y; + + //对capture item进行拖拽 + mCaptureItem.trackMotionScroll(deltaX); + } + } + break; + } + + case MotionEvent.ACTION_UP: + if(mCaptureItem!=null){ + Mode touchMode = mCaptureItem.getTouchMode(); + if(touchMode== Mode.DRAG){ + final VelocityTracker velocityTracker = mVelocityTracker; + velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + int xVel = (int) velocityTracker.getXVelocity(mActivePointerId); + mCaptureItem.fling(xVel); + + intercept = true; + } + } + cancel(); + break; + + case MotionEvent.ACTION_CANCEL: + if(mCaptureItem!=null) + mCaptureItem.revise(); + cancel(); + break; + } + + return intercept; + } + + @Override + public void onTouchEvent(RecyclerView rv, MotionEvent ev) { + final int action = ev.getActionMasked(); + final int actionIndex = ev.getActionIndex(); + + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + mVelocityTracker.addMovement(ev); + + switch (action){ + case MotionEvent.ACTION_POINTER_DOWN: + mActivePointerId = ev.getPointerId(actionIndex); + + mLastMotionX = ev.getX(actionIndex); + mLastMotionY = ev.getY(actionIndex); + break; + + case MotionEvent.ACTION_POINTER_UP: + final int pointerId = ev.getPointerId(actionIndex); + if(pointerId==mActivePointerId){ + final int newIndex = actionIndex == 0 ? 1 : 0; + mActivePointerId = ev.getPointerId(newIndex); + + mLastMotionX = ev.getX(newIndex); + mLastMotionY = ev.getY(newIndex); + } + break; + + //down时,已经将capture item定下来了。所以,后面可以安心考虑event处理 + case MotionEvent.ACTION_MOVE: { + final int activePointerIndex = ev.findPointerIndex(mActivePointerId); + if (activePointerIndex == -1) + break; + + final float x = ev.getX(activePointerIndex); + final float y = (int) ev.getY(activePointerIndex); + + int deltaX = (int) (x - mLastMotionX); + + if(mCaptureItem!=null && mCaptureItem.getTouchMode()== Mode.DRAG){ + mLastMotionX = x; + mLastMotionY = y; + + //对capture item进行拖拽 + mCaptureItem.trackMotionScroll(deltaX); + } + break; + } + + case MotionEvent.ACTION_UP: + if(mCaptureItem!=null){ + Mode touchMode = mCaptureItem.getTouchMode(); + if(touchMode== Mode.DRAG){ + final VelocityTracker velocityTracker = mVelocityTracker; + velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + int xVel = (int) velocityTracker.getXVelocity(mActivePointerId); + mCaptureItem.fling(xVel); + } + } + cancel(); + break; + + case MotionEvent.ACTION_CANCEL: + if(mCaptureItem!=null) + mCaptureItem.revise(); + + cancel(); + break; + + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} + + void cancel(){ + mDealByParent = false; + mActivePointerId = -1; + if(mVelocityTracker!=null){ + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + } + + } + + static View findTopChildUnder(ViewGroup parent,int x, int y) { + final int childCount = parent.getChildCount(); + for (int i = childCount - 1; i >= 0; i--) { + final View child = parent.getChildAt(i); + if (x >= child.getLeft() && x < child.getRight() + && y >= child.getTop() && y < child.getBottom()) { + return child; + } + } + return null; + } + + public static void closeAllItems(RecyclerView recyclerView){ + for(int i=0;iuz>R6S{Pe*M*Cw{7cQhwrZau~yD_S6Ox5zpwXpO;cHZ`Q^^46DQ6+ z|NONlV%pQSRcB7{wI8nbo>Vz+{;K0W@9NwtBE&y$XuA>4e_^Vk0uN&%2ZytR1k)h_ zfwl$%mKG`r{r(}H-BMe$x8Hf|pR6Y8nTy4Z-|H*PJq(>4wx~p2|GK>D#EYm&GX2NT zzfx@ey(7NkQ;?PO{ds}{x}M82ja(L + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxhdpi/btn_mark.xml b/app/src/main/res/drawable-xxhdpi/btn_mark.xml new file mode 100644 index 0000000..aa8ee09 --- /dev/null +++ b/app/src/main/res/drawable-xxhdpi/btn_mark.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete_red_24dp.xml b/app/src/main/res/drawable/ic_delete_red_24dp.xml new file mode 100644 index 0000000..a3f9e8a --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_red_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_red_48dp.xml b/app/src/main/res/drawable/ic_delete_red_48dp.xml new file mode 100644 index 0000000..e7a3836 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_red_48dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_highlight_off_24dp.xml b/app/src/main/res/drawable/ic_highlight_off_24dp.xml new file mode 100644 index 0000000..949c29f --- /dev/null +++ b/app/src/main/res/drawable/ic_highlight_off_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_24dp.xml b/app/src/main/res/drawable/ic_info_outline_24dp.xml new file mode 100644 index 0000000..2cfec65 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_blue_48dp.xml b/app/src/main/res/drawable/ic_settings_blue_48dp.xml new file mode 100644 index 0000000..0ba980d --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_blue_48dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/layout/activity_add_new_device.xml b/app/src/main/res/layout/activity_add_new_device.xml index 1878c43..f12f050 100644 --- a/app/src/main/res/layout/activity_add_new_device.xml +++ b/app/src/main/res/layout/activity_add_new_device.xml @@ -17,7 +17,6 @@ android:layout_height="33dp" android:layout_below="@+id/mainLightsLL" android:background="@color/textDarkGrey" - android:minHeight="?attr/actionBarSize" android:paddingLeft="20dp" android:paddingTop="10dp"> @@ -28,6 +27,7 @@ android:orientation="vertical"> - - + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_glance.xml b/app/src/main/res/layout/fragment_glance.xml index 282a432..7ba2975 100644 --- a/app/src/main/res/layout/fragment_glance.xml +++ b/app/src/main/res/layout/fragment_glance.xml @@ -229,7 +229,6 @@ - - diff --git a/app/src/main/res/layout/scenario_list_item.xml b/app/src/main/res/layout/scenario_list_item.xml index bb0e357..7392cc1 100644 --- a/app/src/main/res/layout/scenario_list_item.xml +++ b/app/src/main/res/layout/scenario_list_item.xml @@ -51,7 +51,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:id="@+id/scenarioDelete" - android:src="@drawable/ic_delete_black_24dp" + android:src="@drawable/ic_delete_red_24dp" android:layout_weight="0.5" android:layout_gravity="center_vertical" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 984bcf1..926fc79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,7 +85,5 @@ 129 - - Hello blank fragment AddNewDevice

    r|I&ia&7>Hp71*zl7}BT=X3w4A#) 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 literal 0 HcmV?d00001 From 6b4e793a4895661c23ae5103e6da447d95643ef8 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Thu, 1 Dec 2016 12:30:13 -0500 Subject: [PATCH 04/25] add STATE_TOGGLE value --- .idea/misc.xml | 2 +- .../main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index fbb6828..5d19981 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -37,7 +37,7 @@ - + diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index 9e20c15..2763863 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -26,6 +26,7 @@ public class xltDevice { // on/off values public static final int STATE_OFF = 0; public static final int STATE_ON = 1; + public static final int STATE_TOGGLE = 2; // default alarm/filter id public static final int DEFAULT_ALARM_ID = 255; From f93841271a385b592cc83e51bfaa4f007f199ab9 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Fri, 2 Dec 2016 01:56:00 -0500 Subject: [PATCH 05/25] Implement event handler lists in xltDevice class --- .../xlightcompanion/SDK/BaseBridge.java | 5 + .../xlightcompanion/SDK/CloudBridge.java | 225 ++++++------------ .../xlightcompanion/SDK/xltDevice.java | 110 ++++++++- .../control/ControlFragment.java | 11 +- .../control/DevicesListAdapter.java | 18 +- .../glance/GlanceFragment.java | 15 +- .../xlightcompanion/main/MainActivity.java | 5 - .../scenario/AddScenarioActivity.java | 4 +- .../scenario/ScenarioFragment.java | 3 +- .../schedule/AddScheduleActivity.java | 15 +- 10 files changed, 246 insertions(+), 165 deletions(-) diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java index 014afa0..38ae8b3 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java @@ -14,6 +14,7 @@ public class BaseBridge { private String m_Name = "Unknown bridge"; private int m_priority = 5; protected Context m_parentContext = null; + protected xltDevice m_parentDevice = null; public boolean isConnected() { return m_bConnected; @@ -50,4 +51,8 @@ public void setPriority(final int priority) { public void setParentContext(Context context) { m_parentContext = context; } + + public void setParentDevice(xltDevice device) { + m_parentDevice = device; + } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java index ea00517..4298dcc 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java @@ -7,10 +7,6 @@ import android.os.Message; import android.util.Log; -import com.umarbhutta.xlightcompanion.main.MainActivity; -import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; -import com.umarbhutta.xlightcompanion.schedule.ScheduleFragment; - import java.io.IOException; import java.util.ArrayList; @@ -197,11 +193,10 @@ public void run() { return resultCode; } - public int JSONConfigScenario(final int brightness, final int cw, final int ww, final int r, final int g, final int b, final String filter) { + public int JSONConfigScenario(final int scenarioId, final int brightness, final int cw, final int ww, final int r, final int g, final int b, final int filter) { new Thread() { @Override public void run() { - int scenarioId = ScenarioFragment.name.size(); boolean x[] = {false, false, false}; //construct first part of string input, and store it in arraylist (of size 1) @@ -236,7 +231,7 @@ public void run() { if (x[1]) { //construct last part of string input, store in arraylist //json = "\"ring3\":[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + DEFAULT_FILTER_ID + "}"; - json = "\"ring3\":[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + xltDevice.DEFAULT_FILTER_ID + "}"; + json = "\"ring3\":[" + xltDevice.STATE_ON + "," + cw + "," + ww + "," + r + "," + g + "," + b + "],\"brightness\":" + brightness + ",\"filter\":" + filter + "}"; message.add(json); //send in last part of string try { @@ -252,15 +247,14 @@ public void run() { return resultCode; } - public int JSONConfigAlarm(final boolean isRepeat, final String weekdays, final int hour, final int minute, final String scenarioName) { + public int JSONConfigSchudle(final int scheduleId, final boolean isRepeat, final String weekdays, final int hour, final int minute, final int alarmId) { final int[] doneSending = {0}; new Thread() { @Override public void run() { - boolean x[] = {false, false, false, false}; + boolean x[] = {false, false}; //SCHEDULE - int scheduleId = ScheduleFragment.name.size(); int repeat = isRepeat ? 1 : 0; //construct first part of string input, and store it in arraylist (of size 1) @@ -279,7 +273,7 @@ public void run() { if (x[0]) { //construct second part of string input, store in arraylist - json = "\"weekdays\":" + "0" + ",\"hour\":" + hour + ",\"min\":" + minute + ",\"alarm_id\":" + xltDevice.DEFAULT_ALARM_ID + "}"; + json = "\"weekdays\":" + "0" + ",\"hour\":" + hour + ",\"min\":" + minute + ",\"alarm_id\":" + alarmId + "}"; message.add(json); //send in second part of string try { @@ -291,96 +285,51 @@ public void run() { e.printStackTrace(); } message.clear(); + } + } + }.start(); + return resultCode; + } - //RULE - int rule_schedule_notif_Id = ScheduleFragment.name.size() - 1; - int scenarioId = 1; - for (int i = 0; i < ScenarioFragment.name.size(); i++) { - if (scenarioName == ScenarioFragment.name.get(i)) { - scenarioId = i; - } - } + public int JSONConfigRule(final int ruleId, final int scheduleId, final int scenarioId) { + new Thread() { + @Override + public void run() { + boolean x[] = {false, false}; - //construct first part of string input, and store it in arraylist (of size 1) - String json2 = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + rule_schedule_notif_Id + "\",\"node_id\":" + getNodeID() + ", '}"; - ArrayList message2 = new ArrayList<>(); - message2.add(json2); - //send in first part of string + //construct first part of string input, and store it in arraylist (of size 1) + String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + ruleId + "\",\"node_id\":" + getNodeID() + ", '}"; + ArrayList message = new ArrayList<>(); + message.add(json); + //send in first part of string + try { + Log.e(TAG, "JSONConfigRule" + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + x[0] = true; + } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { + e.printStackTrace(); + } + message.clear(); + + if (x[0]) { + //construct second part of string input, store in arraylist + json = "\"SCT_uid\":\"a" + scheduleId + "\",\"SNT_uid\":\"s" + scenarioId + "\",\"notif_uid\":\"n" + ruleId + "\"}"; + message.add(json); + //send in second part of string try { - Log.e(TAG, "JSONConfigRule " + message2.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message2); - x[2] = true; + Log.i(TAG, "JSONConfigRule" + message.get(0)); + resultCode = currDevice.callFunction("JSONConfig", message); + x[1] = true; } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { e.printStackTrace(); } - message2.clear(); - - if (x[2]) { - //construct second part of string input, store in arraylist - json2 = "\"SCT_uid\":" + rule_schedule_notif_Id + ",\"SNT_uid\":" + scenarioId + ",\"notif_uid\":" + rule_schedule_notif_Id + "}"; - message2.add(json2); - //send in second part of string - try { - Log.i(TAG, "JSONConfigRule " + message2.get(0)); - resultCode = currDevice.callFunction("JSONConfig", message2); - x[3] = true; - } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { - e.printStackTrace(); - } - message2.clear(); - } + message.clear(); } } }.start(); return resultCode; } -// public int JSONConfigRule(final String scenarioName) { -// new Thread() { -// @Override -// public void run() { -// int rule_schedule_notif_Id = ScheduleFragment.name.size() + 1; -// int scenarioId = 1; -// for (int i = 0; i < ScenarioFragment.name.size(); i++) { -// if (scenarioName == ScenarioFragment.name.get(i)) { -// scenarioId = i + 1; -// } -// } -// boolean x[] = {false, false}; -// -// //construct first part of string input, and store it in arraylist (of size 1) -// String json = "{'x0': '{\"op\":1,\"fl\":0,\"run\":0,\"uid\":\"r" + rule_schedule_notif_Id + "\",\"node_id\":" + nodeId + ", '}"; -// ArrayList message = new ArrayList<>(); -// message.add(json); -// //send in first part of string -// try { -// Log.e(TAG, "JSONConfigRule" + message.get(0)); -// resultCode = currDevice.callFunction("JSONConfig", message); -// x[0] = true; -// } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { -// e.printStackTrace(); -// } -// message.clear(); -// -// if (x[0]) { -// //construct second part of string input, store in arraylist -// json = "\"SCT_uid\":\"a" + rule_schedule_notif_Id + "\",\"SNT_uid\":\"s" + scenarioId + "\",\"notif_uid\":\"n" + rule_schedule_notif_Id + "\"}"; -// message.add(json); -// //send in second part of string -// try { -// Log.i(TAG, "JSONConfigRule" + message.get(0)); -// resultCode = currDevice.callFunction("JSONConfig", message); -// x[1] = true; -// } catch (ParticleCloudException | ParticleDevice.FunctionDoesNotExistException | IOException e) { -// e.printStackTrace(); -// } -// message.clear(); -// } -// } -// }.start(); -// return resultCode; -// } - public int JSONGetDeviceStatus() { new Thread() { @Override @@ -430,6 +379,14 @@ public void run() { subscriptionId = currDevice.subscribeToEvents(null, new ParticleEventHandler() { public void onEvent(String eventName, ParticleEvent event) { Log.i(TAG, "Received event: " + eventName + " with payload: " + event.dataPayload); + // Notes: due to bug of SDK 0.3.4, the eventName is not correct + /// We work around by specifying eventName + if( event.dataPayload.contains("DHTt") ) { + eventName = xltDevice.eventSensorData; + } else { + eventName = xltDevice.eventDeviceStatus; + } + // Demo option: use handler & sendMessage to inform activities InformActivities(eventName, event.dataPayload); @@ -466,67 +423,41 @@ public void run() { // Use handler & sendMessage to inform activities private void InformActivities(final String eventName, final String dataPayload) { + if (m_parentDevice == null) return; try { JSONObject jObject = new JSONObject(dataPayload); - //if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { - if (jObject.has("nd")) { - int nodeId = jObject.getInt("nd"); - if (nodeId == MainActivity.m_mainDevice.getDeviceID()) { - - Message msgControlObj = null; - Bundle bdlControl = null; - if( MainActivity.handlerControl != null ) { - msgControlObj = MainActivity.handlerControl.obtainMessage(); - bdlControl = new Bundle(); - } - - if (jObject.has("State")) { - MainActivity.m_mainDevice.setState(jObject.getInt("State")); - if( MainActivity.handlerDeviceList != null ) { - Message msgObj = MainActivity.handlerDeviceList.obtainMessage(); - Bundle b = new Bundle(); - b.putInt("State", MainActivity.m_mainDevice.getState()); - msgObj.setData(b); - MainActivity.handlerDeviceList.sendMessage(msgObj); + if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { + if (jObject.has("nd")) { + int nodeId = jObject.getInt("nd"); + if (nodeId == m_parentDevice.getDeviceID()) { + Bundle bdlControl = new Bundle(); + if (jObject.has("State")) { + m_parentDevice.setState(jObject.getInt("State")); + bdlControl.putInt("State", m_parentDevice.getState()); } - if( MainActivity.handlerControl != null ) { - bdlControl.putInt("State", MainActivity.m_mainDevice.getState()); + if (jObject.has("BR")) { + m_parentDevice.setBrightness(jObject.getInt("BR")); + bdlControl.putInt("BR", m_parentDevice.getBrightness()); } - } - if (jObject.has("BR")) { - MainActivity.m_mainDevice.setBrightness(jObject.getInt("BR")); - if( MainActivity.handlerControl != null ) { - bdlControl.putInt("BR", MainActivity.m_mainDevice.getBrightness()); + if (jObject.has("CCT")) { + m_parentDevice.setCCT(jObject.getInt("CCT")); + bdlControl.putInt("CCT", m_parentDevice.getCCT()); } - } - if (jObject.has("CCT")) { - MainActivity.m_mainDevice.setCCT(jObject.getInt("CCT")); - if( MainActivity.handlerControl != null ) { - bdlControl.putInt("CCT", MainActivity.m_mainDevice.getCCT()); - } - } - - if( MainActivity.handlerControl != null && msgControlObj != null ) { - msgControlObj.setData(bdlControl); - MainActivity.handlerControl.sendMessage(msgControlObj); + m_parentDevice.sendDeviceStatusMessage(bdlControl); } } - } - //} else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { - if (jObject.has("DHTt")) { - MainActivity.m_mainDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); - if( MainActivity.handlerGlance != null ) { - Message msgObj = MainActivity.handlerGlance.obtainMessage(); - Bundle b = new Bundle(); - b.putInt("DHTt", (int)MainActivity.m_mainDevice.m_Data.m_RoomTemp); - msgObj.setData(b); - MainActivity.handlerGlance.sendMessage(msgObj); + } else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { + Bundle bdlData = new Bundle(); + if (jObject.has("DHTt")) { + m_parentDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); + bdlData.putInt("DHTt", (int)m_parentDevice.m_Data.m_RoomTemp); } + if (jObject.has("DHTh")) { + m_parentDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); + bdlData.putInt("DHTh", m_parentDevice.m_Data.m_RoomHumidity); + } + m_parentDevice.sendSensorDataMessage(bdlData); } - if (jObject.has("DHTh")) { - MainActivity.m_mainDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); - } - //} } catch (final JSONException e) { Log.e(TAG, "Json parsing error: " + e.getMessage()); } @@ -539,23 +470,23 @@ private void BroadcastEvent(final String eventName, String dataPayload) { if (jObject.has("nd")) { int nodeId = jObject.getInt("nd"); // ToDO: search device - if (nodeId == MainActivity.m_mainDevice.getDeviceID()) { + if (nodeId == m_parentDevice.getDeviceID()) { if (jObject.has("State")) { - MainActivity.m_mainDevice.setState(jObject.getInt("State")); + m_parentDevice.setState(jObject.getInt("State")); } if (jObject.has("BR")) { - MainActivity.m_mainDevice.setBrightness(jObject.getInt("BR")); + m_parentDevice.setBrightness(jObject.getInt("BR")); } if (jObject.has("CCT")) { - MainActivity.m_mainDevice.setCCT(jObject.getInt("CCT")); + m_parentDevice.setCCT(jObject.getInt("CCT")); } } } if (jObject.has("DHTt")) { - MainActivity.m_mainDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); + m_parentDevice.m_Data.m_RoomTemp = jObject.getInt("DHTt"); } if (jObject.has("DHTh")) { - MainActivity.m_mainDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); + m_parentDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); } //} } catch (final JSONException e) { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index 2763863..df06606 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -1,8 +1,13 @@ package com.umarbhutta.xlightcompanion.SDK; import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; import android.os.SystemClock; +import java.util.ArrayList; + /** * Created by sunboss on 2016-11-15. * @@ -152,6 +157,10 @@ public class SensorData { // Sensor Data public SensorData m_Data; + // Event Handler List + private ArrayList m_lstEH_DevST = new ArrayList<>(); + private ArrayList m_lstEH_SenDT = new ArrayList<>(); + //------------------------------------------------------------------------- // Regular Interfaces //------------------------------------------------------------------------- @@ -171,6 +180,10 @@ public xltDevice() { // Initialize objects public void Init(Context context) { + // Clear event handler lists + clearDeviceEventHandlerList(); + clearDataEventHandlerList(); + // Ensure we do it only once if( !m_bInitialized ) { ParticleBridge.init(context); @@ -179,6 +192,12 @@ public void Init(Context context) { ParticleBridge.authenticate(); m_bInitialized = true; } + + // Update parent context + setParentContext(context); + + // Set me as the parent device + setParentDevice(); } // Connect to message bridges @@ -271,6 +290,12 @@ private void setParentContext(Context context) { lanBridge.setParentContext(context); } + private void setParentDevice() { + cldBridge.setParentDevice(this); + bleBridge.setParentDevice(this); + lanBridge.setParentDevice(this); + } + public int getDeviceType() { return m_DevType; } @@ -689,15 +714,96 @@ public int ChangeScenario(final int scenario) { return rc; } + //------------------------------------------------------------------------- + // Event Handler Interfaces + //------------------------------------------------------------------------- + public int addDeviceEventHandler(final Handler handler) { + m_lstEH_DevST.add(handler); + return m_lstEH_DevST.size(); + } + + public int addDataEventHandler(final Handler handler) { + m_lstEH_SenDT.add(handler); + return m_lstEH_SenDT.size(); + } + + public boolean removeDeviceEventHandler(final Handler handler) { + return m_lstEH_DevST.remove(handler); + } + + public boolean removeDataEventHandler(final Handler handler) { + return m_lstEH_SenDT.remove(handler); + } + + public void clearDeviceEventHandlerList() { + m_lstEH_DevST.clear(); + } + + public void clearDataEventHandlerList() { + m_lstEH_SenDT.clear(); + } + + // Send device status message to each handler + public void sendDeviceStatusMessage(final Bundle data) { + Handler handler; + Message msg; + for (int i = 0; i < m_lstEH_DevST.size(); i++) { + handler = m_lstEH_DevST.get(i); + if( handler != null ) { + msg = handler.obtainMessage(); + if( msg != null ) { + msg.setData(data); + handler.sendMessage(msg); + } + } + } + } + + // Send sensor data message to each handler + public void sendSensorDataMessage(final Bundle data) { + Handler handler; + Message msg; + for (int i = 0; i < m_lstEH_SenDT.size(); i++) { + handler = m_lstEH_SenDT.get(i); + if( handler != null ) { + msg = handler.obtainMessage(); + if( msg != null ) { + msg.setData(data); + handler.sendMessage(msg); + } + } + } + } + //------------------------------------------------------------------------- // Device Manipulate Interfaces (DMI) //------------------------------------------------------------------------- - public int sceAddScenario(final int br, final int cw, final int ww, final int r, final int g, final int b, final String filter) { + public int sceAddScenario(final int scenarioId, final int br, final int cw, final int ww, final int r, final int g, final int b, final int filter) { + int rc = -1; + + // Can only use Cloud Bridge + if( isCloudOK() ) { + rc = cldBridge.JSONConfigScenario(scenarioId, br, cw, ww, r, g, b, filter); + } + return rc; + } + + public int sceAddSchedule(final int scheduleId, final boolean isRepeat, final String weekdays, final int hour, final int minute, final int alarmId) { + int rc = -1; + + // Can only use Cloud Bridge + if( isCloudOK() ) { + rc = cldBridge.JSONConfigSchudle(scheduleId, isRepeat, weekdays, hour, minute, alarmId); + } + return rc; + } + + public int sceAddRule(final int ruleId, final int scheduleId, final int scenarioId) { int rc = -1; // Can only use Cloud Bridge if( isCloudOK() ) { - rc = cldBridge.JSONConfigScenario(br, cw, ww, r, g, b, filter); + rc = cldBridge.JSONConfigRule(ruleId, scheduleId, scenarioId); } return rc; } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index 736e9cc..8b9787b 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -61,6 +61,14 @@ public class ControlFragment extends Fragment { private boolean state = false; boolean ring1 = false, ring2 = false, ring3 = false; + private Handler m_handlerControl; + + @Override + public void onDestroyView() { + MainActivity.m_mainDevice.removeDeviceEventHandler(m_handlerControl); + super.onDestroyView(); + } + @Nullable @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -101,7 +109,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa brightnessSeekBar.setProgress(MainActivity.m_mainDevice.getBrightness()); cctSeekBar.setProgress(MainActivity.m_mainDevice.getCCT() - 2700); - MainActivity.handlerControl = new Handler() { + m_handlerControl = new Handler() { public void handleMessage(Message msg) { int intValue = msg.getData().getInt("State", -255); if( intValue != -255 ) { @@ -119,6 +127,7 @@ public void handleMessage(Message msg) { } } }; + MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerControl); powerSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java index d1a9850..290f914 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java @@ -19,12 +19,27 @@ */ public class DevicesListAdapter extends RecyclerView.Adapter { + private Handler m_handlerDeviceList; + @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.devices_list_item, parent, false); return new DevicesListViewHolder(view); } + @Override + public void onDetachedFromRecyclerView(RecyclerView recyclerView) { + if( m_handlerDeviceList != null ) { + MainActivity.m_mainDevice.removeDeviceEventHandler(m_handlerDeviceList); + } + super.onDetachedFromRecyclerView(recyclerView); + } + + @Override + public void onViewDetachedFromWindow(RecyclerView.ViewHolder holder) { + super.onViewDetachedFromWindow(holder); + } + @Override public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { ((DevicesListViewHolder) holder).bindView(position); @@ -59,7 +74,7 @@ public void bindView (int position) { if (position == 0) { // Main device mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); - MainActivity.handlerDeviceList = new Handler() { + m_handlerDeviceList = new Handler() { public void handleMessage(Message msg) { int intValue = msg.getData().getInt("State", -255); if( intValue != -255 ) { @@ -67,6 +82,7 @@ public void handleMessage(Message msg) { } } }; + MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerDeviceList); } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java index 26400d8..d5ec999 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java @@ -40,8 +40,18 @@ public class GlanceFragment extends Fragment { TextView outsideTemp, degreeSymbol, roomTemp; private static final String TAG = MainActivity.class.getSimpleName(); + private RecyclerView devicesRecyclerView; WeatherDetails mWeatherDetails; + private Handler m_handlerGlance; + + @Override + public void onDestroyView() { + devicesRecyclerView.setAdapter(null); + MainActivity.m_mainDevice.removeDataEventHandler(m_handlerGlance); + super.onDestroyView(); + } + @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -53,7 +63,7 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, roomTemp = (TextView) view.findViewById(R.id.valRoomTemp); roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); - MainActivity.handlerGlance = new Handler() { + m_handlerGlance = new Handler() { public void handleMessage(Message msg) { int intValue = msg.getData().getInt("DHTt", -255); if( intValue != -255 ) { @@ -61,9 +71,10 @@ public void handleMessage(Message msg) { } } }; + MainActivity.m_mainDevice.addDataEventHandler(m_handlerGlance); //setup recycler view - RecyclerView devicesRecyclerView = (RecyclerView) view.findViewById(R.id.devicesRecyclerView); + devicesRecyclerView = (RecyclerView) view.findViewById(R.id.devicesRecyclerView); //create list adapter DevicesListAdapter devicesListAdapter = new DevicesListAdapter(); //attach adapter to recycler view diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 17501f6..47bac38 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -15,7 +15,6 @@ import com.umarbhutta.xlightcompanion.R; import com.umarbhutta.xlightcompanion.SDK.CloudAccount; -import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.control.ControlFragment; import com.umarbhutta.xlightcompanion.glance.GlanceFragment; import com.umarbhutta.xlightcompanion.SDK.xltDevice; @@ -35,10 +34,6 @@ public class MainActivity extends AppCompatActivity public static xltDevice m_mainDevice; - public static Handler handlerGlance = null; - public static Handler handlerDeviceList = null; - public static Handler handlerControl = null; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java index 9d2485a..846950c 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/AddScenarioActivity.java @@ -18,8 +18,8 @@ import android.widget.Spinner; import android.widget.TextView; -import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.R; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; import com.umarbhutta.xlightcompanion.main.MainActivity; import me.priyesh.chroma.ChromaDialog; @@ -125,7 +125,7 @@ public void onClick(View v) { scenarioInfo = "A " + colorHex + " color with " + scenarioBrightness + "% brightness"; //SEND TO PARTICLE CLOUD FOR ALL RINGS - MainActivity.m_mainDevice.sceAddScenario(scenarioBrightness, cw, ww, r, g, b, scenarioFilter); + MainActivity.m_mainDevice.sceAddScenario(ScenarioFragment.name.size(), scenarioBrightness, cw, ww, r, g, b, xltDevice.DEFAULT_FILTER_ID); //send data to update the list Intent returnIntent = getIntent(); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java index 79706a1..efd14e4 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java @@ -78,8 +78,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { scenarioListAdapter.notifyDataSetChanged(); Toast.makeText(getActivity(), "The scenario has been successfully added", Toast.LENGTH_SHORT).show(); - } - if (resultCode == Activity.RESULT_CANCELED) { + } else if (resultCode == Activity.RESULT_CANCELED) { Toast.makeText(getActivity(), "No new scenarios were added to the list", Toast.LENGTH_SHORT).show(); } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java index 52ae6be..34ebcd8 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java @@ -32,9 +32,8 @@ public class AddScheduleActivity extends AppCompatActivity { private Button addButton; private ImageView backImageView; - private int defeaultNodeId = xltDevice.DEFAULT_DEVICE_ID; private boolean isRepeat = false; - private int hour, minute, nodeId; + private int hour, minute; private String am_pm, weekdays, outgoingWeekdays, scenarioName; //a boolean of which day of week has been selected in active (0-6, 0 = Monday) private boolean[] weekdaySelected = {false, false, false, false, false, false, false}; @@ -181,7 +180,7 @@ public void onClick(View v) { } //get value of device spinner - nodeId = (int) scenarioSpinner.getSelectedItemId(); + //nodeId = (int) scenarioSpinner.getSelectedItemId(); //get value of scenario spinner scenarioName = scenarioSpinner.getSelectedItem().toString(); @@ -248,6 +247,16 @@ public void onClick(View v) { //call JSONConfigAlarm to send a schedule row // DMI //ParticleBridge.JSONConfigAlarm(defeaultNodeId, isRepeat, weekdays, hour, minute, scenarioName); + int scheduleId = ScheduleFragment.name.size(); + MainActivity.m_mainDevice.sceAddSchedule(scheduleId, isRepeat, weekdays, hour, minute, xltDevice.DEFAULT_ALARM_ID); + // Get scenarioId from name + int scenarioId = 1; + for (int i = 0; i < ScenarioFragment.name.size(); i++) { + if (scenarioName == ScenarioFragment.name.get(i)) { + scenarioId = i; + } + } + MainActivity.m_mainDevice.sceAddRule(scheduleId, scheduleId, scenarioId); //send data to update the list Intent returnIntent = getIntent(); From 3ce2241783393161eae1c04b44fd2b6264f5cc52 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sat, 3 Dec 2016 01:27:20 -0500 Subject: [PATCH 06/25] show more information on Glance activity --- .../glance/GlanceFragment.java | 94 ++++++++++++++++- .../glance/WeatherDetails.java | 39 ++++++- app/src/main/res/drawable/weather_icons_1.png | Bin 0 -> 31950 bytes app/src/main/res/layout/fragment_glance.xml | 99 +++++++++++++++++- 4 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/weather_icons_1.png diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java index d5ec999..e84485b 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java @@ -1,6 +1,10 @@ package com.umarbhutta.xlightcompanion.glance; import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Bundle; @@ -11,9 +15,11 @@ import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; +import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; @@ -22,6 +28,7 @@ import com.squareup.okhttp.OkHttpClient; import com.squareup.okhttp.Request; import com.squareup.okhttp.Response; +import com.umarbhutta.xlightcompanion.SDK.CloudAccount; import com.umarbhutta.xlightcompanion.main.MainActivity; import com.umarbhutta.xlightcompanion.R; import com.umarbhutta.xlightcompanion.main.SimpleDividerItemDecoration; @@ -37,7 +44,8 @@ */ public class GlanceFragment extends Fragment { private com.github.clans.fab.FloatingActionButton fab; - TextView outsideTemp, degreeSymbol, roomTemp; + TextView outsideTemp, degreeSymbol, roomTemp, roomHumidity, outsideHumidity, apparentTemp; + ImageView imgWeather; private static final String TAG = MainActivity.class.getSimpleName(); private RecyclerView devicesRecyclerView; @@ -45,6 +53,11 @@ public class GlanceFragment extends Fragment { private Handler m_handlerGlance; + private Bitmap icoDefault, icoClearDay, icoClearNight, icoRain, icoSnow, icoSleet, icoWind, icoFog; + private Bitmap icoCloudy, icoPartlyCloudyDay, icoPartlyCloudyNight; + private static int ICON_WIDTH = 70; + private static int ICON_HEIGHT = 75; + @Override public void onDestroyView() { devicesRecyclerView.setAdapter(null); @@ -60,8 +73,27 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, fab = (com.github.clans.fab.FloatingActionButton) view.findViewById(R.id.fab); outsideTemp = (TextView) view.findViewById(R.id.outsideTemp); degreeSymbol = (TextView) view.findViewById(R.id.degreeSymbol); + outsideHumidity = (TextView) view.findViewById(R.id.valLocalHumidity); + apparentTemp = (TextView) view.findViewById(R.id.valApparentTemp); roomTemp = (TextView) view.findViewById(R.id.valRoomTemp); roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); + roomHumidity = (TextView) view.findViewById(R.id.valRoomHumidity); + roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); + imgWeather = (ImageView) view.findViewById(R.id.weatherIcon); + + Resources res = getResources(); + Bitmap weatherIcons = decodeResource(res, R.drawable.weather_icons_1, 420, 600); + icoDefault = Bitmap.createBitmap(weatherIcons, 0, 0, ICON_WIDTH, ICON_HEIGHT); + icoClearDay = Bitmap.createBitmap(weatherIcons, ICON_WIDTH, 0, ICON_WIDTH, ICON_HEIGHT); + icoClearNight = Bitmap.createBitmap(weatherIcons, ICON_WIDTH * 2, 0, ICON_WIDTH, ICON_HEIGHT); + icoRain = Bitmap.createBitmap(weatherIcons, ICON_WIDTH * 5, ICON_HEIGHT * 2, ICON_WIDTH, ICON_HEIGHT); + icoSnow = Bitmap.createBitmap(weatherIcons, ICON_WIDTH * 4, ICON_HEIGHT * 3, ICON_WIDTH, ICON_HEIGHT); + icoSleet = Bitmap.createBitmap(weatherIcons, ICON_WIDTH * 5, ICON_HEIGHT * 3, ICON_WIDTH, ICON_HEIGHT); + icoWind = Bitmap.createBitmap(weatherIcons, 0, ICON_HEIGHT * 3, ICON_WIDTH, ICON_HEIGHT); + icoFog = Bitmap.createBitmap(weatherIcons, 0, ICON_HEIGHT * 2, ICON_WIDTH, ICON_HEIGHT); + icoCloudy = Bitmap.createBitmap(weatherIcons, ICON_WIDTH , ICON_HEIGHT * 5, ICON_WIDTH, ICON_HEIGHT); + icoPartlyCloudyDay = Bitmap.createBitmap(weatherIcons, ICON_WIDTH, ICON_HEIGHT, ICON_WIDTH, ICON_HEIGHT); + icoPartlyCloudyNight = Bitmap.createBitmap(weatherIcons, ICON_WIDTH * 2, ICON_HEIGHT, ICON_WIDTH, ICON_HEIGHT); m_handlerGlance = new Handler() { public void handleMessage(Message msg) { @@ -69,6 +101,10 @@ public void handleMessage(Message msg) { if( intValue != -255 ) { roomTemp.setText(intValue + "\u00B0"); } + intValue = msg.getData().getInt("DHTh", -255); + if( intValue != -255 ) { + roomHumidity.setText(intValue + "\u0025"); + } } }; MainActivity.m_mainDevice.addDataEventHandler(m_handlerGlance); @@ -86,10 +122,9 @@ public void handleMessage(Message msg) { //divider lines devicesRecyclerView.addItemDecoration(new SimpleDividerItemDecoration(getActivity())); - String apiKey = "b6756abd11c020e6e9914c9fb4730169"; double latitude = 43.4643; double longitude = -80.5204; - String forecastUrl = "https://api.forecast.io/forecast/" + apiKey + "/" + latitude + "," + longitude; + String forecastUrl = "https://api.forecast.io/forecast/" + CloudAccount.DarkSky_apiKey + "/" + latitude + "," + longitude; if (isNetworkAvailable()) { OkHttpClient client = new OkHttpClient(); @@ -135,9 +170,14 @@ public void run() { } private void updateDisplay() { + imgWeather.setImageBitmap(getWeatherIcon(mWeatherDetails.getIcon())); outsideTemp.setText(" " + mWeatherDetails.getTemp("celsius")); degreeSymbol.setText("\u00B0"); + outsideHumidity.setText(mWeatherDetails.getmHumidity() + "\u0025"); + apparentTemp.setText(mWeatherDetails.getApparentTemp("celsius") + "\u00B0"); + roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); + roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); } private WeatherDetails getWeatherDetails(String jsonData) throws JSONException { @@ -151,6 +191,8 @@ private WeatherDetails getWeatherDetails(String jsonData) throws JSONException { weatherDetails.setTemp(currently.getDouble("temperature")); weatherDetails.setIcon(currently.getString("icon")); + weatherDetails.setApparentTemp(currently.getDouble("apparentTemperature")); + weatherDetails.setHumidity((int)(currently.getDouble("humidity") * 100 + 0.5)); return weatherDetails; } @@ -171,4 +213,50 @@ private boolean isNetworkAvailable() { return isAvailable; } + + private Bitmap decodeResource(Resources resources, final int id, final int newWidth, final int newHeight) { + TypedValue value = new TypedValue(); + resources.openRawResource(id, value); + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inTargetDensity = value.density; + Bitmap loadBmp = BitmapFactory.decodeResource(resources, id, opts); + + int width = loadBmp.getWidth(); + int height = loadBmp.getHeight(); + + float scaleWidth = ((float) newWidth) / width; + float scaleHeight = ((float) newHeight) / height; + + Matrix matrix = new Matrix(); + matrix.postScale(scaleWidth, scaleHeight); + + Bitmap newBmp = Bitmap.createBitmap(loadBmp, 0, 0, width, height, matrix, true); + return newBmp; + } + + public Bitmap getWeatherIcon(final String iconName) { + if( iconName.equalsIgnoreCase("clear-day") ) { + return icoClearDay; + } else if( iconName.equalsIgnoreCase("clear-night") ) { + return icoClearNight; + } else if( iconName.equalsIgnoreCase("rain") ) { + return icoRain; + } else if( iconName.equalsIgnoreCase("snow") ) { + return icoSnow; + } else if( iconName.equalsIgnoreCase("sleet") ) { + return icoSleet; + } else if( iconName.equalsIgnoreCase("wind") ) { + return icoWind; + } else if( iconName.equalsIgnoreCase("fog") ) { + return icoFog; + } else if( iconName.equalsIgnoreCase("cloudy") ) { + return icoCloudy; + } else if( iconName.equalsIgnoreCase("partly-cloudy-day") ) { + return icoPartlyCloudyDay; + } else if( iconName.equalsIgnoreCase("partly-cloudy-night") ) { + return icoPartlyCloudyNight; + } else { + return icoDefault; + } + } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java index 165a478..548ea8d 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java @@ -1,5 +1,7 @@ package com.umarbhutta.xlightcompanion.glance; +import android.graphics.Bitmap; + /** * Created by Umar Bhutta. */ @@ -7,16 +9,23 @@ public class WeatherDetails { private String mIcon; private double mTempF; private int mTempC; + private double mApparentTempF; + private int mApparentTempC; + private int mHumidity; + + public WeatherDetails() { + super(); + } public String getIcon() { return mIcon; } - public void setIcon(String mIcon) { + public void setIcon(final String mIcon) { this.mIcon = mIcon; } - public int getTemp(String unit) + public int getTemp(final String unit) { if (unit == "fahrenheit") { return (int) mTempF; @@ -25,9 +34,31 @@ public int getTemp(String unit) } } - public void setTemp(double mTemp) { + public void setTemp(final double mTemp) { this.mTempF = mTemp; - mTempC = (int) ((mTempF - 32.0) * (5.0/9.0)); + mTempC = (int) ((mTempF - 32.0) * (5.0/9.0) + 0.5); + } + + public int getmHumidity() { + return mHumidity; + } + + public void setHumidity(final int humidity) { + this.mHumidity = humidity; + } + + public int getApparentTemp(final String unit) + { + if (unit == "fahrenheit") { + return (int) mApparentTempF; + } else { + return mApparentTempC; + } + } + + public void setApparentTemp(final double mTemp) { + mApparentTempF = mTemp; + mApparentTempC = (int) ((mTempF - 32.0) * (5.0/9.0) + 0.5); } } diff --git a/app/src/main/res/drawable/weather_icons_1.png b/app/src/main/res/drawable/weather_icons_1.png new file mode 100644 index 0000000000000000000000000000000000000000..076b9c976edc5c55680b15f7eb52c41b3c663bbe GIT binary patch literal 31950 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⪆X`;Hq9qXvDIr`PD`DT)E(JEx2JPXrzznc4Lo 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/fragment_glance.xml b/app/src/main/res/layout/fragment_glance.xml index 5f2f96f..282a432 100644 --- a/app/src/main/res/layout/fragment_glance.xml +++ b/app/src/main/res/layout/fragment_glance.xml @@ -27,6 +27,16 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal"> + + + + + + + + + + + + + + + + + + + + + + + @@ -78,13 +151,37 @@ + + + + + + + + + android:paddingLeft="20dp"> Date: Wed, 7 Dec 2016 22:54:25 -0500 Subject: [PATCH 07/25] Event Broadcast demo --- .../xlightcompanion/SDK/CloudBridge.java | 16 +++-- .../xlightcompanion/SDK/xltDevice.java | 20 +++++++ .../xlightcompanion/Tools/DataReceiver.java | 20 +++++++ .../xlightcompanion/Tools/StatusReceiver.java | 20 +++++++ .../control/ControlFragment.java | 59 +++++++++++++------ .../control/DevicesListAdapter.java | 50 +++++++++++++--- .../glance/GlanceFragment.java | 48 +++++++++++---- .../xlightcompanion/main/MainActivity.java | 4 ++ 8 files changed, 195 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/Tools/DataReceiver.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/Tools/StatusReceiver.java diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java index 4298dcc..36e787f 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java @@ -387,11 +387,17 @@ public void onEvent(String eventName, ParticleEvent event) { eventName = xltDevice.eventDeviceStatus; } - // Demo option: use handler & sendMessage to inform activities - InformActivities(eventName, event.dataPayload); - - // Demo Option: use broadcast & receivers to publish events - //BroadcastEvent(eventName, event.dataPayload); + if( m_parentDevice != null ) { + // Demo option: use handler & sendMessage to inform activities + if( m_parentDevice.getEnableEventSendMessage() ) { + InformActivities(eventName, event.dataPayload); + } + + // Demo Option: use broadcast & receivers to publish events + if( m_parentDevice.getEnableEventBroadcast() ) { + BroadcastEvent(eventName, event.dataPayload); + } + } } public void onEventError(Exception e) { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index df06606..479c5b8 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -157,6 +157,10 @@ public class SensorData { // Sensor Data public SensorData m_Data; + // Event Notification + private boolean m_enableEventBroadcast = false; + private boolean m_enableEventSendMessage = true; + // Event Handler List private ArrayList m_lstEH_DevST = new ArrayList<>(); private ArrayList m_lstEH_SenDT = new ArrayList<>(); @@ -717,6 +721,22 @@ public int ChangeScenario(final int scenario) { //------------------------------------------------------------------------- // Event Handler Interfaces //------------------------------------------------------------------------- + public boolean getEnableEventBroadcast() { + return m_enableEventBroadcast; + } + + public boolean getEnableEventSendMessage() { + return m_enableEventSendMessage; + } + + public void setEnableEventBroadcast(final boolean flag) { + m_enableEventBroadcast = flag; + } + + public void setEnableEventSendMessage(final boolean flag) { + m_enableEventSendMessage = flag; + } + public int addDeviceEventHandler(final Handler handler) { m_lstEH_DevST.add(handler); return m_lstEH_DevST.size(); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/Tools/DataReceiver.java b/app/src/main/java/com/umarbhutta/xlightcompanion/Tools/DataReceiver.java new file mode 100644 index 0000000..2afb95b --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/Tools/DataReceiver.java @@ -0,0 +1,20 @@ +package com.umarbhutta.xlightcompanion.Tools; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class DataReceiver extends BroadcastReceiver { + private final String TAG = DataReceiver.class.getSimpleName(); + + public DataReceiver() { + } + + @Override + public void onReceive(Context context, Intent intent) { + // TODO: This method is called when the BroadcastReceiver is receiving + // an Intent broadcast. + Log.i(TAG, "INTENT RECEIVED by " + TAG); + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/Tools/StatusReceiver.java b/app/src/main/java/com/umarbhutta/xlightcompanion/Tools/StatusReceiver.java new file mode 100644 index 0000000..316e5fd --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/Tools/StatusReceiver.java @@ -0,0 +1,20 @@ +package com.umarbhutta.xlightcompanion.Tools; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class StatusReceiver extends BroadcastReceiver { + private final String TAG = DataReceiver.class.getSimpleName(); + + public StatusReceiver() { + } + + @Override + public void onReceive(Context context, Intent intent) { + // TODO: This method is called when the BroadcastReceiver is receiving + // an Intent broadcast. + Log.i(TAG, "INTENT RECEIVED by " + TAG); + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index 8b9787b..83ab660 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -1,5 +1,8 @@ package com.umarbhutta.xlightcompanion.control; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.graphics.Color; import android.os.Bundle; import android.os.Handler; @@ -24,6 +27,7 @@ import com.umarbhutta.xlightcompanion.R; import com.umarbhutta.xlightcompanion.SDK.xltDevice; +import com.umarbhutta.xlightcompanion.Tools.StatusReceiver; import com.umarbhutta.xlightcompanion.main.MainActivity; import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; @@ -63,9 +67,22 @@ public class ControlFragment extends Fragment { private Handler m_handlerControl; + private class MyStatusReceiver extends StatusReceiver { + @Override + public void onReceive(Context context, Intent intent) { + powerSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); + brightnessSeekBar.setProgress(MainActivity.m_mainDevice.getBrightness()); + cctSeekBar.setProgress(MainActivity.m_mainDevice.getCCT() - 2700); + } + } + private final MyStatusReceiver m_StatusReceiver = new MyStatusReceiver(); + @Override public void onDestroyView() { MainActivity.m_mainDevice.removeDeviceEventHandler(m_handlerControl); + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { + getContext().unregisterReceiver(m_StatusReceiver); + } super.onDestroyView(); } @@ -109,25 +126,33 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa brightnessSeekBar.setProgress(MainActivity.m_mainDevice.getBrightness()); cctSeekBar.setProgress(MainActivity.m_mainDevice.getCCT() - 2700); - m_handlerControl = new Handler() { - public void handleMessage(Message msg) { - int intValue = msg.getData().getInt("State", -255); - if( intValue != -255 ) { - powerSwitch.setChecked(intValue > 0); - } - - intValue = msg.getData().getInt("BR", -255); - if( intValue != -255 ) { - brightnessSeekBar.setProgress(intValue); - } + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { + IntentFilter intentFilter = new IntentFilter(xltDevice.bciDeviceStatus); + intentFilter.setPriority(3); + getContext().registerReceiver(m_StatusReceiver, intentFilter); + } - intValue = msg.getData().getInt("CCT", -255); - if( intValue != -255 ) { - cctSeekBar.setProgress(intValue - 2700); + if( MainActivity.m_mainDevice.getEnableEventSendMessage() ) { + m_handlerControl = new Handler() { + public void handleMessage(Message msg) { + int intValue = msg.getData().getInt("State", -255); + if (intValue != -255) { + powerSwitch.setChecked(intValue > 0); + } + + intValue = msg.getData().getInt("BR", -255); + if (intValue != -255) { + brightnessSeekBar.setProgress(intValue); + } + + intValue = msg.getData().getInt("CCT", -255); + if (intValue != -255) { + cctSeekBar.setProgress(intValue - 2700); + } } - } - }; - MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerControl); + }; + MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerControl); + } powerSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java index 290f914..ba3475f 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java @@ -1,5 +1,8 @@ package com.umarbhutta.xlightcompanion.control; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.os.Handler; import android.os.Message; import android.support.v7.widget.RecyclerView; @@ -10,6 +13,8 @@ import android.widget.Switch; import android.widget.TextView; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; +import com.umarbhutta.xlightcompanion.Tools.StatusReceiver; import com.umarbhutta.xlightcompanion.main.MainActivity; import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.R; @@ -21,6 +26,28 @@ public class DevicesListAdapter extends RecyclerView.Adapter { private Handler m_handlerDeviceList; + private class MyStatusReceiver extends StatusReceiver { + public Switch m_mainSwitch = null; + + @Override + public void onReceive(Context context, Intent intent) { + if( m_mainSwitch != null ) { + m_mainSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); + } + } + } + private final MyStatusReceiver m_StatusReceiver = new MyStatusReceiver(); + + @Override + public void onAttachedToRecyclerView(RecyclerView recyclerView) { + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { + IntentFilter intentFilter = new IntentFilter(xltDevice.bciDeviceStatus); + intentFilter.setPriority(3); + recyclerView.getContext().registerReceiver(m_StatusReceiver, intentFilter); + } + super.onAttachedToRecyclerView(recyclerView); + } + @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.devices_list_item, parent, false); @@ -32,6 +59,9 @@ public void onDetachedFromRecyclerView(RecyclerView recyclerView) { if( m_handlerDeviceList != null ) { MainActivity.m_mainDevice.removeDeviceEventHandler(m_handlerDeviceList); } + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { + recyclerView.getContext().unregisterReceiver(m_StatusReceiver); + } super.onDetachedFromRecyclerView(recyclerView); } @@ -74,15 +104,19 @@ public void bindView (int position) { if (position == 0) { // Main device mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); - m_handlerDeviceList = new Handler() { - public void handleMessage(Message msg) { - int intValue = msg.getData().getInt("State", -255); - if( intValue != -255 ) { - mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); + m_StatusReceiver.m_mainSwitch = mDeviceSwitch; + + if( MainActivity.m_mainDevice.getEnableEventSendMessage() ) { + m_handlerDeviceList = new Handler() { + public void handleMessage(Message msg) { + int intValue = msg.getData().getInt("State", -255); + if (intValue != -255) { + mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); + } } - } - }; - MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerDeviceList); + }; + MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerDeviceList); + } } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java index e84485b..8f90bff 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java @@ -1,6 +1,8 @@ package com.umarbhutta.xlightcompanion.glance; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; @@ -29,6 +31,8 @@ import com.squareup.okhttp.Request; import com.squareup.okhttp.Response; import com.umarbhutta.xlightcompanion.SDK.CloudAccount; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; +import com.umarbhutta.xlightcompanion.Tools.DataReceiver; import com.umarbhutta.xlightcompanion.main.MainActivity; import com.umarbhutta.xlightcompanion.R; import com.umarbhutta.xlightcompanion.main.SimpleDividerItemDecoration; @@ -58,10 +62,22 @@ public class GlanceFragment extends Fragment { private static int ICON_WIDTH = 70; private static int ICON_HEIGHT = 75; + private class MyDataReceiver extends DataReceiver { + @Override + public void onReceive(Context context, Intent intent) { + roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); + roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); + } + } + private final MyDataReceiver m_DataReceiver = new MyDataReceiver(); + @Override public void onDestroyView() { devicesRecyclerView.setAdapter(null); MainActivity.m_mainDevice.removeDataEventHandler(m_handlerGlance); + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { + getContext().unregisterReceiver(m_DataReceiver); + } super.onDestroyView(); } @@ -95,19 +111,27 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, icoPartlyCloudyDay = Bitmap.createBitmap(weatherIcons, ICON_WIDTH, ICON_HEIGHT, ICON_WIDTH, ICON_HEIGHT); icoPartlyCloudyNight = Bitmap.createBitmap(weatherIcons, ICON_WIDTH * 2, ICON_HEIGHT, ICON_WIDTH, ICON_HEIGHT); - m_handlerGlance = new Handler() { - public void handleMessage(Message msg) { - int intValue = msg.getData().getInt("DHTt", -255); - if( intValue != -255 ) { - roomTemp.setText(intValue + "\u00B0"); - } - intValue = msg.getData().getInt("DHTh", -255); - if( intValue != -255 ) { - roomHumidity.setText(intValue + "\u0025"); + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { + IntentFilter intentFilter = new IntentFilter(xltDevice.bciSensorData); + intentFilter.setPriority(3); + getContext().registerReceiver(m_DataReceiver, intentFilter); + } + + if( MainActivity.m_mainDevice.getEnableEventSendMessage() ) { + m_handlerGlance = new Handler() { + public void handleMessage(Message msg) { + int intValue = msg.getData().getInt("DHTt", -255); + if (intValue != -255) { + roomTemp.setText(intValue + "\u00B0"); + } + intValue = msg.getData().getInt("DHTh", -255); + if (intValue != -255) { + roomHumidity.setText(intValue + "\u0025"); + } } - } - }; - MainActivity.m_mainDevice.addDataEventHandler(m_handlerGlance); + }; + MainActivity.m_mainDevice.addDataEventHandler(m_handlerGlance); + } //setup recycler view devicesRecyclerView = (RecyclerView) view.findViewById(R.id.devicesRecyclerView); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 47bac38..0325e32 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -46,6 +46,10 @@ protected void onCreate(Bundle savedInstanceState) { m_mainDevice.Init(this); m_mainDevice.Connect(CloudAccount.DEVICE_ID); + // Set SmartDevice Event Notification Flag + //m_mainDevice.setEnableEventSendMessage(false); + //m_mainDevice.setEnableEventBroadcast(true); + //setup drawer layout DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); ActionBarDrawerToggle toggle = new ActionBarDrawerToggle( From 94db83ef8edb54498f94bff34814f759313d1d3b Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sun, 11 Dec 2016 19:48:39 -0500 Subject: [PATCH 08/25] Add BLEAdapter class --- .idea/misc.xml | 2 +- app/src/main/AndroidManifest.xml | 4 + .../xlightcompanion/SDK/BLEAdapter.java | 75 +++++++++++++++++++ ...rticleBridge.java => ParticleAdapter.java} | 10 ++- .../xlightcompanion/SDK/xltDevice.java | 23 ++++-- .../control/ControlFragment.java | 30 ++++---- .../control/DevicesListAdapter.java | 3 +- .../xlightcompanion/main/MainActivity.java | 18 +++++ .../schedule/AddScheduleActivity.java | 2 +- build.gradle | 2 +- 10 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEAdapter.java rename app/src/main/java/com/umarbhutta/xlightcompanion/SDK/{ParticleBridge.java => ParticleAdapter.java} (90%) diff --git a/.idea/misc.xml b/.idea/misc.xml index 5d19981..fbb6828 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -37,7 +37,7 @@ - + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ec8835..7963fe2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ + + + + mPairedDevices = new ArrayList<>(); + + public static void init(Context context) { + m_Context = context; + m_bSupported = m_Context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE); + if (!m_bSupported) { + Log.e(TAG, "Bluetooth NOT supported!"); + return; + } + m_btAdapter = BluetoothAdapter.getDefaultAdapter(); + CheckBluetoothState(); + m_bInitialized = true; + } + + public static boolean initialized() { + return m_bInitialized; + } + + public static boolean IsSupported() { + return m_bSupported; + } + + public static boolean IsEnabled() { + return m_bEnabled; + } + + public static void CheckBluetoothState() { + if (m_btAdapter != null) { + m_bEnabled = m_btAdapter.isEnabled(); + } else { + m_bEnabled = false; + } + + if (m_bEnabled) { + Log.d(TAG, "Bluetooth is enabled..."); + mPairedDevices.clear(); + Set devices = m_btAdapter.getBondedDevices(); + for (BluetoothDevice device : devices) { + if (device.getBluetoothClass().hashCode() == XLIGHT_BLE_CLASS || device.getName().startsWith(XLIGHT_BLE_NAME_PREFIX)) { + mPairedDevices.add(device); + } + } + } + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleAdapter.java similarity index 90% rename from app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleAdapter.java index 28efec8..fa9ce24 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleAdapter.java @@ -13,10 +13,11 @@ * Created by Umar Bhutta. */ @SuppressWarnings({"UnusedDeclaration"}) -public class ParticleBridge { +public class ParticleAdapter { // misc - private static final String TAG = ParticleBridge.class.getSimpleName(); + private static final String TAG = ParticleAdapter.class.getSimpleName(); + private static boolean m_bInitialized = false; private static int resultCode; private static boolean m_bLoggedIn = false; private static List m_devices; @@ -25,6 +26,11 @@ public class ParticleBridge { // Particle functions public static void init(Context context) { ParticleCloudSDK.init(context); + m_bInitialized = true; + } + + public static boolean initialized() { + return m_bInitialized; } public static boolean isAuthenticated() { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index 479c5b8..a6256ae 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -190,10 +190,19 @@ public void Init(Context context) { // Ensure we do it only once if( !m_bInitialized ) { - ParticleBridge.init(context); - // ToDo: get login credential or access token from DMI - // make sure we logged onto IoT cloud - ParticleBridge.authenticate(); + // Init BLE Adapter + if( !BLEAdapter.initialized() ) { + BLEAdapter.init(context); + } + + // Init Particle Adapter + if( !ParticleAdapter.initialized() ) { + ParticleAdapter.init(context); + // ToDo: get login credential or access token from DMI + // make sure we logged onto IoT cloud + ParticleAdapter.authenticate(); + } + m_bInitialized = true; } @@ -231,11 +240,11 @@ public boolean ConnectCloud() { public void run() { // Check ControllerID int timeout = TIMEOUT_CLOUD_LOGIN; - while( !ParticleBridge.isAuthenticated() && timeout-- > 0 ) { + while( !ParticleAdapter.isAuthenticated() && timeout-- > 0 ) { SystemClock.sleep(1000); } - if( ParticleBridge.isAuthenticated() ) { - if (ParticleBridge.checkDeviceID(m_ControllerID)) { + if( ParticleAdapter.isAuthenticated() ) { + if (ParticleAdapter.checkDeviceID(m_ControllerID)) { // Connect Cloud Instance cldBridge.connectCloud(m_ControllerID); } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index 83ab660..f9b5d71 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -159,8 +159,8 @@ public void handleMessage(Message msg) { public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { //check if on or off state = isChecked; - //ParticleBridge.JSONCommandPower(ParticleBridge.DEFAULT_DEVICE_ID, state); - //ParticleBridge.FastCallPowerSwitch(ParticleBridge.DEFAULT_DEVICE_ID, state); + //ParticleAdapter.JSONCommandPower(ParticleAdapter.DEFAULT_DEVICE_ID, state); + //ParticleAdapter.FastCallPowerSwitch(ParticleAdapter.DEFAULT_DEVICE_ID, state); MainActivity.m_mainDevice.PowerSwitch(state); } }); @@ -192,31 +192,31 @@ public void onColorSelected(int color) { //send message to Particle based on which rings have been selected if ((ring1 && ring2 && ring3) || (!ring1 && !ring2 && !ring3)) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_ALL, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_ALL, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, state, br, ww, r, g, b); } else if (ring1 && ring2) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, br, ww, r, g, b); - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_1, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_2, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); } else if (ring2 && ring3) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, br, ww, r, g, b); - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_2, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_3, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); } else if (ring1 && ring3) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, br, ww, r, g, b); - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_1, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_3, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); } else if (ring1) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_1, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_1, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); } else if (ring2) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_2, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_2, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); } else if (ring3) { - //ParticleBridge.JSONCommandColor(ParticleBridge.DEFAULT_DEVICE_ID, ParticleBridge.RING_3, state, br, ww, r, g, b); + //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_3, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); } else { //do nothing @@ -240,7 +240,7 @@ public void onStartTrackingTouch(SeekBar seekBar) { @Override public void onStopTrackingTouch(SeekBar seekBar) { Log.e(TAG, "The brightness value is " + seekBar.getProgress()); - //ParticleBridge.JSONCommandBrightness(ParticleBridge.DEFAULT_DEVICE_ID, seekBar.getProgress()); + //ParticleAdapter.JSONCommandBrightness(ParticleAdapter.DEFAULT_DEVICE_ID, seekBar.getProgress()); MainActivity.m_mainDevice.ChangeBrightness(seekBar.getProgress()); } }); @@ -257,7 +257,7 @@ public void onStartTrackingTouch(SeekBar seekBar) { @Override public void onStopTrackingTouch(SeekBar seekBar) { Log.d(TAG, "The CCT value is " + seekBar.getProgress()+2700); - //ParticleBridge.JSONCommandCCT(ParticleBridge.DEFAULT_DEVICE_ID, seekBar.getProgress()+2700); + //ParticleAdapter.JSONCommandCCT(ParticleAdapter.DEFAULT_DEVICE_ID, seekBar.getProgress()+2700); MainActivity.m_mainDevice.ChangeCCT(seekBar.getProgress()+2700); } }); @@ -277,7 +277,7 @@ public void onItemSelected(AdapterView parent, View view, int position, long //disable all views below spinner disableEnableControls(false); - //ParticleBridge.JSONCommandScenario(ParticleBridge.DEFAULT_DEVICE_ID, position); + //ParticleAdapter.JSONCommandScenario(ParticleAdapter.DEFAULT_DEVICE_ID, position); //position passed into above function corresponds to the scenarioId i.e. s1, s2, s3 to trigger MainActivity.m_mainDevice.ChangeScenario(position); } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java index ba3475f..9a16ab6 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java @@ -16,7 +16,6 @@ import com.umarbhutta.xlightcompanion.SDK.xltDevice; import com.umarbhutta.xlightcompanion.Tools.StatusReceiver; import com.umarbhutta.xlightcompanion.main.MainActivity; -import com.umarbhutta.xlightcompanion.SDK.ParticleBridge; import com.umarbhutta.xlightcompanion.R; /** @@ -93,7 +92,7 @@ public DevicesListViewHolder(View itemView) { mDeviceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener(){ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - //ParticleBridge.FastCallPowerSwitch(ParticleBridge.DEFAULT_DEVICE_ID, isChecked); + //ParticleAdapter.FastCallPowerSwitch(ParticleAdapter.DEFAULT_DEVICE_ID, isChecked); MainActivity.m_mainDevice.PowerSwitch(isChecked); } }); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 0325e32..8f87e49 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -1,5 +1,7 @@ package com.umarbhutta.xlightcompanion.main; +import android.bluetooth.BluetoothAdapter; +import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.support.v4.app.Fragment; @@ -14,6 +16,7 @@ import android.view.MenuItem; import com.umarbhutta.xlightcompanion.R; +import com.umarbhutta.xlightcompanion.SDK.BLEAdapter; import com.umarbhutta.xlightcompanion.SDK.CloudAccount; import com.umarbhutta.xlightcompanion.control.ControlFragment; import com.umarbhutta.xlightcompanion.glance.GlanceFragment; @@ -41,6 +44,13 @@ protected void onCreate(Bundle savedInstanceState) { Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); + // Check Bluetooth + BLEAdapter.init(this); + if( BLEAdapter.IsSupported() && !BLEAdapter.IsEnabled() ) { + Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + startActivityForResult(enableBtIntent, BLEAdapter.REQUEST_ENABLE_BT); + } + // Initialize SmartDevice SDK m_mainDevice = new xltDevice(); m_mainDevice.Init(this); @@ -64,6 +74,14 @@ protected void onCreate(Bundle savedInstanceState) { navigationView.getMenu().getItem(0).setChecked(true); } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == BLEAdapter.REQUEST_ENABLE_BT) { + BLEAdapter.init(this); + } + } + public void displayView(int viewId) { Fragment fragment = null; String title = getString(R.string.app_name); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java index 34ebcd8..271ecc4 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/schedule/AddScheduleActivity.java @@ -246,7 +246,7 @@ public void onClick(View v) { //call JSONConfigAlarm to send a schedule row // DMI - //ParticleBridge.JSONConfigAlarm(defeaultNodeId, isRepeat, weekdays, hour, minute, scenarioName); + //ParticleAdapter.JSONConfigAlarm(defeaultNodeId, isRepeat, weekdays, hour, minute, scenarioName); int scheduleId = ScheduleFragment.name.size(); MainActivity.m_mainDevice.sceAddSchedule(scheduleId, isRepeat, weekdays, hour, minute, xltDevice.DEFAULT_ALARM_ID); // Get scenarioId from name diff --git a/build.gradle b/build.gradle index c20bca1..74b2ab0 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.2.2' + classpath 'com.android.tools.build:gradle:2.2.3' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files From ca954e579c3020ec4bd838b22288d9bc32583b24 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sun, 11 Dec 2016 21:38:51 -0500 Subject: [PATCH 09/25] SDK BLE package --- .../SDK/{ => BLE}/BLEAdapter.java | 12 +- .../xlightcompanion/SDK/BLE/BLEBridge.java | 175 ++++++++++++++++++ .../xlightcompanion/SDK/BLE/BleException.java | 53 ++++++ .../SDK/BLE/BleGattCallback.java | 17 ++ .../SDK/BLE/ConnectException.java | 45 +++++ .../SDK/BLE/TimeoutException.java | 11 ++ .../xlightcompanion/SDK/BLEBridge.java | 33 ---- .../SDK/{ => Cloud}/CloudBridge.java | 6 +- .../SDK/{ => Cloud}/ParticleAdapter.java | 4 +- .../{lanBridge.java => LAN/LANBridge.java} | 4 +- .../xlightcompanion/SDK/xltDevice.java | 13 +- .../xlightcompanion/main/MainActivity.java | 3 +- 12 files changed, 332 insertions(+), 44 deletions(-) rename app/src/main/java/com/umarbhutta/xlightcompanion/SDK/{ => BLE}/BLEAdapter.java (87%) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java delete mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java rename app/src/main/java/com/umarbhutta/xlightcompanion/SDK/{ => Cloud}/CloudBridge.java (99%) rename app/src/main/java/com/umarbhutta/xlightcompanion/SDK/{ => Cloud}/ParticleAdapter.java (96%) rename app/src/main/java/com/umarbhutta/xlightcompanion/SDK/{lanBridge.java => LAN/LANBridge.java} (82%) diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java similarity index 87% rename from app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEAdapter.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java index 08cb631..9e6a8f2 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java @@ -1,9 +1,8 @@ -package com.umarbhutta.xlightcompanion.SDK; +package com.umarbhutta.xlightcompanion.SDK.BLE; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.content.Context; -import android.content.Intent; import android.content.pm.PackageManager; import android.util.Log; @@ -72,4 +71,13 @@ public static void CheckBluetoothState() { } } } + + public static BluetoothDevice SearchDeviceName(final String devName) { + for (BluetoothDevice device : mPairedDevices) { + if (device.getName().equalsIgnoreCase(devName)) { + return device; + } + } + return null; + } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java new file mode 100644 index 0000000..30d271d --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java @@ -0,0 +1,175 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.util.Log; + +import com.umarbhutta.xlightcompanion.SDK.BaseBridge; + + +import static android.bluetooth.BluetoothDevice.BOND_BONDED; + +/** + * Created by sunboss on 2016-11-16. + */ +@SuppressWarnings({"UnusedDeclaration"}) +public class BLEBridge extends BaseBridge { + // misc + private static final String TAG = BLEBridge.class.getSimpleName(); + + public static final int STATE_DISCONNECTED = 0; + public static final int STATE_SCANNING = 1; + public static final int STATE_CONNECTING = 2; + public static final int STATE_CONNECTED = 3; + public static final int STATE_SERVICES_DISCOVERED = 4; + + private boolean m_bPaired = false; + private BluetoothDevice m_bleDevice; + private BluetoothGatt m_bleGatt; + private String m_bleAddress; + private int connectionState = STATE_DISCONNECTED; + + public BLEBridge() { + super(); + setName(TAG); + } + + public boolean PairDevice(final String key) { + // ToDo: pair with SmartController + // createBond() + //m_bPaired = true; + return m_bPaired; + } + + public boolean isPaired() { + return m_bPaired; + } + + public boolean connectController() { + // Connect SmartController via BLE + if( m_bleDevice != null ) { + m_bleDevice.connectGatt(m_parentContext, false, coreGattCallback); + return true; + } + return false; + } + + @Override + public void setName(final String name) { + super.setName(name); + + // Retrieve Bluetooth Device by device name + m_bleDevice = BLEAdapter.SearchDeviceName(name); + if( m_bleDevice != null ) { + m_bPaired = (m_bleDevice.getBondState() == BOND_BONDED); + m_bleAddress = m_bleDevice.getAddress(); + } else { + m_bPaired = false; + m_bleAddress = ""; + } + } + + public String getAddress() { + return m_bleAddress; + } + + public boolean isInScanning() { + return connectionState == STATE_SCANNING; + } + + public boolean isConnectingOrConnected() { + return connectionState >= STATE_CONNECTING; + } + + @Override + public boolean isConnected() { + return connectionState >= STATE_CONNECTED; + } + + public boolean isServiceDiscovered() { + return connectionState == STATE_SERVICES_DISCOVERED; + } + + private BleGattCallback coreGattCallback = new BleGattCallback() { + + @Override + public void onConnectFailure(BleException exception) { + Log.w(TAG, "coreGattCallback:onConnectFailure "); + + m_bleGatt = null; + setConnect(false); + } + + @Override + public void onConnectSuccess(BluetoothGatt gatt, int status) { + Log.i(TAG, "coreGattCallback:onConnectSuccess "); + + m_bleGatt = gatt; + setConnect(true); + } + + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + Log.i(TAG, "coreGattCallback:onConnectionStateChange " + + '\n' + "status: " + status + + '\n' + "newState: " + newState + + '\n' + "thread: " + Thread.currentThread().getId()); + + if (newState == STATE_CONNECTED) { + connectionState = STATE_CONNECTED; + onConnectSuccess(gatt, status); + + } else if (newState == BluetoothGatt.STATE_DISCONNECTED) { + connectionState = STATE_DISCONNECTED; + onConnectFailure(new ConnectException(gatt, status)); + + } else if (newState == STATE_CONNECTING) { + connectionState = STATE_CONNECTING; + } + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + Log.i(TAG, "coreGattCallback:onServicesDiscovered "); + + connectionState = STATE_SERVICES_DISCOVERED; + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + Log.d(TAG, "coreGattCallback:onCharacteristicRead "); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + Log.i(TAG, "coreGattCallback:onCharacteristicWrite "); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + Log.i(TAG, "coreGattCallback:onCharacteristicChanged "); + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + Log.d(TAG, "coreGattCallback:onDescriptorRead "); + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + Log.d(TAG, "coreGattCallback:onDescriptorWrite "); + } + + @Override + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + Log.i(TAG, "coreGattCallback:onReliableWriteCompleted "); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + Log.d(TAG, "coreGattCallback:onReadRemoteRssi "); + } + }; +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java new file mode 100644 index 0000000..0cb16be --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java @@ -0,0 +1,53 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import java.io.Serializable; + +/** + * Created by sunboss on 2016-12-11. + */ + +@SuppressWarnings({"UnusedDeclaration"}) +public abstract class BleException implements Serializable { + private static final long serialVersionUID = 8004414918500865564L; + + public static final int ERROR_CODE_TIMEOUT = 1; + public static final int ERROR_CODE_INITIAL = 101; + public static final int ERROR_CODE_GATT = 201; + public static final int GATT_CODE_OTHER = 301; + + public static final TimeoutException TIMEOUT_EXCEPTION = new TimeoutException(); + + private int code; + private String description; + + public BleException(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public BleException setCode(int code) { + this.code = code; + return this; + } + + public String getDescription() { + return description; + } + + public BleException setDescription(String description) { + this.description = description; + return this; + } + + @Override + public String toString() { + return "BleException { " + + "code=" + code + + ", description='" + description + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java new file mode 100644 index 0000000..3427e22 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java @@ -0,0 +1,17 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; + +/** + * Created by sunboss on 2016-12-11. + */ +public abstract class BleGattCallback extends BluetoothGattCallback { + + public abstract void onConnectSuccess(BluetoothGatt gatt, int status); + + @Override + public abstract void onServicesDiscovered(BluetoothGatt gatt, int status); + + public abstract void onConnectFailure(BleException exception); +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java new file mode 100644 index 0000000..838966e --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java @@ -0,0 +1,45 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothGatt; + +/** + * Created by sunboss on 2016-12-11. + */ + +@SuppressWarnings({"UnusedDeclaration"}) +public class ConnectException extends BleException { + private BluetoothGatt bluetoothGatt; + private int gattStatus; + + public ConnectException(BluetoothGatt bluetoothGatt, int gattStatus) { + super(ERROR_CODE_GATT, "Gatt Exception Occurred! "); + this.bluetoothGatt = bluetoothGatt; + this.gattStatus = gattStatus; + } + + public int getGattStatus() { + return gattStatus; + } + + public ConnectException setGattStatus(int gattStatus) { + this.gattStatus = gattStatus; + return this; + } + + public BluetoothGatt getBluetoothGatt() { + return bluetoothGatt; + } + + public ConnectException setBluetoothGatt(BluetoothGatt bluetoothGatt) { + this.bluetoothGatt = bluetoothGatt; + return this; + } + + @Override + public String toString() { + return "ConnectException{" + + "gattStatus=" + gattStatus + + ", bluetoothGatt=" + bluetoothGatt + + "} " + super.toString(); + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java new file mode 100644 index 0000000..06b672b --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java @@ -0,0 +1,11 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +/** + * Created by sunboss on 2016-12-11. + */ + +public class TimeoutException extends BleException { + public TimeoutException() { + super(ERROR_CODE_TIMEOUT, "Timeout Exception Occurred! "); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java deleted file mode 100644 index 04d98f4..0000000 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLEBridge.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.umarbhutta.xlightcompanion.SDK; - -/** - * Created by sunboss on 2016-11-16. - */ - -@SuppressWarnings({"UnusedDeclaration"}) -public class BLEBridge extends BaseBridge { - // misc - private static final String TAG = BLEBridge.class.getSimpleName(); - private boolean m_bPaired = false; - - public BLEBridge() { - super(); - setName(TAG); - } - - public boolean PairDevice(final String key) { - // ToDo: pair with SmartController - m_bPaired = true; - return m_bPaired; - } - - public boolean isPaired() { - return m_bPaired; - } - - public boolean connectController(final String key) { - // ToDo: connect SmartController via BLE - setConnect(true); - return isConnected(); - } -} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java similarity index 99% rename from app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java index 36e787f..649f866 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java @@ -1,12 +1,14 @@ -package com.umarbhutta.xlightcompanion.SDK; +package com.umarbhutta.xlightcompanion.SDK.Cloud; import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.Message; import android.util.Log; +import com.umarbhutta.xlightcompanion.SDK.BaseBridge; +import com.umarbhutta.xlightcompanion.SDK.xltDevice; + import java.io.IOException; import java.util.ArrayList; diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/ParticleAdapter.java similarity index 96% rename from app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleAdapter.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/ParticleAdapter.java index fa9ce24..1bfb6a4 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/ParticleAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/ParticleAdapter.java @@ -1,7 +1,9 @@ -package com.umarbhutta.xlightcompanion.SDK; +package com.umarbhutta.xlightcompanion.SDK.Cloud; import android.content.Context; +import com.umarbhutta.xlightcompanion.SDK.CloudAccount; + import java.util.ArrayList; import java.util.List; diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/LAN/LANBridge.java similarity index 82% rename from app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/LAN/LANBridge.java index 6cb7ea7..4d22984 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/lanBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/LAN/LANBridge.java @@ -1,4 +1,6 @@ -package com.umarbhutta.xlightcompanion.SDK; +package com.umarbhutta.xlightcompanion.SDK.LAN; + +import com.umarbhutta.xlightcompanion.SDK.BaseBridge; /** * Created by sunboss on 2016-11-16. diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index a6256ae..f45e27e 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -6,6 +6,12 @@ import android.os.Message; import android.os.SystemClock; +import com.umarbhutta.xlightcompanion.SDK.BLE.BLEAdapter; +import com.umarbhutta.xlightcompanion.SDK.BLE.BLEBridge; +import com.umarbhutta.xlightcompanion.SDK.Cloud.CloudBridge; +import com.umarbhutta.xlightcompanion.SDK.Cloud.ParticleAdapter; +import com.umarbhutta.xlightcompanion.SDK.LAN.LANBridge; + import java.util.ArrayList; /** @@ -215,10 +221,11 @@ public void Init(Context context) { // Connect to message bridges public boolean Connect(final String controllerID) { - // ToDo: get devID & devName by controllerID from DMI + // ToDo: get devID, devName & devBLEName by controllerID from DMI m_ControllerID = controllerID; setDeviceID(DEFAULT_DEVICE_ID); - //setDeviceName(); + //setDeviceName(devName); + //bleBridge.setName(devBLEName); // Connect to Cloud ConnectCloud(); @@ -255,7 +262,7 @@ public void run() { } public boolean ConnectBLE() { - return(bleBridge.connectController("8888")); + return(bleBridge.connectController()); } public boolean ConnectLAN() { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 8f87e49..90b391a 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -3,7 +3,6 @@ import android.bluetooth.BluetoothAdapter; import android.content.Intent; import android.os.Bundle; -import android.os.Handler; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentTransaction; import android.support.design.widget.NavigationView; @@ -16,7 +15,7 @@ import android.view.MenuItem; import com.umarbhutta.xlightcompanion.R; -import com.umarbhutta.xlightcompanion.SDK.BLEAdapter; +import com.umarbhutta.xlightcompanion.SDK.BLE.BLEAdapter; import com.umarbhutta.xlightcompanion.SDK.CloudAccount; import com.umarbhutta.xlightcompanion.control.ControlFragment; import com.umarbhutta.xlightcompanion.glance.GlanceFragment; From 456eac86361d9a31849b783981af9dffc8b8907f Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sun, 11 Dec 2016 22:32:23 -0500 Subject: [PATCH 10/25] BLE bridge disconnect --- .../xlightcompanion/SDK/BLE/BLEBridge.java | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java index 30d271d..9c0742e 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java @@ -9,6 +9,8 @@ import com.umarbhutta.xlightcompanion.SDK.BaseBridge; +import java.lang.reflect.Method; + import static android.bluetooth.BluetoothDevice.BOND_BONDED; /** @@ -20,10 +22,11 @@ public class BLEBridge extends BaseBridge { private static final String TAG = BLEBridge.class.getSimpleName(); public static final int STATE_DISCONNECTED = 0; - public static final int STATE_SCANNING = 1; - public static final int STATE_CONNECTING = 2; - public static final int STATE_CONNECTED = 3; - public static final int STATE_SERVICES_DISCOVERED = 4; + public static final int STATE_CONNECTING = 1; + public static final int STATE_CONNECTED = 2; + public static final int STATE_DISCONNECTING = 3; + public static final int STATE_SCANNING = 5; + public static final int STATE_SERVICES_DISCOVERED = 6; private boolean m_bPaired = false; private BluetoothDevice m_bleDevice; @@ -56,6 +59,35 @@ public boolean connectController() { return false; } + public boolean refreshDeviceCache() { + try { + final Method refresh = BluetoothGatt.class.getMethod("refresh"); + if (refresh != null) { + final boolean success = (Boolean) refresh.invoke(m_bleGatt); + Log.i(TAG, "Refreshing result: " + success); + return success; + } + } catch (Exception e) { + Log.e(TAG, "An exception occured while refreshing device", e); + } + return false; + } + + public void closeBluetoothGatt() { + if (m_bleGatt != null) { + m_bleGatt.disconnect(); + } + + if (m_bleGatt != null) { + refreshDeviceCache(); + } + + if (m_bleGatt != null) { + m_bleGatt.close(); + m_bleGatt = null; + } + } + @Override public void setName(final String name) { super.setName(name); @@ -92,6 +124,18 @@ public boolean isServiceDiscovered() { return connectionState == STATE_SERVICES_DISCOVERED; } + /** + * return + * {@link #STATE_DISCONNECTED} + * {@link #STATE_SCANNING} + * {@link #STATE_CONNECTING} + * {@link #STATE_CONNECTED} + * {@link #STATE_SERVICES_DISCOVERED} + */ + public int getConnectionState() { + return connectionState; + } + private BleGattCallback coreGattCallback = new BleGattCallback() { @Override @@ -117,7 +161,7 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState + '\n' + "newState: " + newState + '\n' + "thread: " + Thread.currentThread().getId()); - if (newState == STATE_CONNECTED) { + if (newState == BluetoothGatt.STATE_CONNECTED) { connectionState = STATE_CONNECTED; onConnectSuccess(gatt, status); @@ -125,7 +169,7 @@ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState connectionState = STATE_DISCONNECTED; onConnectFailure(new ConnectException(gatt, status)); - } else if (newState == STATE_CONNECTING) { + } else if (newState == BluetoothGatt.STATE_CONNECTING) { connectionState = STATE_CONNECTING; } } From c6937250e65e6a516339a3e5014b1a1c9df369a3 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Tue, 13 Dec 2016 12:29:22 -0500 Subject: [PATCH 11/25] PowerSwitch supports toggle parameter --- .idea/misc.xml | 2 +- .../com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java | 4 ++-- .../java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java | 2 +- .../umarbhutta/xlightcompanion/control/ControlFragment.java | 2 +- .../xlightcompanion/control/DevicesListAdapter.java | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index fbb6828..5d19981 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -37,7 +37,7 @@ - + diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java index 649f866..5c4cc81 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java @@ -353,12 +353,12 @@ public void run() { return resultCode; } - public int FastCallPowerSwitch(final boolean state) { + public int FastCallPowerSwitch(final int state) { new Thread() { @Override public void run() { // Make the Particle call here - String strParam = String.format("%d:%d", getNodeID(), state ? xltDevice.STATE_ON : xltDevice.STATE_OFF); + String strParam = String.format("%d:%d", getNodeID(), state); ArrayList message = new ArrayList<>(); message.add(strParam); try { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index f45e27e..c5d3ddb 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -625,7 +625,7 @@ public int QueryStatus() { } // Turn On / Off - public int PowerSwitch(final boolean state) { + public int PowerSwitch(final int state) { int rc = -1; // Select Bridge diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index f9b5d71..5efa523 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -161,7 +161,7 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { state = isChecked; //ParticleAdapter.JSONCommandPower(ParticleAdapter.DEFAULT_DEVICE_ID, state); //ParticleAdapter.FastCallPowerSwitch(ParticleAdapter.DEFAULT_DEVICE_ID, state); - MainActivity.m_mainDevice.PowerSwitch(state); + MainActivity.m_mainDevice.PowerSwitch(state ? xltDevice.STATE_ON : xltDevice.STATE_OFF); } }); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java index 9a16ab6..c24a55c 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java @@ -93,7 +93,7 @@ public DevicesListViewHolder(View itemView) { mDeviceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener(){ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { //ParticleAdapter.FastCallPowerSwitch(ParticleAdapter.DEFAULT_DEVICE_ID, isChecked); - MainActivity.m_mainDevice.PowerSwitch(isChecked); + MainActivity.m_mainDevice.PowerSwitch(isChecked ? xltDevice.STATE_ON : xltDevice.STATE_OFF); } }); } From 885e7eb280a51d40b742601489a1950fd0cfbd73 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Fri, 16 Dec 2016 20:56:14 -0500 Subject: [PATCH 12/25] select bridge according to priority --- .../xlightcompanion/SDK/BLE/BLEAdapter.java | 5 ++-- .../xlightcompanion/SDK/BaseBridge.java | 2 +- .../xlightcompanion/SDK/xltDevice.java | 24 +++++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java index 9e6a8f2..da635f7 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java @@ -18,8 +18,9 @@ public class BLEAdapter { // misc private static final String TAG = BLEAdapter.class.getSimpleName(); public static final int REQUEST_ENABLE_BT = 1010; - private static final String XLIGHT_BLE_NAME_PREFIX = "XLIGHT"; - private static final int XLIGHT_BLE_CLASS = 0; + private static final String XLIGHT_BLE_NAME_PREFIX = "Xlight"; + //private static final int XLIGHT_BLE_CLASS = 0x9A050C; // default value for HC-06 is 0x1F00 + private static final int XLIGHT_BLE_CLASS = 0x1F00; // default value for HC-06 is 0x1F00 private static boolean m_bInitialized = false; private static BluetoothAdapter m_btAdapter; diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java index 38ae8b3..1978a9e 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java @@ -12,7 +12,7 @@ public class BaseBridge { private boolean m_bConnected = false; private int m_nodeID; private String m_Name = "Unknown bridge"; - private int m_priority = 5; + private int m_priority = 5; // the bigger, the higher protected Context m_parentContext = null; protected xltDevice m_parentDevice = null; diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index c5d3ddb..db20529 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -217,6 +217,11 @@ public void Init(Context context) { // Set me as the parent device setParentDevice(); + + // Set priority for each bridge - the bigger, the higher + cldBridge.setPriority(6); + bleBridge.setPriority(9); + lanBridge.setPriority(3); } // Connect to message bridges @@ -560,18 +565,27 @@ public void setBridgePriority(final BridgeType bridge, final int priority) { } private BridgeType selectBridge() { - // ToDo: develop an algorithm to select proper bridge /// Use current bridge as long as available if( isBridgeOK(m_currentBridge) ) return m_currentBridge; if( getAutoBridge() ) { - if (isCloudOK()) { + int maxPri = 0; + if (isCloudOK() && cldBridge.getPriority() > maxPri) { m_currentBridge = BridgeType.Cloud; - } else if (isBLEOK()) { + maxPri = cldBridge.getPriority(); + } + + if (isBLEOK() && bleBridge.getPriority() > maxPri) { m_currentBridge = BridgeType.BLE; - } else if (isLANOK()) { + maxPri = bleBridge.getPriority(); + } + + if (isLANOK() && lanBridge.getPriority() > maxPri) { m_currentBridge = BridgeType.LAN; - } else { + maxPri = lanBridge.getPriority(); + } + + if (maxPri == 0) { m_currentBridge = BridgeType.NONE; } } From b68c791660803a5dffb1cf67cbacb8db25cdd7e7 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sat, 18 Mar 2017 02:03:18 -0400 Subject: [PATCH 13/25] SDK supports multi-node under a device(controller) --- app/build.gradle | 2 +- .../xlightcompanion/SDK/BLE/BLEAdapter.java | 2 +- .../xlightcompanion/SDK/BaseBridge.java | 11 +- .../SDK/Cloud/CloudBridge.java | 72 +++- .../xlightcompanion/SDK/SerialMessage.java | 149 +++++++ .../xlightcompanion/SDK/xltDevice.java | 398 +++++++++++++----- .../control/ControlFragment.java | 165 +++++++- .../control/DevicesListAdapter.java | 95 ++++- .../glance/GlanceFragment.java | 16 +- .../glance/WeatherDetails.java | 9 + .../xlightcompanion/main/MainActivity.java | 27 +- .../scenario/ScenarioFragment.java | 4 +- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 4 +- 14 files changed, 779 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/SerialMessage.java diff --git a/app/build.gradle b/app/build.gradle index 336f42b..2e3add3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.application' android { compileSdkVersion 23 - buildToolsVersion "23.0.3" + buildToolsVersion '25.0.0' defaultConfig { applicationId "com.umarbhutta.xlightcompanion" minSdkVersion 19 diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java index da635f7..036e05c 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java @@ -18,7 +18,7 @@ public class BLEAdapter { // misc private static final String TAG = BLEAdapter.class.getSimpleName(); public static final int REQUEST_ENABLE_BT = 1010; - private static final String XLIGHT_BLE_NAME_PREFIX = "Xlight"; + public static final String XLIGHT_BLE_NAME_PREFIX = "Xlight"; //private static final int XLIGHT_BLE_CLASS = 0x9A050C; // default value for HC-06 is 0x1F00 private static final int XLIGHT_BLE_CLASS = 0x1F00; // default value for HC-06 is 0x1F00 diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java index 1978a9e..9472c76 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BaseBridge.java @@ -10,7 +10,6 @@ // Base Class for Bridges public class BaseBridge { private boolean m_bConnected = false; - private int m_nodeID; private String m_Name = "Unknown bridge"; private int m_priority = 5; // the bigger, the higher protected Context m_parentContext = null; @@ -24,12 +23,12 @@ public void setConnect(final boolean connected) { m_bConnected = connected; } - public void setNodeID(final int nodeID) { - m_nodeID = nodeID; - } - public int getNodeID() { - return m_nodeID; + if( m_parentDevice != null ) { + return m_parentDevice.getDeviceID(); + } else { + return xltDevice.DEFAULT_DEVICE_ID; + } } public String getName() { diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java index 5c4cc81..a26a40f 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java @@ -437,19 +437,41 @@ private void InformActivities(final String eventName, final String dataPayload) if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { if (jObject.has("nd")) { int nodeId = jObject.getInt("nd"); - if (nodeId == m_parentDevice.getDeviceID()) { + int ringId = xltDevice.RING_ID_ALL; + if (nodeId == m_parentDevice.getDeviceID() || m_parentDevice.findNodeFromDeviceList(nodeId) >= 0) { Bundle bdlControl = new Bundle(); + bdlControl.putInt("nd", nodeId); + if (jObject.has("Ring")) { + ringId = jObject.getInt("Ring"); + } + bdlControl.putInt("Ring", ringId); if (jObject.has("State")) { - m_parentDevice.setState(jObject.getInt("State")); - bdlControl.putInt("State", m_parentDevice.getState()); + m_parentDevice.setState(nodeId, jObject.getInt("State")); + bdlControl.putInt("State", jObject.getInt("State")); } if (jObject.has("BR")) { - m_parentDevice.setBrightness(jObject.getInt("BR")); - bdlControl.putInt("BR", m_parentDevice.getBrightness()); + m_parentDevice.setBrightness(nodeId, jObject.getInt("BR")); + bdlControl.putInt("BR", jObject.getInt("BR")); } if (jObject.has("CCT")) { - m_parentDevice.setCCT(jObject.getInt("CCT")); - bdlControl.putInt("CCT", m_parentDevice.getCCT()); + m_parentDevice.setCCT(nodeId, jObject.getInt("CCT")); + bdlControl.putInt("CCT", jObject.getInt("CCT")); + } + if (jObject.has("W")) { + m_parentDevice.setWhite(nodeId, ringId, jObject.getInt("W")); + bdlControl.putInt("W", jObject.getInt("W")); + } + if (jObject.has("R")) { + m_parentDevice.setRed(nodeId, ringId, jObject.getInt("R")); + bdlControl.putInt("R", jObject.getInt("R")); + } + if (jObject.has("G")) { + m_parentDevice.setGreen(nodeId, ringId, jObject.getInt("G")); + bdlControl.putInt("G", jObject.getInt("G")); + } + if (jObject.has("B")) { + m_parentDevice.setBlue(nodeId, ringId, jObject.getInt("B")); + bdlControl.putInt("B", jObject.getInt("B")); } m_parentDevice.sendDeviceStatusMessage(bdlControl); } @@ -473,20 +495,38 @@ private void InformActivities(final String eventName, final String dataPayload) // Demo Option: use broadcast & receivers to publish events private void BroadcastEvent(final String eventName, String dataPayload) { + int nodeId = -1; + try { JSONObject jObject = new JSONObject(dataPayload); if (jObject.has("nd")) { - int nodeId = jObject.getInt("nd"); - // ToDO: search device - if (nodeId == m_parentDevice.getDeviceID()) { + nodeId = jObject.getInt("nd"); + int ringId = xltDevice.RING_ID_ALL; + // search device + if (nodeId == m_parentDevice.getDeviceID() || m_parentDevice.findNodeFromDeviceList(nodeId) >= 0) { + if (jObject.has("Ring")) { + ringId = jObject.getInt("Ring"); + } if (jObject.has("State")) { - m_parentDevice.setState(jObject.getInt("State")); + m_parentDevice.setState(nodeId, jObject.getInt("State")); } if (jObject.has("BR")) { - m_parentDevice.setBrightness(jObject.getInt("BR")); + m_parentDevice.setBrightness(nodeId, jObject.getInt("BR")); } if (jObject.has("CCT")) { - m_parentDevice.setCCT(jObject.getInt("CCT")); + m_parentDevice.setCCT(nodeId, jObject.getInt("CCT")); + } + if (jObject.has("W")) { + m_parentDevice.setWhite(nodeId, ringId, jObject.getInt("W")); + } + if (jObject.has("R")) { + m_parentDevice.setRed(nodeId, ringId, jObject.getInt("R")); + } + if (jObject.has("G")) { + m_parentDevice.setGreen(nodeId, ringId, jObject.getInt("G")); + } + if (jObject.has("B")) { + m_parentDevice.setBlue(nodeId, ringId, jObject.getInt("B")); } } } @@ -502,7 +542,11 @@ private void BroadcastEvent(final String eventName, String dataPayload) { } if (eventName.equalsIgnoreCase(xltDevice.eventDeviceStatus)) { - m_parentContext.sendBroadcast(new Intent(xltDevice.bciDeviceStatus)); + if( nodeId >= 0 ) { + Intent devStatus = new Intent(xltDevice.bciDeviceStatus); + devStatus.putExtra("nd", nodeId); + m_parentContext.sendBroadcast(devStatus); + } } else if (eventName.equalsIgnoreCase(xltDevice.eventSensorData)) { m_parentContext.sendBroadcast(new Intent(xltDevice.bciSensorData)); } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/SerialMessage.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/SerialMessage.java new file mode 100644 index 0000000..cd1a94d --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/SerialMessage.java @@ -0,0 +1,149 @@ +package com.umarbhutta.xlightcompanion.SDK; + +/** + * Created by sunboss on 2016-12-16. + */ + +// MySensors Serial Protocol, refer to https://www.mysensors.org/download/serial_api_20 +// Message format: +// node-id;child-sensor-id;message-type;ack;sub-type;payload\n +@SuppressWarnings({"UnusedDeclaration"}) +public class SerialMessage { + + //------------------------------------------------------------------------- + // Constants + //------------------------------------------------------------------------- + // Message types + public static final int C_PRESENTATION = 0; + public static final int C_SET = 1; + public static final int C_REQ = 2; + public static final int C_INTERNAL = 3; + public static final int C_STREAM = 4; // For Firmware and other larger chunks of data that need to be divided into pieces. + + // Type of sensor (used when presenting sensors) + public static final int S_DOOR = 0; // Door sensor, V_TRIPPED, V_ARMED + public static final int S_MOTION = 1; // Motion sensor, V_TRIPPED, V_ARMED + public static final int S_SMOKE = 2; // Smoke sensor, V_TRIPPED, V_ARMED + public static final int S_LIGHT = 3; // Binary light or relay, V_STATUS (or V_LIGHT), V_WATT + public static final int S_BINARY = 3; // Binary light or relay, V_STATUS (or V_LIGHT), V_WATT (same as S_LIGHT) + public static final int S_DIMMER = 4; // Dimmable light or fan device, V_STATUS (on/off), V_DIMMER (dimmer level 0-100), V_WATT + public static final int S_COVER = 5; // Blinds or window cover, V_UP, V_DOWN, V_STOP, V_DIMMER (open/close to a percentage) + public static final int S_TEMP = 6; // Temperature sensor, V_TEMP + public static final int S_HUM = 7; // Humidity sensor, V_HUM + public static final int S_BARO = 8; // Barometer sensor, V_PRESSURE, V_FORECAST + public static final int S_WIND = 9; // Wind sensor, V_WIND, V_GUST + public static final int S_RAIN = 10; // Rain sensor, V_RAIN, V_RAINRATE + public static final int S_UV = 11; // Uv sensor, V_UV + public static final int S_WEIGHT = 12; // Personal scale sensor, V_WEIGHT, V_IMPEDANCE + public static final int S_POWER = 13; // Power meter, V_WATT, V_KWH + public static final int S_HEATER = 14; // Header device, V_HVAC_SETPOINT_HEAT, V_HVAC_FLOW_STATE, V_TEMP + public static final int S_DISTANCE = 15; // Distance sensor, V_DISTANCE + public static final int S_LIGHT_LEVEL = 16; // Light level sensor, V_LIGHT_LEVEL (uncalibrated in percentage), V_LEVEL (light level in lux) + public static final int S_ARDUINO_NODE = 17; // Used (internally) for presenting a non-repeating Arduino node + public static final int S_ARDUINO_REPEATER_NODE = 18; // Used (internally) for presenting a repeating Arduino node + public static final int S_LOCK = 19; // Lock device, V_LOCK_STATUS + public static final int S_IR = 20; // Ir device, V_IR_SEND, V_IR_RECEIVE + public static final int S_WATER = 21; // Water meter, V_FLOW, V_VOLUME + public static final int S_AIR_QUALITY = 22; // Air quality sensor, V_LEVEL + public static final int S_CUSTOM = 23; // Custom sensor + public static final int S_DUST = 24; // Dust sensor, V_LEVEL + public static final int S_SCENE_CONTROLLER = 25; // Scene controller device, V_SCENE_ON, V_SCENE_OFF. + public static final int S_RGB_LIGHT = 26; // RGB light. Send color component data using V_RGB. Also supports V_WATT + public static final int S_RGBW_LIGHT = 27; // RGB light with an additional White component. Send data using V_RGBW. Also supports V_WATT + public static final int S_COLOR_SENSOR = 28; // Color sensor, send color information using V_RGB + public static final int S_HVAC = 29; // Thermostat/HVAC device. V_HVAC_SETPOINT_HEAT, V_HVAC_SETPOINT_COLD, V_HVAC_FLOW_STATE, V_HVAC_FLOW_MODE, V_TEMP + public static final int S_MULTIMETER = 30; // Multimeter device, V_VOLTAGE, V_CURRENT, V_IMPEDANCE + public static final int S_SPRINKLER = 31; // Sprinkler, V_STATUS (turn on/off), V_TRIPPED (if fire detecting device) + public static final int S_WATER_LEAK = 32; // Water leak sensor, V_TRIPPED, V_ARMED + public static final int S_SOUND = 33; // Sound sensor, V_TRIPPED, V_ARMED, V_LEVEL (sound level in dB) + public static final int S_VIBRATION = 34; // Vibration sensor, V_TRIPPED, V_ARMED, V_LEVEL (vibration in Hz) + public static final int S_MOISTURE = 35; // Moisture sensor, V_TRIPPED, V_ARMED, V_LEVEL (water content or moisture in percentage?) + + // Type of sensor data (for set/req/ack messages) + public static final int V_TEMP = 0; // S_TEMP. Temperature S_TEMP, S_HEATER, S_HVAC + public static final int V_HUM = 1; // S_HUM. Humidity + public static final int V_STATUS = 2; // S_LIGHT, S_DIMMER, S_SPRINKLER, S_HVAC, S_HEATER. Used for setting/reporting binary (on/off) status. 1=on, 0=off + public static final int V_LIGHT = 2; // Same as V_STATUS + public static final int V_PERCENTAGE = 3; // S_DIMMER. Used for sending a percentage value 0-100 (%). + public static final int V_DIMMER = 3; // S_DIMMER. Same as V_PERCENTAGE. + public static final int V_PRESSURE = 4; // S_BARO. Atmospheric Pressure + public static final int V_FORECAST = 5; // S_BARO. Whether forecast. string of "stable", "sunny", "cloudy", "unstable", "thunderstorm" or "unknown" + public static final int V_RAIN = 6; // S_RAIN. Amount of rain + public static final int V_RAINRATE = 7; // S_RAIN. Rate of rain + public static final int V_WIND = 8; // S_WIND. Wind speed + public static final int V_GUST = 9; // S_WIND. Gust + public static final int V_DIRECTION = 10; // S_WIND. Wind direction 0-360 (degrees) + public static final int V_UV = 11; // S_UV. UV light level + public static final int V_WEIGHT = 12; // S_WEIGHT. Weight(for scales etc) + public static final int V_DISTANCE = 13; // S_DISTANCE. Distance + public static final int V_IMPEDANCE = 14; // S_MULTIMETER, S_WEIGHT. Impedance value + public static final int V_ARMED = 15; // S_DOOR, S_MOTION, S_SMOKE, S_SPRINKLER. Armed status of a security sensor. 1 = Armed, 0 = Bypassed + public static final int V_TRIPPED = 16; // S_DOOR, S_MOTION, S_SMOKE, S_SPRINKLER, S_WATER_LEAK, S_SOUND, S_VIBRATION, S_MOISTURE. Tripped status of a security sensor. 1 = Tripped, 0 + public static final int V_WATT = 17; // S_POWER, S_LIGHT, S_DIMMER, S_RGB, S_RGBW. Watt value for power meters + public static final int V_KWH = 18; // S_POWER. Accumulated number of KWH for a power meter + public static final int V_SCENE_ON = 19; // S_SCENE_CONTROLLER. Turn on a scene + public static final int V_SCENE_OFF = 20; // S_SCENE_CONTROLLER. Turn of a scene + public static final int V_HEATER = 21; // Deprecated. Use V_HVAC_FLOW_STATE instead. + public static final int V_HVAC_FLOW_STATE = 21; // S_HEATER, S_HVAC. HVAC flow state ("Off", "HeatOn", "CoolOn", or "AutoChangeOver") + public static final int V_HVAC_SPEED = 22; // S_HVAC, S_HEATER. HVAC/Heater fan speed ("Min", "Normal", "Max", "Auto") + public static final int V_LIGHT_LEVEL = 23; // S_LIGHT_LEVEL. Uncalibrated light level. 0-100%. Use V_LEVEL for light level in lux + public static final int V_VAR1 = 24; + public static final int V_VAR2 = 25; + public static final int V_VAR3 = 26; + public static final int V_VAR4 = 27; + public static final int V_VAR5 = 28; + public static final int V_UP = 29; // S_COVER. Window covering. Up + public static final int V_DOWN = 30; // S_COVER. Window covering. Down + public static final int V_STOP = 31; // S_COVER. Window covering. Stop + public static final int V_IR_SEND = 32; // S_IR. Send out an IR-command + public static final int V_IR_RECEIVE = 33; // S_IR. This message contains a received IR-command + public static final int V_FLOW = 34; // S_WATER. Flow of water (in meter) + public static final int V_VOLUME = 35; // S_WATER. Water volume + public static final int V_LOCK_STATUS = 36; // S_LOCK. Set or get lock status. 1=Locked, 0=Unlocked + public static final int V_LEVEL = 37; // S_DUST, S_AIR_QUALITY, S_SOUND (dB), S_VIBRATION (hz), S_LIGHT_LEVEL (lux) + public static final int V_VOLTAGE = 38; // S_MULTIMETER + public static final int V_CURRENT = 39; // S_MULTIMETER + public static final int V_RGB = 40; // S_RGB_LIGHT, S_COLOR_SENSOR. + // Used for sending color information for multi color LED lighting or color sensors. + // Sent as ASCII hex: RRGGBB (RR=red, GG=green, BB=blue component) + public static final int V_RGBW = 41; // S_RGBW_LIGHT + // Used for sending color information to multi color LED lighting. + // Sent as ASCII hex: RRGGBBWW (WW=white component) + public static final int V_ID = 42; // S_TEMP + // Used for sending in sensors hardware ids (i.e. OneWire DS1820b). + public static final int V_UNIT_PREFIX = 43; // S_DUST, S_AIR_QUALITY + // Allows sensors to send in a string representing the + // unit prefix to be displayed in GUI, not parsed by controller! E.g. cm, m, km, inch. + // Can be used for S_DISTANCE or gas concentration + public static final int V_HVAC_SETPOINT_COOL = 44; // S_HVAC. HVAC cool setpoint (Integer between 0-100) + public static final int V_HVAC_SETPOINT_HEAT = 45; // S_HEATER, S_HVAC. HVAC/Heater setpoint (Integer between 0-100) + public static final int V_HVAC_FLOW_MODE = 46; // S_HVAC. Flow mode for HVAC ("Auto", "ContinuousOn", "PeriodicOn") + + // Type of internal messages (for internal messages) + public static final int I_BATTERY_LEVEL = 0; + public static final int I_TIME = 1; + public static final int I_VERSION = 2; + public static final int I_ID_REQUEST = 3; + public static final int I_ID_RESPONSE = 4; + public static final int I_INCLUSION_MODE = 5; + public static final int I_CONFIG = 6; + public static final int I_FIND_PARENT = 7; + public static final int I_FIND_PARENT_RESPONSE = 8; + public static final int I_LOG_MESSAGE = 9; + public static final int I_CHILDREN = 10; + public static final int I_SKETCH_NAME = 11; + public static final int I_SKETCH_VERSION = 12; + public static final int I_REBOOT = 13; + public static final int I_GATEWAY_READY = 14; + public static final int I_REQUEST_SIGNING = 15; + public static final int I_GET_NONCE = 16; + public static final int I_GET_NONCE_RESPONSE = 17; + + // Type of data stream (for streamed message) + public static final int ST_FIRMWARE_CONFIG_REQUEST = 0; + public static final int ST_FIRMWARE_CONFIG_RESPONSE = 1; + public static final int ST_FIRMWARE_REQUEST = 2; + public static final int ST_FIRMWARE_RESPONSE = 3; + public static final int ST_SOUND = 4; + public static final int ST_IMAGE = 5; +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index db20529..476346c 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -14,6 +14,8 @@ import java.util.ArrayList; +import static com.umarbhutta.xlightcompanion.SDK.BLE.BLEAdapter.XLIGHT_BLE_NAME_PREFIX; + /** * Created by sunboss on 2016-11-15. * @@ -33,6 +35,9 @@ public class xltDevice { //------------------------------------------------------------------------- private static final String TAG = xltDevice.class.getSimpleName(); public static final int DEFAULT_DEVICE_ID = 1; + public static final String DEFAULT_DEVICE_NAME = ""; + public static final String DEFAULT_DEVICE_BLENAME = XLIGHT_BLE_NAME_PREFIX + DEFAULT_DEVICE_NAME; + // on/off values public static final int STATE_OFF = 0; @@ -91,6 +96,8 @@ public class xltDevice { public static final int devtypMRing1 = 10; public static final int devtypDummy = 255; + public static final int DEFAULT_DEVICE_TYPE = devtypWRing3; + public enum BridgeType { NONE, Cloud, @@ -106,9 +113,12 @@ public class xltRing { public int m_State = 0; public int m_Brightness = 50; public int m_CCT = CT_MIN_VALUE; - public int m_R = 128; - public int m_G = 128; + public int m_R = 0; + public int m_G = 0; public int m_B = 0; + public int m_String1 = 50; + public int m_String2 = 50; + public int m_String3 = 50; public boolean isSameColor(final xltRing that) { if( this.m_State != that.m_State ) return false; @@ -133,11 +143,23 @@ public boolean isSameBright(final xltRing that) { public class SensorData { public float m_RoomTemp = 24; // Room temperature public int m_RoomHumidity = 40; // Room humidity + public int m_RoomBrightness = 0; // ALS value public float m_OutsideTemp = 23; // Local outside temperature public int m_OutsideHumidity = 30; // Local outside humidity } + //------------------------------------------------------------------------- + // Device / Node under the Controller + //------------------------------------------------------------------------- + public class xltNodeInfo { + public int m_ID = 0; + public int m_Type; + public String m_Name; + // Rings + public xltRing[] m_Ring = new xltRing[MAX_RING_NUM]; + } + //------------------------------------------------------------------------- // Variables //------------------------------------------------------------------------- @@ -145,8 +167,6 @@ public class SensorData { private static boolean m_bInitialized = false; private String m_ControllerID; private int m_DevID = DEFAULT_DEVICE_ID; - private String m_DevName = "Main xlight"; - private int m_DevType = devtypWRing3; // Bridge Objects private CloudBridge cldBridge; @@ -157,8 +177,9 @@ public class SensorData { private BridgeType m_currentBridge = BridgeType.Cloud; private boolean m_autoBridge = true; - // Rings - private xltRing[] m_Ring = new xltRing[MAX_RING_NUM]; + // Device/Node List + private ArrayList m_lstNodes = new ArrayList<>(); + xltNodeInfo m_currentNode = null; // Sensor Data public SensorData m_Data; @@ -179,10 +200,6 @@ public xltDevice() { // Create member objects m_Data= new SensorData(); - for(int i = 0; i < MAX_RING_NUM; i++) { - m_Ring[i] = new xltRing(); - } - cldBridge = new CloudBridge(); bleBridge = new BLEBridge(); lanBridge = new LANBridge(); @@ -193,6 +210,7 @@ public void Init(Context context) { // Clear event handler lists clearDeviceEventHandlerList(); clearDataEventHandlerList(); + clearDeviceList(); // Ensure we do it only once if( !m_bInitialized ) { @@ -226,11 +244,18 @@ public void Init(Context context) { // Connect to message bridges public boolean Connect(final String controllerID) { - // ToDo: get devID, devName & devBLEName by controllerID from DMI + // ToDo: get device (node) list: devID, devType, devName & devBLEName by controllerID from DMI + // If DMI cannot communicate to the Cloud, return the most recent values in cookie, + // If there is no cookie, return default values. m_ControllerID = controllerID; - setDeviceID(DEFAULT_DEVICE_ID); - //setDeviceName(devName); + // ToDo: Add device/node list + if( m_lstNodes.size() <= 0 ) { + // Easy for testing + addNodeToDeviceList(DEFAULT_DEVICE_ID, DEFAULT_DEVICE_TYPE, DEFAULT_DEVICE_NAME); + } + //... //bleBridge.setName(devBLEName); + bleBridge.setName(DEFAULT_DEVICE_BLENAME); // Connect to Cloud ConnectCloud(); @@ -275,6 +300,18 @@ public boolean ConnectLAN() { return(lanBridge.connectController("192.168.0.114", 5555)); } + public boolean isSunny() { + return(m_currentNode != null ? isSunny(m_currentNode.m_Type) : false); + } + + public boolean isRainbow() { + return(m_currentNode != null ? isRainbow(m_currentNode.m_Type) : false); + } + + public boolean isMirage() { + return(m_currentNode != null ? isMirage(m_currentNode.m_Type) : false); + } + public boolean isSunny(final int DevType) { return(DevType >= devtypWRing3 && DevType <= devtypWRing1); } @@ -298,15 +335,59 @@ public String getControllerID() { return m_ControllerID; } + public int addNodeToDeviceList(final int devID, final int devType, final String devName) { + xltNodeInfo lv_node = new xltNodeInfo(); + lv_node.m_ID = devID; + lv_node.m_Type = devType; + lv_node.m_Name = devName; + for(int i = 0; i < MAX_RING_NUM; i++) { + lv_node.m_Ring[i] = new xltRing(); + } + m_lstNodes.add(lv_node); + if( m_currentNode == null ) { + m_currentNode = lv_node; + m_DevID = devID; + } + return m_lstNodes.size(); + } + + public int findNodeFromDeviceList(final int devID) { + for (xltNodeInfo lv_node : m_lstNodes) { + if( lv_node.m_ID == devID ) { + return m_lstNodes.indexOf(lv_node); + } + } + return -1; + } + + public boolean removeNodeFromDeviceList(final int devID) { + int lv_index = findNodeFromDeviceList(devID); + if( lv_index >= 0 ) { + if( m_currentNode != null ) { + if( m_currentNode.m_ID == devID ) m_currentNode = null; + } + m_lstNodes.remove(lv_index); + return true; + } + return false; + } + + public void clearDeviceList() { + m_currentNode = null; + m_lstNodes.clear(); + } + public int getDeviceID() { return m_DevID; } + // Change current device / node public void setDeviceID(final int devID) { m_DevID = devID; - cldBridge.setNodeID(devID); - bleBridge.setNodeID(devID); - lanBridge.setNodeID(devID); + int lv_index = findNodeFromDeviceList(devID); + if( lv_index >= 0 ) { + m_currentNode = m_lstNodes.get(lv_index); + } } private void setParentContext(Context context) { @@ -322,186 +403,283 @@ private void setParentDevice() { } public int getDeviceType() { - return m_DevType; - } - - public void setDeviceType(final int devType) { - m_DevType = devType; + return(m_currentNode != null ? m_currentNode.m_Type : devtypDummy); } public String getDeviceName() { - return m_DevName; + return(m_currentNode != null ? m_currentNode.m_Name : ""); } - public void setDeviceName(final String devName) { - m_DevName = devName; + public int getState() { + return(getState(m_DevID)); } - public int getState() { - return(getState(RING_ID_ALL)); + public int getState(final int nodeID) { + return(getState(nodeID, RING_ID_ALL)); } - public int getState(final int ringID) { + public int getState(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_State); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_Ring[index].m_State; + } + return(-1); } public void setState(final int state) { - setState(RING_ID_ALL, state); + setState(m_DevID, state); } - public void setState(final int ringID, final int state) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_State = state; - m_Ring[1].m_State = state; - m_Ring[2].m_State = state; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_State = state; + public void setState(final int nodeID, final int state) { + setState(nodeID, RING_ID_ALL, state); + } + + public void setState(final int nodeID, final int ringID, final int state) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_State = state; + m_lstNodes.get(lv_dev).m_Ring[1].m_State = state; + m_lstNodes.get(lv_dev).m_Ring[2].m_State = state; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_State = state; + } } } public int getBrightness() { - return(getBrightness(RING_ID_ALL)); + return(getBrightness(m_DevID)); + } + + public int getBrightness(final int nodeID) { + return(getBrightness(nodeID, RING_ID_ALL)); } - public int getBrightness(final int ringID) { + public int getBrightness(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_Brightness); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_Ring[index].m_Brightness; + } + return(-1); } public void setBrightness(final int brightness) { - setBrightness(RING_ID_ALL, brightness); + setBrightness(m_DevID, brightness); + } + + public void setBrightness(final int nodeID, final int brightness) { + setBrightness(nodeID, RING_ID_ALL, brightness); } - public void setBrightness(final int ringID, final int brightness) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_Brightness = brightness; - m_Ring[1].m_Brightness = brightness; - m_Ring[2].m_Brightness = brightness; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_Brightness = brightness; + public void setBrightness(final int nodeID, final int ringID, final int brightness) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_Brightness = brightness; + m_lstNodes.get(lv_dev).m_Ring[1].m_Brightness = brightness; + m_lstNodes.get(lv_dev).m_Ring[2].m_Brightness = brightness; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_Brightness = brightness; + } } } public int getCCT() { - return(getCCT(RING_ID_ALL)); + return(getCCT(m_DevID)); + } + + public int getCCT(final int nodeID) { + return(getCCT(nodeID, RING_ID_ALL)); } - public int getCCT(final int ringID) { + public int getCCT(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_CCT); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return m_lstNodes.get(lv_dev).m_Ring[index].m_CCT; + } + return(-1); } - public void setCCT(final int cct) { - setCCT(RING_ID_ALL, cct); + public void setCCT(final int nodeID, final int cct) { + setCCT(nodeID, RING_ID_ALL, cct); } - public void setCCT(final int ringID, final int cct) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_CCT = cct; - m_Ring[1].m_CCT = cct; - m_Ring[2].m_CCT = cct; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_CCT = cct; + public void setCCT(final int cct) { + setCCT(m_DevID, cct); + } + + public void setCCT(final int nodeID, final int ringID, final int cct) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_CCT = cct; + m_lstNodes.get(lv_dev).m_Ring[1].m_CCT = cct; + m_lstNodes.get(lv_dev).m_Ring[2].m_CCT = cct; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_CCT = cct; + } } } public int getWhite() { - return(getWhite(RING_ID_ALL)); + return(getWhite(m_DevID)); } - public int getWhite(final int ringID) { + public int getWhite(final int nodeID) { + return(getWhite(nodeID, RING_ID_ALL)); + } + + public int getWhite(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_CCT % 256); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return(m_lstNodes.get(lv_dev).m_Ring[index].m_CCT % 256); + } + return(-1); } public void setWhite(final int white) { - setWhite(RING_ID_ALL, white); + setWhite(m_DevID, white); + } + + public void setWhite(final int nodeID, final int white) { + setWhite(nodeID, RING_ID_ALL, white); } - public void setWhite(final int ringID, final int white) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_CCT = white; - m_Ring[1].m_CCT = white; - m_Ring[2].m_CCT = white; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_CCT = white; + public void setWhite(final int nodeID, final int ringID, final int white) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_CCT = white; + m_lstNodes.get(lv_dev).m_Ring[1].m_CCT = white; + m_lstNodes.get(lv_dev).m_Ring[2].m_CCT = white; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_CCT = white; + } } } public int getRed() { - return(getRed(RING_ID_ALL)); + return(getRed(m_DevID)); + } + + public int getRed(final int nodeID) { + return(getRed(nodeID, RING_ID_ALL)); } - public int getRed(final int ringID) { + public int getRed(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_R); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return(m_lstNodes.get(lv_dev).m_Ring[index].m_R); + } + return(-1); } public void setRed(final int red) { - setRed(RING_ID_ALL, red); + setRed(m_DevID, red); + } + + public void setRed(final int nodeID, final int red) { + setRed(nodeID, RING_ID_ALL, red); } - public void setRed(final int ringID, final int red) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_R = red; - m_Ring[1].m_R = red; - m_Ring[2].m_R = red; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_R = red; + public void setRed(final int nodeID, final int ringID, final int red) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_R = red; + m_lstNodes.get(lv_dev).m_Ring[1].m_R = red; + m_lstNodes.get(lv_dev).m_Ring[2].m_R = red; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_R = red; + } } } public int getGreen() { - return(getGreen(RING_ID_ALL)); + return(getGreen(m_DevID)); } - public int getGreen(final int ringID) { + public int getGreen(final int nodeID) { + return(getGreen(nodeID, RING_ID_ALL)); + } + + public int getGreen(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_G); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return(m_lstNodes.get(lv_dev).m_Ring[index].m_G); + } + return(-1); } public void setGreen(final int green) { - setGreen(RING_ID_ALL, green); + setGreen(m_DevID, green); } - public void setGreen(final int ringID, final int green) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_G = green; - m_Ring[1].m_G = green; - m_Ring[2].m_G = green; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_G = green; + public void setGreen(final int nodeID, final int green) { + setGreen(nodeID, RING_ID_ALL, green); + } + + public void setGreen(final int nodeID, final int ringID, final int green) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_G = green; + m_lstNodes.get(lv_dev).m_Ring[1].m_G = green; + m_lstNodes.get(lv_dev).m_Ring[2].m_G = green; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_G = green; + } } } public int getBlue() { - return(getBlue(RING_ID_ALL)); + return(getBlue(m_DevID)); + } + + public int getBlue(final int nodeID) { + return(getBlue(nodeID, RING_ID_ALL)); } - public int getBlue(final int ringID) { + public int getBlue(final int nodeID, final int ringID) { int index = getRingIndex(ringID); - return(m_Ring[index].m_B); + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + return(m_lstNodes.get(lv_dev).m_Ring[index].m_B); + } + return(-1); } public void setBlue(final int blue) { - setBlue(RING_ID_ALL, blue); + setBlue(m_DevID, blue); + } + + public void setBlue(final int nodeID, final int blue) { + setBlue(nodeID, RING_ID_ALL, blue); } - public void setBlue(final int ringID, final int blue) { - if( ringID == RING_ID_ALL ) { - m_Ring[0].m_B = blue; - m_Ring[1].m_B = blue; - m_Ring[2].m_B = blue; - } else { - int index = getRingIndex(ringID); - m_Ring[index].m_B = blue; + public void setBlue(final int nodeID, final int ringID, final int blue) { + int lv_dev = findNodeFromDeviceList(nodeID); + if( lv_dev >= 0 ) { + if (ringID == RING_ID_ALL) { + m_lstNodes.get(lv_dev).m_Ring[0].m_B = blue; + m_lstNodes.get(lv_dev).m_Ring[1].m_B = blue; + m_lstNodes.get(lv_dev).m_Ring[2].m_B = blue; + } else { + int index = getRingIndex(ringID); + m_lstNodes.get(lv_dev).m_Ring[index].m_B = blue; + } } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java index 5efa523..c119c29 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/ControlFragment.java @@ -32,6 +32,9 @@ import com.umarbhutta.xlightcompanion.scenario.ScenarioFragment; import java.util.ArrayList; +import java.util.Random; +import java.util.Timer; +import java.util.TimerTask; import me.priyesh.chroma.ChromaDialog; import me.priyesh.chroma.ColorMode; @@ -67,6 +70,13 @@ public class ControlFragment extends Fragment { private Handler m_handlerControl; + private Timer mTimer = null; + private TimerTask mTimerTask = null; + private static int count = 0; + private boolean isPause = false; + private boolean isStop = true; + private int ran_r = 125, ran_g = 50, ran_b = 0; + private class MyStatusReceiver extends StatusReceiver { @Override public void onReceive(Context context, Intent intent) { @@ -79,6 +89,10 @@ public void onReceive(Context context, Intent intent) { @Override public void onDestroyView() { + if (!isStop) { + stopTimer(); + } + MainActivity.m_mainDevice.removeDeviceEventHandler(m_handlerControl); if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { getContext().unregisterReceiver(m_StatusReceiver); @@ -119,9 +133,6 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa // Apply the scenarioAdapter to the spinner scenarioSpinner.setAdapter(scenarioAdapter); - // Just for demo. In real world, should get from DMI - MainActivity.m_mainDevice.setDeviceName(DEFAULT_LAMP_TEXT); - powerSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); brightnessSeekBar.setProgress(MainActivity.m_mainDevice.getBrightness()); cctSeekBar.setProgress(MainActivity.m_mainDevice.getCCT() - 2700); @@ -152,6 +163,7 @@ public void handleMessage(Message msg) { } }; MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerControl); + updateDeviceRingLabel(); } powerSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @@ -168,22 +180,33 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { colorTextView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + int initColor; + int ringID = xltDevice.RING_ID_ALL; + if( ring1 && !ring2 && !ring3 ) ringID = xltDevice.RING_ID_1; + if( ring2 && !ring1 && !ring3 ) ringID = xltDevice.RING_ID_2; + if( ring3 && !ring1 && !ring2 ) ringID = xltDevice.RING_ID_3; + if( MainActivity.m_mainDevice.getRed(ringID) == 0 && MainActivity.m_mainDevice.getGreen(ringID) == 0 && MainActivity.m_mainDevice.getBlue(ringID) == 0) { + initColor = ContextCompat.getColor(getActivity(), R.color.colorAccent); + } else { + initColor = Color.argb(0xff, MainActivity.m_mainDevice.getRed(ringID), MainActivity.m_mainDevice.getGreen(ringID), MainActivity.m_mainDevice.getBlue(ringID)); + } + Log.e(TAG, "int: " + initColor + " HEX: #" + String.format("%06X", (0xFFFFFF & initColor))); new ChromaDialog.Builder() - .initialColor(ContextCompat.getColor(getActivity(), R.color.colorAccent)) + .initialColor(initColor) .colorMode(ColorMode.RGB) // There's also ARGB and HSV .onColorSelected(new ColorSelectListener() { @Override public void onColorSelected(int color) { - Log.e(TAG, "int: " + color); colorHex = String.format("%06X", (0xFFFFFF & color)); - Log.e(TAG, "HEX: #" + colorHex); + Log.e(TAG, "int: " + color + " HEX: #" + colorHex); - int br = 65; + state = powerSwitch.isChecked(); + int br = brightnessSeekBar.getProgress(); + //int ww = (cctSeekBar.getProgress() / ((6500 - 2700) * 255)); int ww = 0; - int c = (int) Long.parseLong(colorHex, 16); - int r = (c >> 16) & 0xFF; - int g = (c >> 8) & 0xFF; - int b = (c >> 0) & 0xFF; + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 0) & 0xFF; Log.e(TAG, "RGB: " + r + "," + g + "," + b); colorHex = "#" + colorHex; @@ -194,30 +217,71 @@ public void onColorSelected(int color) { if ((ring1 && ring2 && ring3) || (!ring1 && !ring2 && !ring3)) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_ALL, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_ALL, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_ALL, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_ALL, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_ALL, b); } else if (ring1 && ring2) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_1, state, br, ww, r, g, b); //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_2, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_1, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_1, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_1, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_1, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_2, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_2, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_2, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_2, b); } else if (ring2 && ring3) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_2, state, br, ww, r, g, b); //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_3, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_2, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_2, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_2, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_2, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_3, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_3, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_3, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_3, b); + } else if (ring1 && ring3) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_1, state, br, ww, r, g, b); //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_3, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_1, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_1, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_1, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_1, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_3, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_3, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_3, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_3, b); } else if (ring1) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_1, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_1, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_1, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_1, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_1, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_1, b); } else if (ring2) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_2, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_2, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_2, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_2, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_2, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_2, b); } else if (ring3) { //ParticleAdapter.JSONCommandColor(ParticleAdapter.DEFAULT_DEVICE_ID, ParticleAdapter.RING_3, state, br, ww, r, g, b); MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_3, state, br, ww, r, g, b); + MainActivity.m_mainDevice.setWhite(xltDevice.RING_ID_3, ww); + MainActivity.m_mainDevice.setRed(xltDevice.RING_ID_3, r); + MainActivity.m_mainDevice.setGreen(xltDevice.RING_ID_3, g); + MainActivity.m_mainDevice.setBlue(xltDevice.RING_ID_3, b); } else { //do nothing } @@ -258,13 +322,18 @@ public void onStartTrackingTouch(SeekBar seekBar) { public void onStopTrackingTouch(SeekBar seekBar) { Log.d(TAG, "The CCT value is " + seekBar.getProgress()+2700); //ParticleAdapter.JSONCommandCCT(ParticleAdapter.DEFAULT_DEVICE_ID, seekBar.getProgress()+2700); - MainActivity.m_mainDevice.ChangeCCT(seekBar.getProgress()+2700); + MainActivity.m_mainDevice.ChangeCCT(seekBar.getProgress() + 2700); } }); scenarioSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { + + if (!isStop) { + stopTimer(); + } + if (parent.getItemAtPosition(position).toString() == "None") { //scenarioNoneLL.animate().alpha(1).setDuration(600).start(); @@ -279,7 +348,18 @@ public void onItemSelected(AdapterView parent, View view, int position, long //ParticleAdapter.JSONCommandScenario(ParticleAdapter.DEFAULT_DEVICE_ID, position); //position passed into above function corresponds to the scenarioId i.e. s1, s2, s3 to trigger - MainActivity.m_mainDevice.ChangeScenario(position); + //MainActivity.m_mainDevice.ChangeScenario(position); + + // For demonstration + if (parent.getItemAtPosition(position).toString() == "Dinner") { + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 70, 197, 136, 33, 0); + } else if (parent.getItemAtPosition(position).toString() == "Sleep") { + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 10, 26, 254, 52, 0); + } else if (parent.getItemAtPosition(position).toString() == "Dance") { + if (isStop) { + startTimer(); + } + } } } @@ -369,4 +449,63 @@ private void updateDeviceRingLabel() { deviceRingLabel.setText(label); } + + private void startTimer(){ + isStop = false; + if (mTimer == null) { + mTimer = new Timer(); + } + + if (mTimerTask == null) { + mTimerTask = new TimerTask() { + @Override + public void run() { + Log.i(TAG, "count: "+String.valueOf(count)); + + int which; + Random random = new Random(); + do { + try { + which = random.nextInt(3); + if( which == 0 ) { + //r = random.nextInt(256); + ran_r += random.nextInt(60); + ran_r %= 255; + } else if( which == 1 ) { + //g = random.nextInt(256); + ran_g += random.nextInt(45); + ran_g %= 255; + } else { + //b = random.nextInt(256); + ran_b += random.nextInt(36); + ran_b %= 255; + } + MainActivity.m_mainDevice.ChangeColor(xltDevice.RING_ID_ALL, true, 10, 0, ran_r, ran_g, ran_b); + Thread.sleep(2500); + } catch (InterruptedException e) { + } + } while (isPause); + + count ++; + } + }; + } + + if(mTimer != null && mTimerTask != null ) + mTimer.schedule(mTimerTask, 1000, 1000); + + } + + private void stopTimer(){ + if (mTimer != null) { + mTimer.cancel(); + mTimer = null; + } + if (mTimerTask != null) { + mTimerTask.cancel(); + mTimerTask = null; + } + count = 0; + isStop = true; + } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java index c24a55c..d727acc 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/control/DevicesListAdapter.java @@ -3,6 +3,7 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.support.v7.widget.RecyclerView; @@ -24,14 +25,32 @@ public class DevicesListAdapter extends RecyclerView.Adapter { private Handler m_handlerDeviceList; + public Switch[] m_Switch = new Switch[MainActivity.deviceNodeIDs.length]; + + public int findPositionByNodeID(final int _nodeID) { + for (int iSw = 0; iSw < m_Switch.length; iSw++) { + if((Integer)m_Switch[iSw].getTag() == _nodeID) { + return iSw; + } + } + return -1; + } + + public void setSwitchState(final int _nodeID, final int _state) { + int nPos = findPositionByNodeID(_nodeID); + if( nPos >= 0 ) { + m_Switch[nPos].setChecked(_state > 0); + } + } private class MyStatusReceiver extends StatusReceiver { - public Switch m_mainSwitch = null; @Override public void onReceive(Context context, Intent intent) { - if( m_mainSwitch != null ) { - m_mainSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); + int nNodeID = intent.getIntExtra("nd", -1); + if( nNodeID >= 0 ) { + int nState = MainActivity.m_mainDevice.getState(nNodeID); + setSwitchState(nNodeID, nState); } } } @@ -39,6 +58,19 @@ public void onReceive(Context context, Intent intent) { @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { + if( MainActivity.m_mainDevice.getEnableEventSendMessage() ) { + m_handlerDeviceList = new Handler() { + public void handleMessage(Message msg) { + int nNodeID = msg.getData().getInt("nd", -1); + if( nNodeID >= 0 ) { + int nState = msg.getData().getInt("State", -255); + setSwitchState(nNodeID, nState); + } + } + }; + MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerDeviceList); + } + if( MainActivity.m_mainDevice.getEnableEventBroadcast() ) { IntentFilter intentFilter = new IntentFilter(xltDevice.bciDeviceStatus); intentFilter.setPriority(3); @@ -76,47 +108,64 @@ public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { @Override public int getItemCount() { - return 3; + return MainActivity.deviceNodeIDs.length; } private class DevicesListViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { private TextView mDeviceName; private Switch mDeviceSwitch; + private int mDeviceID; public DevicesListViewHolder(View itemView) { super(itemView); mDeviceName = (TextView) itemView.findViewById(R.id.deviceName); mDeviceSwitch = (Switch) itemView.findViewById(R.id.deviceSwitch); - itemView.setOnClickListener(this); + //itemView.setOnClickListener(this); + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // Change Current Device/Node + MainActivity.m_mainDevice.setDeviceID(mDeviceID); + } + }); + + itemView.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + // Change Current Device/Node + MainActivity.m_mainDevice.setDeviceID(mDeviceID); + // Bring to Control Activity + if( MainActivity.m_eventHandler != null ) { + Message msg = MainActivity.m_eventHandler.obtainMessage(); + if( msg != null ) { + Bundle bdlData = new Bundle(); + bdlData.putInt("cmd", 1); // Menu + bdlData.putInt("item", R.id.nav_control); // Item + msg.setData(bdlData); + MainActivity.m_eventHandler.sendMessage(msg); + } + } + return false; + } + }); mDeviceSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener(){ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { //ParticleAdapter.FastCallPowerSwitch(ParticleAdapter.DEFAULT_DEVICE_ID, isChecked); + // Change Current Device/Node + MainActivity.m_mainDevice.setDeviceID(mDeviceID); MainActivity.m_mainDevice.PowerSwitch(isChecked ? xltDevice.STATE_ON : xltDevice.STATE_OFF); } }); } public void bindView (int position) { - mDeviceName.setText(MainActivity.deviceNames[position]); - if (position == 0) { - // Main device - mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); - m_StatusReceiver.m_mainSwitch = mDeviceSwitch; - - if( MainActivity.m_mainDevice.getEnableEventSendMessage() ) { - m_handlerDeviceList = new Handler() { - public void handleMessage(Message msg) { - int intValue = msg.getData().getInt("State", -255); - if (intValue != -255) { - mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState() > 0); - } - } - }; - MainActivity.m_mainDevice.addDeviceEventHandler(m_handlerDeviceList); - } - } + mDeviceID = MainActivity.deviceNodeIDs[position]; + mDeviceName.setText(MainActivity.deviceNames[position] + ": " + mDeviceID); + mDeviceSwitch.setChecked(MainActivity.m_mainDevice.getState(mDeviceID) > 0); + mDeviceSwitch.setTag(mDeviceID); + m_Switch[position] = mDeviceSwitch; } @Override diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java index 8f90bff..57d4155 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java @@ -48,7 +48,7 @@ */ public class GlanceFragment extends Fragment { private com.github.clans.fab.FloatingActionButton fab; - TextView outsideTemp, degreeSymbol, roomTemp, roomHumidity, outsideHumidity, apparentTemp; + TextView txtLocation, outsideTemp, degreeSymbol, roomTemp, roomHumidity, outsideHumidity, apparentTemp; ImageView imgWeather; private static final String TAG = MainActivity.class.getSimpleName(); @@ -87,6 +87,7 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, View view = inflater.inflate(R.layout.fragment_glance, container, false); fab = (com.github.clans.fab.FloatingActionButton) view.findViewById(R.id.fab); + txtLocation = (TextView) view.findViewById(R.id.location); outsideTemp = (TextView) view.findViewById(R.id.outsideTemp); degreeSymbol = (TextView) view.findViewById(R.id.degreeSymbol); outsideHumidity = (TextView) view.findViewById(R.id.valLocalHumidity); @@ -146,8 +147,15 @@ public void handleMessage(Message msg) { //divider lines devicesRecyclerView.addItemDecoration(new SimpleDividerItemDecoration(getActivity())); - double latitude = 43.4643; - double longitude = -80.5204; + // Waterloo + //String strLocation = "Waterloo, ON"; + //double latitude = 43.4643; + //double longitude = -80.5204; + // Suzhou + final String strLocation = "Suzhou, China"; + double latitude = 31.2989; + double longitude = 120.5852; + String forecastUrl = "https://api.forecast.io/forecast/" + CloudAccount.DarkSky_apiKey + "/" + latitude + "," + longitude; if (isNetworkAvailable()) { @@ -171,6 +179,7 @@ public void onResponse(Response response) throws IOException { String jsonData = response.body().string(); if (response.isSuccessful()) { mWeatherDetails = getWeatherDetails(jsonData); + mWeatherDetails.setLocation(strLocation); getActivity().runOnUiThread(new Runnable() { @Override public void run() { @@ -195,6 +204,7 @@ public void run() { private void updateDisplay() { imgWeather.setImageBitmap(getWeatherIcon(mWeatherDetails.getIcon())); + txtLocation.setText(mWeatherDetails.getLocation()); outsideTemp.setText(" " + mWeatherDetails.getTemp("celsius")); degreeSymbol.setText("\u00B0"); outsideHumidity.setText(mWeatherDetails.getmHumidity() + "\u0025"); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java index 548ea8d..acc7c1b 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/WeatherDetails.java @@ -6,6 +6,7 @@ * Created by Umar Bhutta. */ public class WeatherDetails { + private String mLocation; private String mIcon; private double mTempF; private int mTempC; @@ -17,6 +18,14 @@ public WeatherDetails() { super(); } + public String getLocation() { + return mLocation; + } + + public void setLocation(final String location) { + mLocation = location; + } + public String getIcon() { return mIcon; } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 90b391a..6d3490d 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -3,6 +3,8 @@ import android.bluetooth.BluetoothAdapter; import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Message; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentTransaction; import android.support.design.widget.NavigationView; @@ -27,7 +29,8 @@ public class MainActivity extends AppCompatActivity implements NavigationView.OnNavigationItemSelectedListener { //constants for testing lists - public static final String[] deviceNames = {"Living Room", "Bedroom", "Basement Kitchen"}; + public static final String[] deviceNames = {"Living Room", "Bedroom", "Bar"}; + public static final int[] deviceNodeIDs = {1, 10, 11}; public static final String[] scheduleTimes = {"10:30 AM", "12:45 PM", "02:00 PM", "06:45 PM", "08:00 PM", "11:30 PM"}; public static final String[] scheduleDays = {"Mo Tu We Th Fr", "Every day", "Mo We Th Sa Su", "Tomorrow", "We", "Mo Tu Fr Sa Su"}; public static final String[] scenarioNames = {"Brunching", "Guests", "Naptime", "Dinner", "Sunset", "Bedtime"}; @@ -35,6 +38,7 @@ public class MainActivity extends AppCompatActivity public static final String[] filterNames = {"Breathe", "Music Match", "Flash"}; public static xltDevice m_mainDevice; + public static Handler m_eventHandler; @Override protected void onCreate(Bundle savedInstanceState) { @@ -53,6 +57,14 @@ protected void onCreate(Bundle savedInstanceState) { // Initialize SmartDevice SDK m_mainDevice = new xltDevice(); m_mainDevice.Init(this); + + // Setup Device/Node List + for( int lv_idx = 0; lv_idx < 3; lv_idx++ ) { + m_mainDevice.addNodeToDeviceList(deviceNodeIDs[lv_idx], xltDevice.DEFAULT_DEVICE_TYPE, deviceNames[lv_idx]); + } + m_mainDevice.setDeviceID(deviceNodeIDs[0]); + + // Connect to Controller m_mainDevice.Connect(CloudAccount.DEVICE_ID); // Set SmartDevice Event Notification Flag @@ -71,6 +83,19 @@ protected void onCreate(Bundle savedInstanceState) { displayView(R.id.nav_glance); navigationView.getMenu().getItem(0).setChecked(true); + + m_eventHandler = new Handler() { + public void handleMessage(Message msg) { + int nCmd = msg.getData().getInt("cmd", -1); + if( nCmd == 1) { + // Menu + int nItem = msg.getData().getInt("item", -1); + if( nItem >= 0 ) { + displayView(nItem); + } + } + } + }; } @Override diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java index efd14e4..5075e0e 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/scenario/ScenarioFragment.java @@ -28,8 +28,8 @@ public class ScenarioFragment extends Fragment { public static String SCENARIO_NAME = "SCENARIO_NAME"; public static String SCENARIO_INFO = "SCENARIO_INFO"; - public static ArrayList name = new ArrayList<>(Arrays.asList("Preset 1", "Preset 2", "Turn off")); - public static ArrayList info = new ArrayList<>(Arrays.asList("A bright, party room preset", "A relaxed atmosphere with yellow tones", "Turn the chandelier rings off")); + public static ArrayList name = new ArrayList<>(Arrays.asList("Dinner", "Sleep", "Dance")); + public static ArrayList info = new ArrayList<>(Arrays.asList("A bright, party room preset", "A relaxed atmosphere with yellow tones", "Random breathing color")); ScenarioListAdapter scenarioListAdapter; RecyclerView scenarioRecyclerView; diff --git a/build.gradle b/build.gradle index 74b2ab0..1ea4bd0 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.2.3' + classpath 'com.android.tools.build:gradle:2.3.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 732830c..24eb9b1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Aug 23 14:01:18 EDT 2016 +#Sun Mar 12 22:39:59 EDT 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip From 5f15457b2a67b70bb174c67d64c7b1b0850358ba Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Sun, 19 Mar 2017 03:37:54 -0400 Subject: [PATCH 14/25] display ALS sensor data --- .../xlightcompanion/SDK/Cloud/CloudBridge.java | 9 ++++++++- .../xlightcompanion/glance/GlanceFragment.java | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java index a26a40f..5f46ac2 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/Cloud/CloudBridge.java @@ -383,7 +383,7 @@ public void onEvent(String eventName, ParticleEvent event) { Log.i(TAG, "Received event: " + eventName + " with payload: " + event.dataPayload); // Notes: due to bug of SDK 0.3.4, the eventName is not correct /// We work around by specifying eventName - if( event.dataPayload.contains("DHTt") ) { + if( event.dataPayload.contains("DHTt") || event.dataPayload.contains("ALS") || event.dataPayload.contains("PIR") ) { eventName = xltDevice.eventSensorData; } else { eventName = xltDevice.eventDeviceStatus; @@ -486,6 +486,10 @@ private void InformActivities(final String eventName, final String dataPayload) m_parentDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); bdlData.putInt("DHTh", m_parentDevice.m_Data.m_RoomHumidity); } + if (jObject.has("ALS")) { + m_parentDevice.m_Data.m_RoomBrightness = jObject.getInt("ALS"); + bdlData.putInt("ALS", m_parentDevice.m_Data.m_RoomBrightness); + } m_parentDevice.sendSensorDataMessage(bdlData); } } catch (final JSONException e) { @@ -536,6 +540,9 @@ private void BroadcastEvent(final String eventName, String dataPayload) { if (jObject.has("DHTh")) { m_parentDevice.m_Data.m_RoomHumidity = jObject.getInt("DHTh"); } + if (jObject.has("ALS")) { + m_parentDevice.m_Data.m_RoomBrightness = jObject.getInt("ALS"); + } //} } catch (final JSONException e) { Log.e(TAG, "Json parsing error: " + e.getMessage()); diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java index 57d4155..588e3da 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/glance/GlanceFragment.java @@ -48,7 +48,7 @@ */ public class GlanceFragment extends Fragment { private com.github.clans.fab.FloatingActionButton fab; - TextView txtLocation, outsideTemp, degreeSymbol, roomTemp, roomHumidity, outsideHumidity, apparentTemp; + TextView txtLocation, outsideTemp, degreeSymbol, roomTemp, roomHumidity, roomBrightness, outsideHumidity, apparentTemp; ImageView imgWeather; private static final String TAG = MainActivity.class.getSimpleName(); @@ -67,6 +67,7 @@ private class MyDataReceiver extends DataReceiver { public void onReceive(Context context, Intent intent) { roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); + roomBrightness.setText(MainActivity.m_mainDevice.m_Data.m_RoomBrightness + "\u0025"); } } private final MyDataReceiver m_DataReceiver = new MyDataReceiver(); @@ -96,6 +97,8 @@ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); roomHumidity = (TextView) view.findViewById(R.id.valRoomHumidity); roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); + roomBrightness = (TextView) view.findViewById(R.id.valRoomBrightness); + roomBrightness.setText(MainActivity.m_mainDevice.m_Data.m_RoomBrightness + "\u0025"); imgWeather = (ImageView) view.findViewById(R.id.weatherIcon); Resources res = getResources(); @@ -129,6 +132,10 @@ public void handleMessage(Message msg) { if (intValue != -255) { roomHumidity.setText(intValue + "\u0025"); } + intValue = msg.getData().getInt("ALS", -255); + if (intValue != -255) { + roomBrightness.setText(intValue + "\u0025"); + } } }; MainActivity.m_mainDevice.addDataEventHandler(m_handlerGlance); @@ -212,6 +219,7 @@ private void updateDisplay() { roomTemp.setText(MainActivity.m_mainDevice.m_Data.m_RoomTemp + "\u00B0"); roomHumidity.setText(MainActivity.m_mainDevice.m_Data.m_RoomHumidity + "\u0025"); + roomBrightness.setText(MainActivity.m_mainDevice.m_Data.m_RoomBrightness + "\u0025"); } private WeatherDetails getWeatherDetails(String jsonData) throws JSONException { From 3d03688490721c665c920f72854f8b16f02bccf9 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Tue, 21 Mar 2017 06:04:17 -0400 Subject: [PATCH 15/25] BLE connection --- .../SDK/BLE/BLEAdapterDelegate.java | 45 +++ .../SDK/BLE/BLEAdapterFactory.java | 18 + .../SDK/BLE/BLEAdapterWrapper.java | 22 ++ .../xlightcompanion/SDK/BLE/BLEBridge.java | 203 +++------- .../SDK/BLE/BLEDeviceConnector.java | 354 ++++++++++++++++++ ...EAdapter.java => BLEPairedDeviceList.java} | 36 +- .../xlightcompanion/SDK/BLE/BleException.java | 53 --- .../SDK/BLE/BleGattCallback.java | 17 - .../SDK/BLE/ConnectException.java | 45 --- .../SDK/BLE/DeviceConnector.java | 20 + .../SDK/BLE/MessageHandler.java | 31 ++ .../SDK/BLE/MessageHandlerImpl.java | 58 +++ .../SDK/BLE/NullBLEWrapper.java | 37 ++ .../SDK/BLE/NullDeviceConnector.java | 27 ++ .../SDK/BLE/TimeoutException.java | 11 - .../xlightcompanion/SDK/xltDevice.java | 8 +- .../xlightcompanion/main/MainActivity.java | 12 +- 17 files changed, 689 insertions(+), 308 deletions(-) create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterDelegate.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterFactory.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterWrapper.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEDeviceConnector.java rename app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/{BLEAdapter.java => BLEPairedDeviceList.java} (65%) delete mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java delete mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java delete mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/DeviceConnector.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandler.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandlerImpl.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullBLEWrapper.java create mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullDeviceConnector.java delete mode 100644 app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterDelegate.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterDelegate.java new file mode 100644 index 0000000..ea60b0a --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterDelegate.java @@ -0,0 +1,45 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; + +import java.util.Set; + +/** + * Created by sunboss on 2017-03-21. + */ + +public class BLEAdapterDelegate implements BLEAdapterWrapper { + + private final BluetoothAdapter adapter; + + public BLEAdapterDelegate(BluetoothAdapter adapter) { + assert adapter != null; + this.adapter = adapter; + } + + @Override + public Set getBondedDevices() { + return adapter.getBondedDevices(); + } + + @Override + public void cancelDiscovery() { + adapter.cancelDiscovery(); + } + + @Override + public boolean isDiscovering() { + return adapter.isDiscovering(); + } + + @Override + public void startDiscovery() { + adapter.startDiscovery(); + } + + @Override + public boolean isEnabled() { + return adapter.isEnabled(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterFactory.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterFactory.java new file mode 100644 index 0000000..e11bfe8 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterFactory.java @@ -0,0 +1,18 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothAdapter; + +/** + * Created by sunboss on 2017-03-21. + */ + +public class BLEAdapterFactory { + private BLEAdapterFactory() { + // utility class + } + + public static BLEAdapterWrapper getBluetoothAdapterWrapper() { + BluetoothAdapter defaultAdapter = BluetoothAdapter.getDefaultAdapter(); + return defaultAdapter != null ? new BLEAdapterDelegate(defaultAdapter) : new NullBLEWrapper(); + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterWrapper.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterWrapper.java new file mode 100644 index 0000000..2866261 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapterWrapper.java @@ -0,0 +1,22 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothDevice; + +import java.util.Set; + +/** + * Created by sunboss on 2017-03-21. + */ + +public interface BLEAdapterWrapper { + + Set getBondedDevices(); + + void cancelDiscovery(); + + boolean isDiscovering(); + + void startDiscovery(); + + boolean isEnabled(); +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java index 9c0742e..18690ef 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEBridge.java @@ -4,6 +4,8 @@ import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattDescriptor; +import android.os.Handler; +import android.os.Message; import android.util.Log; import com.umarbhutta.xlightcompanion.SDK.BaseBridge; @@ -20,72 +22,38 @@ public class BLEBridge extends BaseBridge { // misc private static final String TAG = BLEBridge.class.getSimpleName(); + private static final boolean D = true; - public static final int STATE_DISCONNECTED = 0; - public static final int STATE_CONNECTING = 1; - public static final int STATE_CONNECTED = 2; - public static final int STATE_DISCONNECTING = 3; - public static final int STATE_SCANNING = 5; - public static final int STATE_SERVICES_DISCOVERED = 6; - + private DeviceConnector mDeviceConnector = new NullDeviceConnector(); private boolean m_bPaired = false; + private boolean m_bLoggedIn = false; private BluetoothDevice m_bleDevice; - private BluetoothGatt m_bleGatt; private String m_bleAddress; - private int connectionState = STATE_DISCONNECTED; public BLEBridge() { super(); setName(TAG); } - public boolean PairDevice(final String key) { - // ToDo: pair with SmartController - // createBond() - //m_bPaired = true; - return m_bPaired; - } - public boolean isPaired() { return m_bPaired; } public boolean connectController() { // Connect SmartController via BLE - if( m_bleDevice != null ) { - m_bleDevice.connectGatt(m_parentContext, false, coreGattCallback); + if( m_bleDevice != null && m_bleAddress.length() > 0 ) { + MessageHandler messageHandler = new MessageHandlerImpl(mHandler); + mDeviceConnector = new BLEDeviceConnector(messageHandler, m_bleAddress); + mDeviceConnector.connect(); return true; } return false; } - public boolean refreshDeviceCache() { - try { - final Method refresh = BluetoothGatt.class.getMethod("refresh"); - if (refresh != null) { - final boolean success = (Boolean) refresh.invoke(m_bleGatt); - Log.i(TAG, "Refreshing result: " + success); - return success; - } - } catch (Exception e) { - Log.e(TAG, "An exception occured while refreshing device", e); - } - return false; - } - - public void closeBluetoothGatt() { - if (m_bleGatt != null) { - m_bleGatt.disconnect(); - } - - if (m_bleGatt != null) { - refreshDeviceCache(); - } - - if (m_bleGatt != null) { - m_bleGatt.close(); - m_bleGatt = null; - } + public boolean Login(final String key) { + // ToDo: send login message + //m_bLoggedIn = true; + return m_bLoggedIn; } @Override @@ -93,7 +61,7 @@ public void setName(final String name) { super.setName(name); // Retrieve Bluetooth Device by device name - m_bleDevice = BLEAdapter.SearchDeviceName(name); + m_bleDevice = BLEPairedDeviceList.SearchDeviceName(name); if( m_bleDevice != null ) { m_bPaired = (m_bleDevice.getBondState() == BOND_BONDED); m_bleAddress = m_bleDevice.getAddress(); @@ -107,113 +75,46 @@ public String getAddress() { return m_bleAddress; } - public boolean isInScanning() { - return connectionState == STATE_SCANNING; - } - - public boolean isConnectingOrConnected() { - return connectionState >= STATE_CONNECTING; - } - - @Override - public boolean isConnected() { - return connectionState >= STATE_CONNECTED; - } - - public boolean isServiceDiscovered() { - return connectionState == STATE_SERVICES_DISCOVERED; - } - - /** - * return - * {@link #STATE_DISCONNECTED} - * {@link #STATE_SCANNING} - * {@link #STATE_CONNECTING} - * {@link #STATE_CONNECTED} - * {@link #STATE_SERVICES_DISCOVERED} - */ - public int getConnectionState() { - return connectionState; - } - - private BleGattCallback coreGattCallback = new BleGattCallback() { - - @Override - public void onConnectFailure(BleException exception) { - Log.w(TAG, "coreGattCallback:onConnectFailure "); - - m_bleGatt = null; - setConnect(false); - } - + // The Handler that gets information back from the BluetoothService + private final Handler mHandler = new Handler() { @Override - public void onConnectSuccess(BluetoothGatt gatt, int status) { - Log.i(TAG, "coreGattCallback:onConnectSuccess "); - - m_bleGatt = gatt; - setConnect(true); - } - - @Override - public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { - Log.i(TAG, "coreGattCallback:onConnectionStateChange " - + '\n' + "status: " + status - + '\n' + "newState: " + newState - + '\n' + "thread: " + Thread.currentThread().getId()); - - if (newState == BluetoothGatt.STATE_CONNECTED) { - connectionState = STATE_CONNECTED; - onConnectSuccess(gatt, status); - - } else if (newState == BluetoothGatt.STATE_DISCONNECTED) { - connectionState = STATE_DISCONNECTED; - onConnectFailure(new ConnectException(gatt, status)); - - } else if (newState == BluetoothGatt.STATE_CONNECTING) { - connectionState = STATE_CONNECTING; + public void handleMessage(Message msg) { + switch (msg.what) { + case MessageHandler.MSG_CONNECTED: + // Device connected + Log.i(TAG, "onConnectSuccess"); + setConnect(true); + //onBluetoothStateChanged(); + break; + case MessageHandler.MSG_CONNECTING: + Log.i(TAG, "onConnecting"); + setConnect(false); + //onBluetoothStateChanged(); + break; + case MessageHandler.MSG_NOT_CONNECTED: + Log.i(TAG, "onDisconnected"); + setConnect(false); + //onBluetoothStateChanged(); + break; + case MessageHandler.MSG_CONNECTION_FAILED: + Log.w(TAG, "onConnectFailed"); + setConnect(false); + //onBluetoothStateChanged(); + break; + case MessageHandler.MSG_CONNECTION_LOST: + Log.w(TAG, "onConnectionLost"); + setConnect(false); + //onBluetoothStateChanged(); + break; + case MessageHandler.MSG_BYTES_WRITTEN: + String written = new String((byte[]) msg.obj); + Log.i(TAG, "written = '" + written + "'"); + break; + case MessageHandler.MSG_LINE_READ: + String line = (String) msg.obj; + if (D) Log.d(TAG, line); + break; } } - - @Override - public void onServicesDiscovered(BluetoothGatt gatt, int status) { - Log.i(TAG, "coreGattCallback:onServicesDiscovered "); - - connectionState = STATE_SERVICES_DISCOVERED; - } - - @Override - public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { - Log.d(TAG, "coreGattCallback:onCharacteristicRead "); - } - - @Override - public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { - Log.i(TAG, "coreGattCallback:onCharacteristicWrite "); - } - - @Override - public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { - Log.i(TAG, "coreGattCallback:onCharacteristicChanged "); - } - - @Override - public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { - Log.d(TAG, "coreGattCallback:onDescriptorRead "); - } - - @Override - public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { - Log.d(TAG, "coreGattCallback:onDescriptorWrite "); - } - - @Override - public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { - Log.i(TAG, "coreGattCallback:onReliableWriteCompleted "); - } - - @Override - public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { - Log.d(TAG, "coreGattCallback:onReadRemoteRssi "); - } }; } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEDeviceConnector.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEDeviceConnector.java new file mode 100644 index 0000000..9baf44c --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEDeviceConnector.java @@ -0,0 +1,354 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothSocket; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * This class does all the work for setting up and managing Bluetooth + * connections with other devices. It has a thread that listens for + * incoming connections, a thread for connecting with a device, and a + * thread for performing data transmissions when connected. + */ +public class BLEDeviceConnector implements DeviceConnector { + private static final String TAG = BLEDeviceConnector.class.getSimpleName(); + private static final boolean D = true; + + public static final int CHANNEL = 1; + + private final BluetoothAdapter mAdapter; + private final MessageHandler mHandler; + private final String mAddress; + private ConnectThread mConnectThread; + private ConnectedThread mConnectedThread; + private int mState; + + @Override + public int getState() { + return mState; + } + + /** + * Prepare a new Bluetooth session. + * + * @param handler A Handler to send messages back to the UI Activity + */ + public BLEDeviceConnector(MessageHandler handler, String address) { + mAdapter = BluetoothAdapter.getDefaultAdapter(); + mState = STATE_NONE; + mHandler = handler; + mAddress = address; + } + + /** + * Set the current state of the connection + * + * @param state An integer defining the current connection state + */ + private synchronized void setState(int state) { + if (D) Log.d(TAG, "setState() " + mState + " -> " + state); + mState = state; + } + + private BluetoothAdapter getBluetoothAdapter() { + return BluetoothAdapter.getDefaultAdapter(); + } + + @Override + public synchronized void connect() { + BluetoothDevice device = getBluetoothAdapter().getRemoteDevice(mAddress); + connect(device); + } + + /** + * Start the ConnectThread to initiate a connection to a remote device. + * + * @param device The BluetoothDevice to connect + */ + public synchronized void connect(BluetoothDevice device) { + if (D) Log.d(TAG, "connect to: " + device); + + // Cancel any thread attempting to make a connection + if (mState == STATE_CONNECTING) { + if (mConnectThread != null) { + mConnectThread.cancel(); + mConnectThread = null; + } + } + + // Cancel any thread currently running a connection + if (mConnectedThread != null) { + mConnectedThread.cancel(); + mConnectedThread = null; + } + + // Start the thread to connect with the given device + try { + mConnectThread = new ConnectThread(device); + mConnectThread.start(); + setState(STATE_CONNECTING); + mHandler.sendConnectingTo(device.getName()); + } catch (SecurityException e) { + Log.e(TAG, e.getMessage(), e); + } catch (IllegalArgumentException e) { + Log.e(TAG, e.getMessage(), e); + } catch (NoSuchMethodException e) { + Log.e(TAG, e.getMessage(), e); + } catch (IllegalAccessException e) { + Log.e(TAG, e.getMessage(), e); + } catch (InvocationTargetException e) { + Log.e(TAG, e.getMessage(), e); + } + } + + /** + * Start the ConnectedThread to begin managing a Bluetooth connection + * + * @param socket The BluetoothSocket on which the connection was made + * @param device The BluetoothDevice that has been connected + */ + public synchronized void connected(BluetoothSocket socket, BluetoothDevice device) { + if (D) Log.d(TAG, "connected"); + + // Cancel the thread that completed the connection + if (mConnectThread != null) { + mConnectThread.cancel(); + mConnectThread = null; + } + + // Cancel any thread currently running a connection + if (mConnectedThread != null) { + mConnectedThread.cancel(); + mConnectedThread = null; + } + + // Start the thread to manage the connection and perform transmissions + mConnectedThread = new ConnectedThread(socket); + mConnectedThread.start(); + + setState(STATE_CONNECTED); + mHandler.sendConnectedTo(device.getName()); + } + + /** + * Stop all threads + */ + @Override + public synchronized void disconnect() { + if (D) Log.d(TAG, "shutdown"); + + if (mConnectThread != null) { + mConnectThread.cancel(); + mConnectThread = null; + } + + if (mConnectedThread != null) { + mConnectedThread.shutdown(); + mConnectedThread.cancel(); + mConnectedThread = null; + } + + setState(STATE_NONE); + mHandler.sendNotConnected(); + } + + @Override + public void sendAsciiMessage(CharSequence chars) { + write((chars.toString() + "\n").getBytes()); + } + + /** + * Write to the ConnectedThread in an unsynchronized manner + * + * @param out The bytes to write + * @see ConnectedThread#write(byte[]) + */ + private void write(byte[] out) { + // Create temporary object + ConnectedThread r; + // Synchronize a copy of the ConnectedThread + synchronized (this) { + if (mState != STATE_CONNECTED) return; + r = mConnectedThread; + } + // Perform the write unsynchronized + r.write(out); + } + + /** + * Indicate that the connection attempt failed and notify the UI Activity. + */ + private void connectionFailed() { + setState(STATE_NONE); + mHandler.sendConnectionFailed(); + } + + /** + * Indicate that the connection was lost and notify the UI Activity. + */ + private void connectionLost() { + setState(STATE_NONE); + mHandler.sendConnectionLost(); + } + + + /** + * This thread runs while attempting to make an outgoing connection + * with a device. It runs straight through; the connection either + * succeeds or fails. + */ + private class ConnectThread extends Thread { + private final BluetoothSocket mmSocket; + private final BluetoothDevice mmDevice; + + public ConnectThread(BluetoothDevice device) throws SecurityException, NoSuchMethodException, IllegalArgumentException, IllegalAccessException, InvocationTargetException { + mmDevice = device; + BluetoothSocket tmp = null; + + Log.i(TAG, "calling device.createRfcommSocket with channel " + CHANNEL + " ..."); + try { + // call hidden method, see BluetoothDevice source code for more details: + // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/bluetooth/BluetoothDevice.java + Method m = device.getClass().getMethod("createRfcommSocket", int.class); + tmp = (BluetoothSocket) m.invoke(device, CHANNEL); + Log.i(TAG, "setting socket to result of createRfcommSocket"); + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + } + mmSocket = tmp; + } + + public void run() { + Log.i(TAG, "BEGIN mConnectThread"); + setName("ConnectThread"); + + // Always cancel discovery because it will slow down a connection + mAdapter.cancelDiscovery(); + + // Make a connection to the BluetoothSocket + try { + // This is a blocking call and will only return on a + // successful connection or an exception + mmSocket.connect(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + connectionFailed(); + try { + mmSocket.close(); + } catch (IOException e2) { + Log.e(TAG, "unable to close() socket during connection failure", e2); + } + return; + } + + // Reset the ConnectThread because we're done + synchronized (BLEDeviceConnector.this) { + mConnectThread = null; + } + + // Start the connected thread + connected(mmSocket, mmDevice); + } + + public void cancel() { + try { + mmSocket.close(); + } catch (IOException e) { + Log.e(TAG, "close() of connect socket failed", e); + } + } + } + + /** + * This thread runs during a connection with a remote device. + * It handles all incoming and outgoing transmissions. + */ + private class ConnectedThread extends Thread { + private final BluetoothSocket mmSocket; + private final InputStream mmInStream; + private final OutputStream mmOutStream; + + public ConnectedThread(BluetoothSocket socket) { + Log.d(TAG, "create ConnectedThread"); + mmSocket = socket; + InputStream tmpIn = null; + OutputStream tmpOut = null; + + // Get the BluetoothSocket input and output streams + try { + tmpIn = socket.getInputStream(); + tmpOut = socket.getOutputStream(); + } catch (IOException e) { + Log.e(TAG, "temp sockets not created", e); + } + + mmInStream = tmpIn; + mmOutStream = tmpOut; + } + + private boolean stop = false; + private boolean hasReadAnything = false; + + public void shutdown() { + stop = true; + if (!hasReadAnything) return; + if (mmInStream != null) { + try { + mmInStream.close(); + } catch (IOException e) { + Log.e(TAG, "close() of InputStream failed."); + } + } + } + + public void run() { + Log.i(TAG, "BEGIN mConnectedThread"); + + BufferedReader reader = new BufferedReader(new InputStreamReader(mmInStream)); + + while (!stop) { + try { + String line = reader.readLine(); + if (line != null) { + mHandler.sendLineRead(line); + } + } catch (IOException e) { + Log.e(TAG, "disconnected", e); + connectionLost(); + break; + } + } + } + + /** + * Write to the connected OutStream. + * + * @param bytes The bytes to write + */ + public void write(byte[] bytes) { + try { + mmOutStream.write(bytes); + mHandler.sendBytesWritten(bytes); + } catch (IOException e) { + Log.e(TAG, "Exception during write", e); + } + } + + public void cancel() { + try { + mmSocket.close(); + } catch (IOException e) { + Log.e(TAG, "close() of connect socket failed", e); + } + } + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEPairedDeviceList.java similarity index 65% rename from app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java rename to app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEPairedDeviceList.java index 036e05c..879246a 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEAdapter.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BLEPairedDeviceList.java @@ -1,6 +1,5 @@ package com.umarbhutta.xlightcompanion.SDK.BLE; -import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.content.Context; import android.content.pm.PackageManager; @@ -14,21 +13,21 @@ */ @SuppressWarnings({"UnusedDeclaration"}) -public class BLEAdapter { +public class BLEPairedDeviceList { // misc - private static final String TAG = BLEAdapter.class.getSimpleName(); + private static final String TAG = BLEPairedDeviceList.class.getSimpleName(); + public static final int REQUEST_ENABLE_BT = 1010; public static final String XLIGHT_BLE_NAME_PREFIX = "Xlight"; //private static final int XLIGHT_BLE_CLASS = 0x9A050C; // default value for HC-06 is 0x1F00 private static final int XLIGHT_BLE_CLASS = 0x1F00; // default value for HC-06 is 0x1F00 + private static BLEAdapterWrapper mBtAdapter = BLEAdapterFactory.getBluetoothAdapterWrapper(); + private static ArrayList mPairedDevices = new ArrayList<>(); + private static boolean m_bInitialized = false; - private static BluetoothAdapter m_btAdapter; private static Context m_Context; private static boolean m_bSupported = false; - private static boolean m_bEnabled = false; - - public static ArrayList mPairedDevices = new ArrayList<>(); public static void init(Context context) { m_Context = context; @@ -37,7 +36,7 @@ public static void init(Context context) { Log.e(TAG, "Bluetooth NOT supported!"); return; } - m_btAdapter = BluetoothAdapter.getDefaultAdapter(); + CheckBluetoothState(); m_bInitialized = true; } @@ -51,23 +50,18 @@ public static boolean IsSupported() { } public static boolean IsEnabled() { - return m_bEnabled; + return mBtAdapter.isEnabled(); } public static void CheckBluetoothState() { - if (m_btAdapter != null) { - m_bEnabled = m_btAdapter.isEnabled(); - } else { - m_bEnabled = false; - } - - if (m_bEnabled) { + if (IsEnabled()) { Log.d(TAG, "Bluetooth is enabled..."); - mPairedDevices.clear(); - Set devices = m_btAdapter.getBondedDevices(); - for (BluetoothDevice device : devices) { - if (device.getBluetoothClass().hashCode() == XLIGHT_BLE_CLASS || device.getName().startsWith(XLIGHT_BLE_NAME_PREFIX)) { - mPairedDevices.add(device); + Set devices = mBtAdapter.getBondedDevices(); + if (devices != null && !devices.isEmpty()) { + for (BluetoothDevice device : devices) { + if (device.getBluetoothClass().hashCode() == XLIGHT_BLE_CLASS && device.getName().startsWith(XLIGHT_BLE_NAME_PREFIX)) { + mPairedDevices.add(device); + } } } } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java deleted file mode 100644 index 0cb16be..0000000 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleException.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.umarbhutta.xlightcompanion.SDK.BLE; - -import java.io.Serializable; - -/** - * Created by sunboss on 2016-12-11. - */ - -@SuppressWarnings({"UnusedDeclaration"}) -public abstract class BleException implements Serializable { - private static final long serialVersionUID = 8004414918500865564L; - - public static final int ERROR_CODE_TIMEOUT = 1; - public static final int ERROR_CODE_INITIAL = 101; - public static final int ERROR_CODE_GATT = 201; - public static final int GATT_CODE_OTHER = 301; - - public static final TimeoutException TIMEOUT_EXCEPTION = new TimeoutException(); - - private int code; - private String description; - - public BleException(int code, String description) { - this.code = code; - this.description = description; - } - - public int getCode() { - return code; - } - - public BleException setCode(int code) { - this.code = code; - return this; - } - - public String getDescription() { - return description; - } - - public BleException setDescription(String description) { - this.description = description; - return this; - } - - @Override - public String toString() { - return "BleException { " + - "code=" + code + - ", description='" + description + '\'' + - '}'; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java deleted file mode 100644 index 3427e22..0000000 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/BleGattCallback.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.umarbhutta.xlightcompanion.SDK.BLE; - -import android.bluetooth.BluetoothGatt; -import android.bluetooth.BluetoothGattCallback; - -/** - * Created by sunboss on 2016-12-11. - */ -public abstract class BleGattCallback extends BluetoothGattCallback { - - public abstract void onConnectSuccess(BluetoothGatt gatt, int status); - - @Override - public abstract void onServicesDiscovered(BluetoothGatt gatt, int status); - - public abstract void onConnectFailure(BleException exception); -} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java deleted file mode 100644 index 838966e..0000000 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/ConnectException.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.umarbhutta.xlightcompanion.SDK.BLE; - -import android.bluetooth.BluetoothGatt; - -/** - * Created by sunboss on 2016-12-11. - */ - -@SuppressWarnings({"UnusedDeclaration"}) -public class ConnectException extends BleException { - private BluetoothGatt bluetoothGatt; - private int gattStatus; - - public ConnectException(BluetoothGatt bluetoothGatt, int gattStatus) { - super(ERROR_CODE_GATT, "Gatt Exception Occurred! "); - this.bluetoothGatt = bluetoothGatt; - this.gattStatus = gattStatus; - } - - public int getGattStatus() { - return gattStatus; - } - - public ConnectException setGattStatus(int gattStatus) { - this.gattStatus = gattStatus; - return this; - } - - public BluetoothGatt getBluetoothGatt() { - return bluetoothGatt; - } - - public ConnectException setBluetoothGatt(BluetoothGatt bluetoothGatt) { - this.bluetoothGatt = bluetoothGatt; - return this; - } - - @Override - public String toString() { - return "ConnectException{" + - "gattStatus=" + gattStatus + - ", bluetoothGatt=" + bluetoothGatt + - "} " + super.toString(); - } -} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/DeviceConnector.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/DeviceConnector.java new file mode 100644 index 0000000..d1ba548 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/DeviceConnector.java @@ -0,0 +1,20 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +/** + * Created by sunboss on 2017-03-21. + */ + +public interface DeviceConnector { + + int STATE_NONE = 0; // we're doing nothing + int STATE_CONNECTING = 2; // now initiating an outgoing connection + int STATE_CONNECTED = 3; // now connected to a remote device + + void connect(); + + void disconnect(); + + void sendAsciiMessage(CharSequence chars); + + int getState(); +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandler.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandler.java new file mode 100644 index 0000000..20a4dce --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandler.java @@ -0,0 +1,31 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +/** + * Created by sunboss on 2017-03-21. + */ + +public interface MessageHandler { + + int MSG_NOT_CONNECTED = 10; + int MSG_CONNECTING = 11; + int MSG_CONNECTED = 12; + int MSG_CONNECTION_FAILED = 13; + int MSG_CONNECTION_LOST = 14; + int MSG_LINE_READ = 21; + int MSG_BYTES_WRITTEN = 22; + + void sendLineRead(String line); + + void sendBytesWritten(byte[] bytes); + + void sendConnectingTo(String deviceName); + + void sendConnectedTo(String deviceName); + + void sendNotConnected(); + + void sendConnectionFailed(); + + void sendConnectionLost(); + +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandlerImpl.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandlerImpl.java new file mode 100644 index 0000000..7f12e00 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/MessageHandlerImpl.java @@ -0,0 +1,58 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.os.Handler; + +/** + * Created by sunboss on 2017-03-21. + */ + +public class MessageHandlerImpl implements MessageHandler { + private final Handler handler; + + public MessageHandlerImpl(Handler handler) { + this.handler = handler; + } + + @Override + public void sendLineRead(String line) { + handler.obtainMessage(MSG_LINE_READ, -1, -1, line).sendToTarget(); + } + + @Override + public void sendBytesWritten(byte[] bytes) { + handler.obtainMessage(MSG_BYTES_WRITTEN, -1, -1, bytes).sendToTarget(); + } + + @Override + public void sendConnectingTo(String deviceName) { + sendMessage(MSG_CONNECTING, deviceName); + } + + @Override + public void sendConnectedTo(String deviceName) { + sendMessage(MSG_CONNECTED, deviceName); + } + + @Override + public void sendNotConnected() { + sendMessage(MSG_NOT_CONNECTED); + } + + @Override + public void sendConnectionFailed() { + sendMessage(MSG_CONNECTION_FAILED); + } + + @Override + public void sendConnectionLost() { + sendMessage(MSG_CONNECTION_LOST); + } + + private void sendMessage(int messageId, String deviceName) { + handler.obtainMessage(messageId, -1, -1, deviceName).sendToTarget(); + } + + private void sendMessage(int messageId) { + handler.obtainMessage(messageId).sendToTarget(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullBLEWrapper.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullBLEWrapper.java new file mode 100644 index 0000000..39497a1 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullBLEWrapper.java @@ -0,0 +1,37 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +import android.bluetooth.BluetoothDevice; + +import java.util.Collections; +import java.util.Set; + +/** + * Created by sunboss on 2017-03-21. + */ + +public class NullBLEWrapper implements BLEAdapterWrapper { + @Override + public Set getBondedDevices() { + return Collections.emptySet(); + } + + @Override + public void cancelDiscovery() { + // nothing to cancel + } + + @Override + public boolean isDiscovering() { + return false; + } + + @Override + public void startDiscovery() { + // nothing to discover + } + + @Override + public boolean isEnabled() { + return false; + } +} diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullDeviceConnector.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullDeviceConnector.java new file mode 100644 index 0000000..d6b4927 --- /dev/null +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/NullDeviceConnector.java @@ -0,0 +1,27 @@ +package com.umarbhutta.xlightcompanion.SDK.BLE; + +/** + * Created by sunboss on 2017-03-21. + */ + +public class NullDeviceConnector implements DeviceConnector { + @Override + public void connect() { + // do nothing + } + + @Override + public void disconnect() { + // do nothing + } + + @Override + public void sendAsciiMessage(CharSequence chars) { + // do nothing + } + + @Override + public int getState() { + return STATE_NONE; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java deleted file mode 100644 index 06b672b..0000000 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/BLE/TimeoutException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.umarbhutta.xlightcompanion.SDK.BLE; - -/** - * Created by sunboss on 2016-12-11. - */ - -public class TimeoutException extends BleException { - public TimeoutException() { - super(ERROR_CODE_TIMEOUT, "Timeout Exception Occurred! "); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java index 476346c..a1a9890 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/SDK/xltDevice.java @@ -6,7 +6,7 @@ import android.os.Message; import android.os.SystemClock; -import com.umarbhutta.xlightcompanion.SDK.BLE.BLEAdapter; +import com.umarbhutta.xlightcompanion.SDK.BLE.BLEPairedDeviceList; import com.umarbhutta.xlightcompanion.SDK.BLE.BLEBridge; import com.umarbhutta.xlightcompanion.SDK.Cloud.CloudBridge; import com.umarbhutta.xlightcompanion.SDK.Cloud.ParticleAdapter; @@ -14,7 +14,7 @@ import java.util.ArrayList; -import static com.umarbhutta.xlightcompanion.SDK.BLE.BLEAdapter.XLIGHT_BLE_NAME_PREFIX; +import static com.umarbhutta.xlightcompanion.SDK.BLE.BLEPairedDeviceList.XLIGHT_BLE_NAME_PREFIX; /** * Created by sunboss on 2016-11-15. @@ -215,8 +215,8 @@ public void Init(Context context) { // Ensure we do it only once if( !m_bInitialized ) { // Init BLE Adapter - if( !BLEAdapter.initialized() ) { - BLEAdapter.init(context); + if( !BLEPairedDeviceList.initialized() ) { + BLEPairedDeviceList.init(context); } // Init Particle Adapter diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 6d3490d..2dba5b6 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -17,7 +17,7 @@ import android.view.MenuItem; import com.umarbhutta.xlightcompanion.R; -import com.umarbhutta.xlightcompanion.SDK.BLE.BLEAdapter; +import com.umarbhutta.xlightcompanion.SDK.BLE.BLEPairedDeviceList; import com.umarbhutta.xlightcompanion.SDK.CloudAccount; import com.umarbhutta.xlightcompanion.control.ControlFragment; import com.umarbhutta.xlightcompanion.glance.GlanceFragment; @@ -48,10 +48,10 @@ protected void onCreate(Bundle savedInstanceState) { setSupportActionBar(toolbar); // Check Bluetooth - BLEAdapter.init(this); - if( BLEAdapter.IsSupported() && !BLEAdapter.IsEnabled() ) { + BLEPairedDeviceList.init(this); + if( BLEPairedDeviceList.IsSupported() && !BLEPairedDeviceList.IsEnabled() ) { Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); - startActivityForResult(enableBtIntent, BLEAdapter.REQUEST_ENABLE_BT); + startActivityForResult(enableBtIntent, BLEPairedDeviceList.REQUEST_ENABLE_BT); } // Initialize SmartDevice SDK @@ -101,8 +101,8 @@ public void handleMessage(Message msg) { @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode == BLEAdapter.REQUEST_ENABLE_BT) { - BLEAdapter.init(this); + if (requestCode == BLEPairedDeviceList.REQUEST_ENABLE_BT) { + BLEPairedDeviceList.init(this); } } From 340b6c774f204fb3bb131ae27791be60e146c2a5 Mon Sep 17 00:00:00 2001 From: sunbaoshi Date: Fri, 24 Mar 2017 05:53:24 -0400 Subject: [PATCH 16/25] cloud SDK & private cloud --- .idea/gradle.xml | 1 + .idea/misc.xml | 2 +- .idea/modules.xml | 1 + app/build.gradle | 28 +- .../xlightcompanion/main/MainActivity.java | 2 +- bintray_upload_v1.gradle | 64 + build.gradle | 4 + cloudsdk/.gitignore | 1 + cloudsdk/build.gradle | 92 ++ cloudsdk/consumer-proguard-rules.pro | 1 + cloudsdk/proguard-rules.pro | 17 + cloudsdk/releasing.md | 17 + .../android/sdk/cloud/ApplicationTest.java | 13 + cloudsdk/src/main/AndroidManifest.xml | 6 + .../particle/android/sdk/cloud/ApiDefs.java | 145 ++ .../android/sdk/cloud/ApiFactory.java | 148 ++ .../android/sdk/cloud/BroadcastContract.java | 9 + .../android/sdk/cloud/DeviceState.java | 284 ++++ .../android/sdk/cloud/EventsDelegate.java | 255 ++++ .../android/sdk/cloud/FunctionArgs.java | 13 + .../sdk/cloud/ParallelDeviceFetcher.java | 122 ++ .../sdk/cloud/ParticleAccessToken.java | 195 +++ .../android/sdk/cloud/ParticleCloud.java | 781 +++++++++++ .../sdk/cloud/ParticleCloudException.java | 225 +++ .../android/sdk/cloud/ParticleCloudSDK.java | 80 ++ .../android/sdk/cloud/ParticleDevice.java | 690 +++++++++ .../android/sdk/cloud/ParticleEvent.java | 35 + .../sdk/cloud/ParticleEventHandler.java | 8 + .../sdk/cloud/ParticleEventVisibility.java | 16 + .../android/sdk/cloud/ParticleUser.java | 69 + .../particle/android/sdk/cloud/Responses.java | 321 +++++ .../android/sdk/cloud/SDKGlobals.java | 42 + .../android/sdk/cloud/SDKProvider.java | 113 ++ .../sdk/cloud/SimpleParticleEventHandler.java | 9 + .../android/sdk/cloud/models/AccountInfo.java | 96 ++ .../sdk/cloud/models/DeviceStateChange.java | 60 + .../android/sdk/cloud/models/SignUpInfo.java | 91 ++ .../sdk/persistance/AppDataStorage.java | 41 + .../sdk/persistance/SensitiveDataStorage.java | 94 ++ .../io/particle/android/sdk/utils/Async.java | 155 ++ .../io/particle/android/sdk/utils/EZ.java | 129 ++ .../io/particle/android/sdk/utils/Funcy.java | 197 +++ .../android/sdk/utils/Parcelables.java | 91 ++ .../utils/ParticleInternalStringUtils.java | 74 + .../android/sdk/utils/Preconditions.java | 31 + .../io/particle/android/sdk/utils/Py.java | 409 ++++++ .../io/particle/android/sdk/utils/TLog.java | 77 + .../particle/android/sdk/utils/Toaster.java | 47 + .../gateway/bridge/ValidateOrigin.java | 26 + .../kaazing/gateway/client/impl/Channel.java | 64 + .../gateway/client/impl/CommandMessage.java | 26 + .../gateway/client/impl/DecoderInput.java | 31 + .../gateway/client/impl/DecoderListener.java | 32 + .../gateway/client/impl/EncoderOutput.java | 31 + .../kaazing/gateway/client/impl/Handler.java | 29 + .../gateway/client/impl/WebSocketChannel.java | 107 ++ .../gateway/client/impl/WebSocketHandler.java | 37 + .../client/impl/WebSocketHandlerAdapter.java | 78 ++ .../client/impl/WebSocketHandlerFactory.java | 28 + .../client/impl/WebSocketHandlerListener.java | 88 ++ .../client/impl/auth/AuthenticationUtil.java | 69 + .../client/impl/bridge/BridgeUtil.java | 191 +++ .../impl/bridge/ClassLoaderFactory.java | 67 + .../impl/bridge/HttpRequestBridgeHandler.java | 273 ++++ .../gateway/client/impl/bridge/Proxy.java | 79 ++ .../client/impl/bridge/ProxyListener.java | 30 + .../bridge/WebSocketNativeBridgeHandler.java | 201 +++ .../gateway/client/impl/bridge/XoaEvent.java | 118 ++ .../gateway/client/impl/http/HttpRequest.java | 183 +++ .../HttpRequestAuthenticationHandler.java | 340 +++++ .../impl/http/HttpRequestDelegateHandler.java | 209 +++ .../client/impl/http/HttpRequestEvent.java | 68 + .../client/impl/http/HttpRequestFactory.java | 31 + .../client/impl/http/HttpRequestHandler.java | 34 + .../impl/http/HttpRequestHandlerAdapter.java | 80 ++ .../impl/http/HttpRequestHandlerFactory.java | 29 + .../client/impl/http/HttpRequestListener.java | 49 + .../impl/http/HttpRequestLoggingHandler.java | 100 ++ .../impl/http/HttpRequestRedirectHandler.java | 154 ++ .../http/HttpRequestTransportHandler.java | 122 ++ .../client/impl/http/HttpRequestUtil.java | 66 + .../client/impl/http/HttpResponse.java | 84 ++ .../client/impl/util/WSCompositeURI.java | 113 ++ .../gateway/client/impl/util/WSURI.java | 70 + .../client/impl/util/WebSocketUtil.java | 113 ++ .../client/impl/ws/CloseCommandMessage.java | 57 + .../gateway/client/impl/ws/ReadyState.java | 29 + .../impl/ws/WebSocketCompositeChannel.java | 108 ++ .../impl/ws/WebSocketCompositeHandler.java | 432 ++++++ .../impl/ws/WebSocketHandshakeObject.java | 81 ++ .../impl/ws/WebSocketLoggingHandler.java | 148 ++ .../ws/WebSocketReAuthenticateHandler.java | 105 ++ .../impl/ws/WebSocketSelectedChannel.java | 49 + .../impl/ws/WebSocketSelectedHandler.java | 30 + .../impl/ws/WebSocketSelectedHandlerImpl.java | 217 +++ .../impl/ws/WebSocketTransportHandler.java | 156 +++ .../client/impl/wseb/CreateChannel.java | 56 + .../client/impl/wseb/CreateHandler.java | 34 + .../impl/wseb/CreateHandlerFactory.java | 26 + .../client/impl/wseb/CreateHandlerImpl.java | 217 +++ .../impl/wseb/CreateHandlerListener.java | 31 + .../client/impl/wseb/DownstreamChannel.java | 72 + .../client/impl/wseb/DownstreamHandler.java | 32 + .../impl/wseb/DownstreamHandlerFactory.java | 26 + .../impl/wseb/DownstreamHandlerImpl.java | 392 ++++++ .../impl/wseb/DownstreamHandlerListener.java | 36 + .../client/impl/wseb/UpstreamChannel.java | 51 + .../client/impl/wseb/UpstreamHandler.java | 36 + .../impl/wseb/UpstreamHandlerFactory.java | 26 + .../client/impl/wseb/UpstreamHandlerImpl.java | 225 +++ .../impl/wseb/UpstreamHandlerListener.java | 29 + .../impl/wseb/WebSocketEmulatedChannel.java | 46 + .../impl/wseb/WebSocketEmulatedDecoder.java | 30 + .../wseb/WebSocketEmulatedDecoderImpl.java | 187 +++ .../WebSocketEmulatedDecoderListener.java | 33 + .../impl/wseb/WebSocketEmulatedEncoder.java | 32 + .../wseb/WebSocketEmulatedEncoderImpl.java | 61 + .../impl/wseb/WebSocketEmulatedHandler.java | 341 +++++ .../WebSocketNativeAuthenticationHandler.java | 182 +++ .../wsn/WebSocketNativeBalancingHandler.java | 307 ++++ .../impl/wsn/WebSocketNativeChannel.java | 63 + .../client/impl/wsn/WebSocketNativeCodec.java | 114 ++ .../wsn/WebSocketNativeDelegateHandler.java | 206 +++ .../impl/wsn/WebSocketNativeEncoder.java | 33 + .../impl/wsn/WebSocketNativeEncoderImpl.java | 200 +++ .../impl/wsn/WebSocketNativeHandler.java | 149 ++ .../wsn/WebSocketNativeHandshakeHandler.java | 388 +++++ .../client/transport/AuthenticateEvent.java | 55 + .../client/transport/BridgeDelegate.java | 26 + .../gateway/client/transport/CloseEvent.java | 58 + .../gateway/client/transport/ErrorEvent.java | 43 + .../gateway/client/transport/Event.java | 78 ++ .../client/transport/IoBufferUtil.java | 52 + .../gateway/client/transport/LoadEvent.java | 50 + .../client/transport/MessageEvent.java | 84 ++ .../gateway/client/transport/OpenEvent.java | 65 + .../client/transport/ProgressEvent.java | 66 + .../transport/ReadyStateChangedEvent.java | 29 + .../client/transport/RedirectEvent.java | 55 + .../transport/http/HttpRequestDelegate.java | 43 + .../http/HttpRequestDelegateFactory.java | 28 + .../http/HttpRequestDelegateImpl.java | 350 +++++ .../http/HttpRequestDelegateListener.java | 40 + .../transport/http/HttpRequestUtil.java | 70 + .../client/transport/ws/Base64Util.java | 179 +++ .../client/transport/ws/BridgeSocket.java | 40 + .../transport/ws/BridgeSocketFactory.java | 30 + .../client/transport/ws/BridgeSocketImpl.java | 80 ++ .../client/transport/ws/FrameProcessor.java | 209 +++ .../transport/ws/FrameProcessorListener.java | 30 + .../transport/ws/WebSocketDelegate.java | 40 + .../ws/WebSocketDelegateFactory.java | 30 + .../transport/ws/WebSocketDelegateImpl.java | 1066 ++++++++++++++ .../ws/WebSocketDelegateListener.java | 40 + .../transport/ws/WsFrameEncodingSupport.java | 234 ++++ .../client/transport/ws/WsMessage.java | 58 + .../gateway/client/util/Base64Util.java | 179 +++ .../gateway/client/util/GenericURI.java | 95 ++ .../kaazing/gateway/client/util/HexUtil.java | 95 ++ .../kaazing/gateway/client/util/HttpURI.java | 66 + .../gateway/client/util/StringUtils.java | 122 ++ .../kaazing/gateway/client/util/URIUtils.java | 62 + .../client/util/WrappedByteBuffer.java | 1247 +++++++++++++++++ .../util/auth/LoginHandlerProvider.java | 40 + .../main/java/org/kaazing/net/URLFactory.java | 206 +++ .../net/URLStreamHandlerFactorySpi.java | 54 + .../net/auth/BasicChallengeHandler.java | 108 ++ .../kaazing/net/auth/ChallengeHandler.java | 118 ++ .../kaazing/net/auth/ChallengeRequest.java | 118 ++ .../kaazing/net/auth/ChallengeResponse.java | 95 ++ .../net/auth/DispatchChallengeHandler.java | 116 ++ .../org/kaazing/net/auth/LoginHandler.java | 63 + .../net/auth/NegotiableChallengeHandler.java | 98 ++ .../net/auth/NegotiateChallengeHandler.java | 96 ++ .../kaazing/net/http/HttpRedirectPolicy.java | 317 +++++ .../auth/BasicChallengeResponseFactory.java | 40 + .../auth/DefaultBasicChallengeHandler.java | 122 ++ .../auth/DefaultDispatchChallengeHandler.java | 784 +++++++++++ .../org/kaazing/net/impl/auth/RealmUtils.java | 65 + .../net/impl/util/BlockingQueueImpl.java | 137 ++ .../kaazing/net/impl/util/ResumableTimer.java | 133 ++ .../org/kaazing/net/sse/SseEventReader.java | 97 ++ .../org/kaazing/net/sse/SseEventSource.java | 96 ++ .../net/sse/SseEventSourceFactory.java | 97 ++ .../org/kaazing/net/sse/SseEventType.java | 26 + .../org/kaazing/net/sse/SseException.java | 37 + .../impl/AuthenticatedEventSourceFactory.java | 61 + .../impl/AuthenticatedSseEventSourceImpl.java | 277 ++++ .../sse/impl/AuthenticatedSseEventStream.java | 371 +++++ .../sse/impl/DefaultEventSourceFactory.java | 85 ++ .../net/sse/impl/SseEventReaderImpl.java | 205 +++ .../net/sse/impl/SseEventSourceImpl.java | 328 +++++ .../kaazing/net/sse/impl/SseEventStream.java | 362 +++++ .../net/sse/impl/SseEventStreamListener.java | 30 + .../org/kaazing/net/sse/impl/SsePayload.java | 53 + .../net/sse/impl/SseURLConnection.java | 100 ++ .../net/sse/impl/SseURLConnectionImpl.java | 281 ++++ .../net/sse/impl/legacy/EventSource.java | 123 ++ .../sse/impl/legacy/EventSourceAdapter.java | 47 + .../net/sse/impl/legacy/EventSourceEvent.java | 82 ++ .../net/sse/impl/legacy/EventSourceImpl.java | 180 +++ .../sse/impl/legacy/EventSourceListener.java | 53 + .../SseURLStreamHandlerFactorySpiImpl.java | 50 + .../sse/impl/url/SseURLStreamHandlerImpl.java | 79 ++ .../java/org/kaazing/net/ws/WebSocket.java | 433 ++++++ .../kaazing/net/ws/WebSocketException.java | 86 ++ .../kaazing/net/ws/WebSocketExtension.java | 315 +++++ .../org/kaazing/net/ws/WebSocketFactory.java | 211 +++ .../net/ws/WebSocketMessageReader.java | 124 ++ .../kaazing/net/ws/WebSocketMessageType.java | 31 + .../net/ws/WebSocketMessageWriter.java | 61 + .../org/kaazing/net/ws/WsURLConnection.java | 425 ++++++ .../net/ws/impl/DefaultWebSocketFactory.java | 219 +++ .../kaazing/net/ws/impl/WebSocketImpl.java | 1188 ++++++++++++++++ .../WsExtensionParameterValuesSpiImpl.java | 68 + .../net/ws/impl/WsURLConnectionImpl.java | 367 +++++ .../net/ws/impl/io/WsInputStreamImpl.java | 172 +++ .../net/ws/impl/io/WsMessagePullParser.java | 88 ++ .../ws/impl/io/WsMessageReaderAdapter.java | 82 ++ .../net/ws/impl/io/WsMessageReaderImpl.java | 187 +++ .../net/ws/impl/io/WsMessageWriterImpl.java | 76 + .../net/ws/impl/io/WsOutputStreamImpl.java | 96 ++ .../kaazing/net/ws/impl/io/WsReaderImpl.java | 149 ++ .../kaazing/net/ws/impl/io/WsWriterImpl.java | 119 ++ .../spi/WebSocketExtensionFactorySpi.java | 64 + .../spi/WebSocketExtensionHandlerSpi.java | 108 ++ .../WebSocketExtensionParameterValuesSpi.java | 60 + .../ws/impl/spi/WebSocketExtensionSpi.java | 52 + .../url/WsURLStreamHandlerFactorySpiImpl.java | 78 ++ .../ws/impl/url/WsURLStreamHandlerImpl.java | 97 ++ .../WssURLStreamHandlerFactorySpiImpl.java | 46 + .../ws/impl/url/WssURLStreamHandlerImpl.java | 39 + cloudsdk/src/main/res/values/config.xml | 14 + .../main/res/values/oauth_client_creds.xml | 8 + ...org.kaazing.net.URLStreamHandlerFactorySpi | 2 + ...org.kaazing.net.auth.BasicChallengeHandler | 1 + ....kaazing.net.auth.DispatchChallengeHandler | 1 + .../org.kaazing.net.sse.SseEventSourceFactory | 1 + .../org.kaazing.net.ws.WebSocketFactory | 1 + pom_generator_v1.gradle | 44 + settings.gradle | 1 + 241 files changed, 29952 insertions(+), 10 deletions(-) create mode 100644 bintray_upload_v1.gradle create mode 100644 cloudsdk/.gitignore create mode 100644 cloudsdk/build.gradle create mode 100644 cloudsdk/consumer-proguard-rules.pro create mode 100644 cloudsdk/proguard-rules.pro create mode 100644 cloudsdk/releasing.md create mode 100644 cloudsdk/src/androidTest/java/io/particle/android/sdk/cloud/ApplicationTest.java create mode 100644 cloudsdk/src/main/AndroidManifest.xml create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiDefs.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiFactory.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/BroadcastContract.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/DeviceState.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/EventsDelegate.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/FunctionArgs.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParallelDeviceFetcher.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleAccessToken.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloud.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudException.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleCloudSDK.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleDevice.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEvent.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventHandler.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleEventVisibility.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/ParticleUser.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/Responses.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKGlobals.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/SDKProvider.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/SimpleParticleEventHandler.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/AccountInfo.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/DeviceStateChange.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/cloud/models/SignUpInfo.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/persistance/AppDataStorage.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/persistance/SensitiveDataStorage.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/Async.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/EZ.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/Funcy.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/Parcelables.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/ParticleInternalStringUtils.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/Preconditions.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/Py.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/TLog.java create mode 100644 cloudsdk/src/main/java/io/particle/android/sdk/utils/Toaster.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/bridge/ValidateOrigin.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Channel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/CommandMessage.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderInput.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/DecoderListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/EncoderOutput.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/Handler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerAdapter.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/WebSocketHandlerListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/auth/AuthenticationUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/BridgeUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ClassLoaderFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/HttpRequestBridgeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/Proxy.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/ProxyListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/WebSocketNativeBridgeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/bridge/XoaEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequest.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestAuthenticationHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestDelegateHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerAdapter.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestHandlerFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestLoggingHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestRedirectHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestTransportHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpRequestUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/http/HttpResponse.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSCompositeURI.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WSURI.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/util/WebSocketUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/CloseCommandMessage.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/ReadyState.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketCompositeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketHandshakeObject.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketLoggingHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketReAuthenticateHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketSelectedHandlerImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/ws/WebSocketTransportHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/CreateHandlerListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/DownstreamHandlerListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/UpstreamHandlerListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoder.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedDecoderListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoder.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedEncoderImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wseb/WebSocketEmulatedHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeAuthenticationHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeBalancingHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeChannel.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeCodec.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeDelegateHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoder.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeEncoderImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/impl/wsn/WebSocketNativeHandshakeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/AuthenticateEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/BridgeDelegate.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/CloseEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ErrorEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/Event.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/IoBufferUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/LoadEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/MessageEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/OpenEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ProgressEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ReadyStateChangedEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/RedirectEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegate.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestDelegateListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/http/HttpRequestUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/Base64Util.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocket.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/BridgeSocketImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessor.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/FrameProcessorListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegate.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WebSocketDelegateListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsFrameEncodingSupport.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/transport/ws/WsMessage.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/Base64Util.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/GenericURI.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/HexUtil.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/HttpURI.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/StringUtils.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/URIUtils.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/WrappedByteBuffer.java create mode 100644 cloudsdk/src/main/java/org/kaazing/gateway/client/util/auth/LoginHandlerProvider.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/URLFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/URLStreamHandlerFactorySpi.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/BasicChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeRequest.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/ChallengeResponse.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/DispatchChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/LoginHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/NegotiableChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/auth/NegotiateChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/http/HttpRedirectPolicy.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/impl/auth/BasicChallengeResponseFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultBasicChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/impl/auth/DefaultDispatchChallengeHandler.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/impl/auth/RealmUtils.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/impl/util/BlockingQueueImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/impl/util/ResumableTimer.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/SseEventReader.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSource.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/SseEventSourceFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/SseEventType.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/SseException.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedEventSourceFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventSourceImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/AuthenticatedSseEventStream.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/DefaultEventSourceFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventReaderImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventSourceImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStream.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseEventStreamListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SsePayload.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnection.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/SseURLConnectionImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSource.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceAdapter.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceEvent.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/legacy/EventSourceListener.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerFactorySpiImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/sse/impl/url/SseURLStreamHandlerImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocket.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketException.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketExtension.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageReader.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageType.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WebSocketMessageWriter.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/WsURLConnection.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/DefaultWebSocketFactory.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/WebSocketImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsExtensionParameterValuesSpiImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/WsURLConnectionImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsInputStreamImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessagePullParser.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderAdapter.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageReaderImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsMessageWriterImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsOutputStreamImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsReaderImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/io/WsWriterImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionFactorySpi.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionHandlerSpi.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionParameterValuesSpi.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/spi/WebSocketExtensionSpi.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerFactorySpiImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WsURLStreamHandlerImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerFactorySpiImpl.java create mode 100644 cloudsdk/src/main/java/org/kaazing/net/ws/impl/url/WssURLStreamHandlerImpl.java create mode 100644 cloudsdk/src/main/res/values/config.xml create mode 100644 cloudsdk/src/main/res/values/oauth_client_creds.xml create mode 100644 cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.URLStreamHandlerFactorySpi create mode 100644 cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.BasicChallengeHandler create mode 100644 cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.auth.DispatchChallengeHandler create mode 100644 cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.sse.SseEventSourceFactory create mode 100644 cloudsdk/src/main/resources/META-INF/services/org.kaazing.net.ws.WebSocketFactory create mode 100644 pom_generator_v1.gradle diff --git a/.idea/gradle.xml b/.idea/gradle.xml index fe72da5..21c27e8 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -10,6 +10,7 @@ - + diff --git a/.idea/modules.xml b/.idea/modules.xml index c480f6a..f396e7d 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -4,6 +4,7 @@ + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 2e3add3..de8800a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,12 +1,12 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 23 - buildToolsVersion '25.0.0' + compileSdkVersion 25 + buildToolsVersion '25.0.2' defaultConfig { applicationId "com.umarbhutta.xlightcompanion" minSdkVersion 19 - targetSdkVersion 23 + targetSdkVersion 25 versionCode 1 versionName "1.0" } @@ -27,13 +27,25 @@ android { dependencies { compile fileTree(include: ['*.jar'], dir: 'libs') testCompile 'junit:junit:4.12' - compile 'com.android.support:appcompat-v7:23.4.0' - compile 'com.android.support:design:23.4.0' - compile 'com.android.support:support-v4:23.4.0' - compile 'com.android.support:recyclerview-v7:23.4.0' + compile 'com.android.support:appcompat-v7:25.2.0' + compile 'com.android.support:support-fragment:25.2.0' + compile 'com.android.support:design:25.2.0' + compile 'com.android.support:support-v4:25.2.0' + compile 'com.android.support:recyclerview-v7:25.2.0' compile 'com.github.clans:fab:1.6.4' compile 'com.squareup.okhttp3:okhttp:3.4.1' - compile 'io.particle:cloudsdk:0.3.4' + + // BY DEFAULT, BUILD APP AGAINST THE LOCAL SDK SOURCE + // (i.e.: make modifications to the SDK source in the local repo show up in this app + // just by rebuilding) + compile project(':cloudsdk') + // + // **OR** + // + // comment out the above, and + // UNCOMMENT THE FOLLOWING TO USE A PUBLISHED VERSION OF THE SDK: + //compile 'io.particle:cloudsdk:0.3.4' + compile 'io.particle:devicesetup:0.3.6' compile 'me.priyesh:chroma:1.0.2' } diff --git a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java index 2dba5b6..145111b 100644 --- a/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java +++ b/app/src/main/java/com/umarbhutta/xlightcompanion/main/MainActivity.java @@ -30,7 +30,7 @@ public class MainActivity extends AppCompatActivity //constants for testing lists public static final String[] deviceNames = {"Living Room", "Bedroom", "Bar"}; - public static final int[] deviceNodeIDs = {1, 10, 11}; + public static final int[] deviceNodeIDs = {1, 8, 11}; public static final String[] scheduleTimes = {"10:30 AM", "12:45 PM", "02:00 PM", "06:45 PM", "08:00 PM", "11:30 PM"}; public static final String[] scheduleDays = {"Mo Tu We Th Fr", "Every day", "Mo We Th Sa Su", "Tomorrow", "We", "Mo Tu Fr Sa Su"}; public static final String[] scenarioNames = {"Brunching", "Guests", "Naptime", "Dinner", "Sunset", "Bedtime"}; diff --git a/bintray_upload_v1.gradle b/bintray_upload_v1.gradle new file mode 100644 index 0000000..4d255ed --- /dev/null +++ b/bintray_upload_v1.gradle @@ -0,0 +1,64 @@ +// lifted from https://raw.githubusercontent.com/nuuneoi/JCenter/master/bintrayv1.gradle +// and copied into the repo for safety/stability +apply plugin: 'com.jfrog.bintray' + +version = libraryVersion + +task sourcesJar(type: Jar) { + from android.sourceSets.main.java.srcDirs + classifier = 'sources' +} + +task javadoc(type: Javadoc) { + source = android.sourceSets.main.java.srcDirs + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +artifacts { + archives javadocJar + archives sourcesJar +} + +// FIXME: this feels hackish, but it works for now, and it shouldn't +// have any side effects* for anyone building the lib locally, +// so #SHIPIT +// +// * My apologies if this turns out not to be true. Patches welcome! +Properties authDataProps = new Properties() +try { + authDataProps.load(project.rootProject.file('../bintray_user_auth_secrets.properties').newDataInputStream()) +} catch (Exception e) { + // do nothing; this is the default state for everyone who isn't publishing + // the lib. +} + +bintray { + user = authDataProps.getProperty("bintray.user") + key = authDataProps.getProperty("bintray.apikey") + + configurations = ['archives'] + pkg { + userOrg = bintrayOrg + repo = bintrayRepo + name = bintrayName + desc = libraryDescription + websiteUrl = siteUrl + vcsUrl = gitUrl + licenses = allLicenses + publish = true + publicDownloadNumbers = false + version { +// desc = libraryDescription + gpg { + sign = false // Determines whether to GPG sign the files. The default is false + // passphrase = properties.getProperty("bintray.gpg.password") // Optional. The passphrase for GPG signing' + } + } + } +} + diff --git a/build.gradle b/build.gradle index 1ea4bd0..d17194e 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,10 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:2.3.0' + classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.7.3' + classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5' + classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.6.2' + classpath 'me.tatarka:gradle-retrolambda:3.4.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/cloudsdk/.gitignore b/cloudsdk/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/cloudsdk/.gitignore @@ -0,0 +1 @@ +/build diff --git a/cloudsdk/build.gradle b/cloudsdk/build.gradle new file mode 100644 index 0000000..3d2e280 --- /dev/null +++ b/cloudsdk/build.gradle @@ -0,0 +1,92 @@ +apply plugin: 'com.android.library' +apply plugin: 'me.tatarka.retrolambda' + +// This is the library version used when deploying the artifact +version = '0.4.1' + +ext { + bintrayRepo = 'android' + bintrayName = 'cloud-sdk' + bintrayOrg = 'particle' + + publishedGroupId = 'io.particle' + libraryName = 'Particle (formerly Spark) Android Cloud SDK library ' + artifact = 'cloudsdk' + + libraryDescription = 'Particle (formerly Spark) Android Cloud SDK library\n' + + 'The Particle Android Cloud SDK enables Android apps to interact with Particle-powered connected products via the Particle Cloud.\n' + + 'Library will allow you to easily manage active user sessions to Particle cloud, query for device info,\n' + + 'read and write data to/from Particle Core/Photon devices and (via exposed variables and functions)\n' + + 'publish and subscribe events to/from the cloud or to/from devices (coming soon).' + + siteUrl = 'https://github.com/spark/spark-sdk-android' + gitUrl = 'https://github.com/spark/spark-sdk-android.git' + + libraryVersion = project.version + + developerId = 'idok' + developerName = 'Ido Kleinman' + developerEmail = 'ido@particle.io' + + licenseName = 'The Apache Software License, Version 2.0' + licenseUrl = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + allLicenses = ["Apache-2.0"] +} + + +android { + compileSdkVersion 25 + buildToolsVersion '25.0.2' + + dexOptions { + javaMaxHeapSize "2g" + } + + defaultConfig { + minSdkVersion 15 + targetSdkVersion 25 + versionCode 1 + versionName project.version + consumerProguardFiles 'consumer-proguard-rules.pro' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + disable 'InvalidPackage' + } + +} + +dependencies { + compile fileTree(include: ['*.jar'], dir: 'libs') + + compile 'com.google.code.findbugs:jsr305:3.0.1' + compile 'com.google.code.gson:gson:2.7' + compile 'com.squareup.okhttp:okhttp:2.7.5' + compile 'com.squareup.okio:okio:1.9.0' + compile 'com.squareup.retrofit:retrofit:1.9.0' + compile 'org.greenrobot:eventbus:3.0.0' + + compile 'com.android.support:support-fragment:25.2.0' + + retrolambdaConfig 'net.orfjackal.retrolambda:retrolambda:2.3.0' +} + +apply from: '../pom_generator_v1.gradle' +apply from: '../bintray_upload_v1.gradle' + + +// disable insane, build-breaking doclint tool in Java 8 +if (JavaVersion.current().isJava8Compatible()) { + tasks.withType(Javadoc) { + //noinspection SpellCheckingInspection + options.addStringOption('Xdoclint:none', '-quiet') + } +} + + +apply plugin: 'com.getkeepsafe.dexcount' diff --git a/cloudsdk/consumer-proguard-rules.pro b/cloudsdk/consumer-proguard-rules.pro new file mode 100644 index 0000000..4fd6809 --- /dev/null +++ b/cloudsdk/consumer-proguard-rules.pro @@ -0,0 +1 @@ +-dontwarn okio.** diff --git a/cloudsdk/proguard-rules.pro b/cloudsdk/proguard-rules.pro new file mode 100644 index 0000000..9d244f6 --- /dev/null +++ b/cloudsdk/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/jensck/Library/android-sdk-linux/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/cloudsdk/releasing.md b/cloudsdk/releasing.md new file mode 100644 index 0000000..64c18c7 --- /dev/null +++ b/cloudsdk/releasing.md @@ -0,0 +1,17 @@ +# Making official releases + +These are the steps for releasing an updated version of the Particle SDK. +For example, if you were releasing version `2.4.2`, you'd do the following: + +1. Make sure the CHANGELOG is current +2. Pull from origin to ensure you have the latest upstream changes +3. Update the `version` field in `cloudsdk/build.gradle` to `'2.4.2'` +4. Build a release and publish it to JCenter. From the `cloudsdk` dir, +do: `../gradlew clean build install bintrayUpload` +5. Submit a PR to the docs site updating the version code in `android.md` to `2.4.2` +6. Update the example app to pull the new version from JCenter, clean its build, and +then build & run the example app as a final smoke test. +7. Commit and push the previous two changes +8. Tag the release: `git tag v2.4.2` (note the "v" at the beginning) +9. Push the tag: `git push origin v2.4.2` (again, note the "v") +10. (if applicable) announce the update via the appropriate channels diff --git a/cloudsdk/src/androidTest/java/io/particle/android/sdk/cloud/ApplicationTest.java b/cloudsdk/src/androidTest/java/io/particle/android/sdk/cloud/ApplicationTest.java new file mode 100644 index 0000000..344ea05 --- /dev/null +++ b/cloudsdk/src/androidTest/java/io/particle/android/sdk/cloud/ApplicationTest.java @@ -0,0 +1,13 @@ +package io.particle.android.sdk.cloud; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * Testing Fundamentals + */ +public class ApplicationTest extends ApplicationTestCase { + public ApplicationTest() { + super(Application.class); + } +} \ No newline at end of file diff --git a/cloudsdk/src/main/AndroidManifest.xml b/cloudsdk/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a704bac --- /dev/null +++ b/cloudsdk/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiDefs.java b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiDefs.java new file mode 100644 index 0000000..77a9457 --- /dev/null +++ b/cloudsdk/src/main/java/io/particle/android/sdk/cloud/ApiDefs.java @@ -0,0 +1,145 @@ +package io.particle.android.sdk.cloud; + +import java.util.List; + +import io.particle.android.sdk.cloud.Responses.CallFunctionResponse; +import io.particle.android.sdk.cloud.Responses.ClaimCodeResponse; +import io.particle.android.sdk.cloud.Responses.Models; +import io.particle.android.sdk.cloud.Responses.ReadDoubleVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadIntVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadObjectVariableResponse; +import io.particle.android.sdk.cloud.Responses.ReadStringVariableResponse; +import io.particle.android.sdk.cloud.Responses.SimpleResponse; +import io.particle.android.sdk.cloud.models.SignUpInfo; +import retrofit.client.Response; +import retrofit.http.Body; +import retrofit.http.DELETE; +import retrofit.http.Field; +import retrofit.http.FormUrlEncoded; +import retrofit.http.GET; +import retrofit.http.Multipart; +import retrofit.http.POST; +import retrofit.http.PUT; +import retrofit.http.Part; +import retrofit.http.Path; +import retrofit.mime.TypedOutput; + + +/** + * Particle cloud REST APIs, modelled for the Retrofit library + */ +public class ApiDefs { + + // FIXME: turn some of these common strings into constants? + + /** + * The main Particle cloud API + */ + public interface CloudApi { + + @GET("/v1/devices") + List getDevices(); + + @GET("/v1/devices/{deviceID}") + Models.CompleteDevice getDevice(@Path("deviceID") String deviceID); + + // FIXME: put a real response type on this? + @FormUrlEncoded + @PUT("/v1/devices/{deviceID}") + Response nameDevice(@Path("deviceID") String deviceID, + @Field("name") String name); + + @FormUrlEncoded + @PUT("/v1/devices/{deviceID}") + Response flashKnownApp(@Path("deviceID") String deviceID, + @Field("app") String appName); + + @Multipart + @PUT("/v1/devices/{deviceID}") + Response flashFile(@Path("deviceID") String deviceID, + @Part("file") TypedOutput file); + + @POST("/v1/devices/{deviceID}/{function}") + CallFunctionResponse callFunction(@Path("deviceID") String deviceID, + @Path("function") String function, + @Body FunctionArgs args); + + @GET("/v1/devices/{deviceID}/{variable}") + ReadObjectVariableResponse getVariable(@Path("deviceID") String deviceID, + @Path("variable") String variable); + + @GET("/v1/devices/{deviceID}/{variable}") + ReadIntVariableResponse getIntVariable(@Path("deviceID") String deviceID, + @Path("variable") String variable); + + @GET("/v1/devices/{deviceID}/{variable}") + ReadStringVariableResponse getStringVariable(@Path("deviceID") String deviceID, + @Path("variable") String variable); + + @GET("/v1/devices/{deviceID}/{variable}") + ReadDoubleVariableResponse getDoubleVariable(@Path("deviceID") String deviceID, + @Path("variable") String variable); + + @FormUrlEncoded + @POST("/v1/devices/events") + SimpleResponse publishEvent(@Field("name") String eventName, + @Field("data") String eventData, + @Field("private") boolean isPrivate, + @Field("ttl") int timeToLive); + + /** + * Newer versions of OkHttp require a body for POSTs, but just pass in + * a blank string for the body and all is well. + */ + @FormUrlEncoded + @POST("/v1/device_claims") + ClaimCodeResponse generateClaimCode(@Field("blank") String blankBody); + + @FormUrlEncoded + @POST("/v1/orgs/{orgSlug}/products/{productSlug}/device_claims") + ClaimCodeResponse generateClaimCodeForOrg(@Field("blank") String blankBody, + @Path("orgSlug") String orgSlug, + @Path("productSlug") String productSlug); + + @FormUrlEncoded + @POST("/v1/devices") + SimpleResponse claimDevice(@Field("id") String deviceID); + + @DELETE("/v1/devices/{deviceID}") + SimpleResponse unclaimDevice(@Path("deviceID") String deviceID); + + } + + /** + * APIs dealing with identity and authorization + *