Skip to content

Instantly share code, notes, and snippets.

@kuhar
Created March 11, 2014 11:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kuhar/9483699 to your computer and use it in GitHub Desktop.
Save kuhar/9483699 to your computer and use it in GitHub Desktop.
cocos2d-x 2.x HttpClient - checking file size before downloading
#ifndef KL_HTTP_CLIENT_H
#define KL_HTTP_CLIENT_H
#include "cocos2d.h"
#include "cocos-ext.h"
#include "ExtensionMacros.h"
USING_NS_CC;
USING_NS_CC_EXT;
#include "downloader/HttpRequest.h"
#include "downloader/HttpResponse.h"
namespace KoalaLib
{
/** @brief Singleton that handles asynchrounous http requests
* Once the request completed, a callback will issued in main thread when it provided during make request
*/
class HttpClient : public CCObject
{
public:
static HttpClient* getInstance();
static void destroyInstance();
void send ( HttpRequest* request );
inline void setTimeoutForConnect ( int value )
{
_timeoutForConnect = value;
};
inline int getTimeoutForConnect()
{
return _timeoutForConnect;
}
inline void setTimeoutForRead ( int value )
{
_timeoutForRead = value;
};
inline int getTimeoutForRead()
{
return _timeoutForRead;
};
private:
HttpClient();
virtual ~HttpClient();
bool init ( void );
bool lazyInitThreadSemphore();
/** Poll function called from main thread to dispatch callbacks when http requests finished **/
void dispatchResponseCallbacks ( float delta );
int _timeoutForConnect;
int _timeoutForRead;
};
} /* namespace KoalaLib */
#endif //KL_HTTP_CLIENT_H
#include "downloader/HttpClient.h"
#if ( CC_TARGET_PLATFORM != CC_PLATFORM_WP8 )
#include <queue>
#include <pthread.h>
#include <errno.h>
#include "curl/curl.h"
namespace KoalaLib
{
static pthread_t s_networkThread;
static pthread_mutex_t s_requestQueueMutex;
static pthread_mutex_t s_responseQueueMutex;
static pthread_mutex_t s_SleepMutex;
static pthread_cond_t s_SleepCondition;
static unsigned long s_asyncRequestCount = 0;
#if (CC_TARGET_PLATFORM == CC_PLATFORM_WIN32)
typedef int int32_t;
#endif
static bool need_quit = false;
static CCArray* s_requestQueue = NULL;
static CCArray* s_responseQueue = NULL;
static HttpClient* s_pHttpClient = NULL; // pointer to singleton
static char s_errorBuffer[CURL_ERROR_SIZE];
typedef size_t ( *write_callback ) ( void* ptr, size_t size, size_t nmemb,
void* stream );
// Callback function used by libcurl for collect response data
static size_t writeData ( void* ptr, size_t size, size_t nmemb, void* stream )
{
std::vector<char>* recvBuffer = ( std::vector<char>* ) stream;
size_t sizes = size * nmemb;
// add data to the end of recvBuffer
// write data maybe called more than once in a single request
recvBuffer->insert ( recvBuffer->end(), ( char* ) ptr, ( char* ) ptr + sizes );
return sizes;
}
// Callback function used by libcurl for collect header data
static size_t writeHeaderData ( void* ptr, size_t size, size_t nmemb,
void* stream )
{
std::vector<char>* recvBuffer = ( std::vector<char>* ) stream;
size_t sizes = size * nmemb;
// add data to the end of recvBuffer
// write data maybe called more than once in a single request
recvBuffer->insert ( recvBuffer->end(), ( char* ) ptr, ( char* ) ptr + sizes );
return sizes;
}
static int processGetTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* errorCode, write_callback headerCallback,
void* headerStream );
static int processSizeTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* errorCode, write_callback headerCallback,
void* headerStream );
static int processPostTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* errorCode, write_callback headerCallback,
void* headerStream );
static int processPutTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* errorCode, write_callback headerCallback,
void* headerStream );
static int processDeleteTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* errorCode, write_callback headerCallback,
void* headerStream );
// int processDownloadTask(HttpRequest *task, write_callback callback, void *stream, int32_t *errorCode);
// Worker thread
static void* networkThread ( void* data )
{
HttpRequest* request = NULL;
while ( true )
{
if ( need_quit )
{
break;
}
// step 1: send http request if the requestQueue isn't empty
request = NULL;
pthread_mutex_lock ( &s_requestQueueMutex ); //Get request task from queue
if ( 0 != s_requestQueue->count() )
{
request = dynamic_cast<HttpRequest*> ( s_requestQueue->objectAtIndex ( 0 ) );
s_requestQueue->removeObjectAtIndex ( 0 );
// request's refcount = 1 here
}
pthread_mutex_unlock ( &s_requestQueueMutex );
if ( NULL == request )
{
// Wait for http request tasks from main thread
pthread_cond_wait ( &s_SleepCondition, &s_SleepMutex );
continue;
}
// step 2: libcurl sync access
// Create a HttpResponse object, the default setting is http access failed
HttpResponse* response = new HttpResponse ( request );
// request's refcount = 2 here, it's retained by HttpRespose constructor
request->release();
// ok, refcount = 1 now, only HttpResponse hold it.
int32_t responseCode = -1;
int retValue = 0;
// Process the request -> get response packet
switch ( request->getRequestType() )
{
case HttpRequest::kHttpGet: // HTTP GET
retValue = processGetTask ( request,
writeData,
response->getResponseData(),
&responseCode,
writeHeaderData,
response->getResponseHeader() );
break;
case HttpRequest::kHttpSize: // HTTP GET REMOTE FILE SIZE
retValue = processSizeTask ( request,
writeData,
response->getResponseData(),
&responseCode,
writeHeaderData,
response->getResponseHeader() );
break;
case HttpRequest::kHttpPost: // HTTP POST
retValue = processPostTask ( request,
writeData,
response->getResponseData(),
&responseCode,
writeHeaderData,
response->getResponseHeader() );
break;
case HttpRequest::kHttpPut:
retValue = processPutTask ( request,
writeData,
response->getResponseData(),
&responseCode,
writeHeaderData,
response->getResponseHeader() );
break;
case HttpRequest::kHttpDelete:
retValue = processDeleteTask ( request,
writeData,
response->getResponseData(),
&responseCode,
writeHeaderData,
response->getResponseHeader() );
break;
default:
CCAssert ( true,
"HttpClient: unkown request type, only GET, POST and SIZE are supported" );
break;
}
// write data to HttpResponse
response->setResponseCode ( responseCode );
if ( retValue != 0 )
{
response->setSucceed ( false );
response->setErrorBuffer ( s_errorBuffer );
}
else
{
response->setSucceed ( true );
}
// add response packet into queue
pthread_mutex_lock ( &s_responseQueueMutex );
s_responseQueue->addObject ( response );
pthread_mutex_unlock ( &s_responseQueueMutex );
// resume dispatcher selector
CCDirector::sharedDirector()->getScheduler()->resumeTarget (
HttpClient::getInstance() );
}
// cleanup: if worker thread received quit signal, clean up un-completed request queue
pthread_mutex_lock ( &s_requestQueueMutex );
s_requestQueue->removeAllObjects();
pthread_mutex_unlock ( &s_requestQueueMutex );
s_asyncRequestCount -= s_requestQueue->count();
if ( s_requestQueue != NULL )
{
pthread_mutex_destroy ( &s_requestQueueMutex );
pthread_mutex_destroy ( &s_responseQueueMutex );
pthread_mutex_destroy ( &s_SleepMutex );
pthread_cond_destroy ( &s_SleepCondition );
s_requestQueue->release();
s_requestQueue = NULL;
s_responseQueue->release();
s_responseQueue = NULL;
}
pthread_exit ( NULL );
return 0;
}
//Configure curl's timeout property
static bool configureCURL ( CURL* handle )
{
if ( !handle )
{
return false;
}
int32_t code;
code = curl_easy_setopt ( handle, CURLOPT_ERRORBUFFER, s_errorBuffer );
if ( code != CURLE_OK )
{
return false;
}
code = curl_easy_setopt ( handle, CURLOPT_TIMEOUT,
HttpClient::getInstance()->getTimeoutForRead() );
if ( code != CURLE_OK )
{
return false;
}
code = curl_easy_setopt ( handle, CURLOPT_CONNECTTIMEOUT,
HttpClient::getInstance()->getTimeoutForConnect() );
if ( code != CURLE_OK )
{
return false;
}
curl_easy_setopt ( handle, CURLOPT_SSL_VERIFYPEER, 0L );
curl_easy_setopt ( handle, CURLOPT_SSL_VERIFYHOST, 0L );
return true;
}
class CURLRaii
{
/// Instance of CURL
CURL* m_curl;
/// Keeps custom header data
curl_slist* m_headers;
int m_downloadLength;
public:
CURLRaii() :
m_curl ( curl_easy_init() ),
m_headers ( NULL ),
m_downloadLength ( -1 )
{
}
~CURLRaii()
{
if ( m_curl )
{
curl_easy_cleanup ( m_curl );
}
/* free the linked list for header data */
if ( m_headers )
{
curl_slist_free_all ( m_headers );
}
}
template <class T>
bool setOption ( CURLoption option, T data )
{
return CURLE_OK == curl_easy_setopt ( m_curl, option, data );
}
/**
* @brief Inits CURL instance for common usage
* @param request Null not allowed
* @param callback Response write callback
* @param stream Response write stream
*/
bool init ( HttpRequest* request, write_callback callback, void* stream,
write_callback headerCallback, void* headerStream )
{
if ( !m_curl )
{
return false;
}
if ( !configureCURL ( m_curl ) )
{
return false;
}
/* get custom header data (if set) */
std::vector<std::string> headers = request->getHeaders();
if ( !headers.empty() )
{
/* append custom headers one by one */
for ( std::vector<std::string>::iterator it = headers.begin();
it != headers.end(); ++it )
{
m_headers = curl_slist_append ( m_headers, it->c_str() );
}
/* set custom headers for curl */
if ( !setOption ( CURLOPT_HTTPHEADER, m_headers ) )
{
return false;
}
}
return setOption ( CURLOPT_URL, request->getUrl() )
&& setOption ( CURLOPT_WRITEFUNCTION, callback )
&& setOption ( CURLOPT_WRITEDATA, stream )
&& setOption ( CURLOPT_HEADERFUNCTION, headerCallback )
&& setOption ( CURLOPT_HEADERDATA, headerStream );
}
/// @param responseCode Null not allowed
bool perform ( int* responseCode )
{
if ( CURLE_OK != curl_easy_perform ( m_curl ) )
{
return false;
}
CURLcode code = curl_easy_getinfo ( m_curl, CURLINFO_RESPONSE_CODE,
responseCode );
if ( code != CURLE_OK || *responseCode != 200 )
{
return false;
}
// Get some moar data.
double res = -1; //why the is that returned as double!!!????
curl_easy_getinfo ( m_curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD, &res );
m_downloadLength = res;
return true;
}
int getDownloadLenght()
{
return m_downloadLength;
}
};
//Process Get Request
static int processGetTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* responseCode, write_callback headerCallback,
void* headerStream )
{
CURLRaii curl;
bool ok = curl.init ( request, callback, stream, headerCallback, headerStream )
&& curl.setOption ( CURLOPT_FOLLOWLOCATION, true )
&& curl.perform ( responseCode );
return ok ? 0 : 1;
}
//Process Size Request
static int processSizeTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* responseCode, write_callback headerCallback,
void* headerStream )
{
CURLRaii curl;
bool ok = curl.init ( request, callback, stream, headerCallback, headerStream )
&& curl.setOption ( CURLOPT_HEADER, true )
&& curl.setOption ( CURLOPT_NOBODY, true )
&& curl.setOption ( CURLOPT_FOLLOWLOCATION, true )
&& curl.perform ( responseCode );
CCLog ( "Download lenght: %d\n", curl.getDownloadLenght() );
request->setUserData ( ( void* ) ( long ) curl.getDownloadLenght() );
return ok ? 0 : 1;
}
//Process POST Request
static int processPostTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* responseCode, write_callback headerCallback,
void* headerStream )
{
CURLRaii curl;
bool ok = curl.init ( request, callback, stream, headerCallback, headerStream )
&& curl.setOption ( CURLOPT_POST, 1 )
&& curl.setOption ( CURLOPT_POSTFIELDS, request->getRequestData() )
&& curl.setOption ( CURLOPT_POSTFIELDSIZE, request->getRequestDataSize() )
&& curl.perform ( responseCode );
return ok ? 0 : 1;
}
//Process PUT Request
static int processPutTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* responseCode, write_callback headerCallback,
void* headerStream )
{
CURLRaii curl;
bool ok = curl.init ( request, callback, stream, headerCallback, headerStream )
&& curl.setOption ( CURLOPT_CUSTOMREQUEST, "PUT" )
&& curl.setOption ( CURLOPT_POSTFIELDS, request->getRequestData() )
&& curl.setOption ( CURLOPT_POSTFIELDSIZE, request->getRequestDataSize() )
&& curl.perform ( responseCode );
return ok ? 0 : 1;
}
//Process DELETE Request
static int processDeleteTask ( HttpRequest* request, write_callback callback,
void* stream, int32_t* responseCode, write_callback headerCallback,
void* headerStream )
{
CURLRaii curl;
bool ok = curl.init ( request, callback, stream, headerCallback, headerStream )
&& curl.setOption ( CURLOPT_CUSTOMREQUEST, "DELETE" )
&& curl.setOption ( CURLOPT_FOLLOWLOCATION, true )
&& curl.perform ( responseCode );
return ok ? 0 : 1;
}
// HttpClient implementation
HttpClient* HttpClient::getInstance()
{
if ( s_pHttpClient == NULL )
{
s_pHttpClient = new
HttpClient();
}
return s_pHttpClient;
}
void HttpClient::destroyInstance()
{
CCAssert ( s_pHttpClient, "" );
CCDirector::sharedDirector()->getScheduler()->unscheduleSelector (
schedule_selector (
HttpClient::dispatchResponseCallbacks ), s_pHttpClient );
s_pHttpClient->release();
}
HttpClient::HttpClient()
: _timeoutForConnect ( 30 )
, _timeoutForRead ( 60 )
{
CCDirector::sharedDirector()->getScheduler()->scheduleSelector (
schedule_selector (
HttpClient::dispatchResponseCallbacks ), this, 0, false );
CCDirector::sharedDirector()->getScheduler()->pauseTarget ( this );
}
HttpClient::~HttpClient()
{
need_quit = true;
if ( s_requestQueue != NULL )
{
pthread_cond_signal ( &s_SleepCondition );
}
s_pHttpClient = NULL;
}
//Lazy create semaphore & mutex & thread
bool HttpClient::lazyInitThreadSemphore()
{
if ( s_requestQueue != NULL )
{
return true;
}
else
{
s_requestQueue = new CCArray();
s_requestQueue->init();
s_responseQueue = new CCArray();
s_responseQueue->init();
pthread_mutex_init ( &s_requestQueueMutex, NULL );
pthread_mutex_init ( &s_responseQueueMutex, NULL );
pthread_mutex_init ( &s_SleepMutex, NULL );
pthread_cond_init ( &s_SleepCondition, NULL );
pthread_create ( &s_networkThread, NULL, networkThread, NULL );
pthread_detach ( s_networkThread );
need_quit = false;
}
return true;
}
//Add a get task to queue
void HttpClient::send ( HttpRequest* request )
{
if ( false == lazyInitThreadSemphore() )
{
return;
}
if ( !request )
{
return;
}
++s_asyncRequestCount;
request->retain();
pthread_mutex_lock ( &s_requestQueueMutex );
s_requestQueue->addObject ( request );
pthread_mutex_unlock ( &s_requestQueueMutex );
// Notify thread start to work
pthread_cond_signal ( &s_SleepCondition );
}
// Poll and notify main thread if responses exists in queue
void HttpClient::dispatchResponseCallbacks ( float delta )
{
HttpResponse* response = NULL;
pthread_mutex_lock ( &s_responseQueueMutex );
if ( s_responseQueue->count() )
{
response = dynamic_cast<HttpResponse*> ( s_responseQueue->objectAtIndex ( 0 ) );
s_responseQueue->removeObjectAtIndex ( 0 );
}
pthread_mutex_unlock ( &s_responseQueueMutex );
if ( response )
{
--s_asyncRequestCount;
HttpRequest* request = response->getHttpRequest();
CCObject* pTarget = request->getTarget();
SEL_KL_HttpResponse pSelector = request->getSelector();
if ( pTarget && pSelector )
{
( pTarget->*pSelector ) ( this, response );
}
response->release();
}
if ( 0 == s_asyncRequestCount )
{
CCDirector::sharedDirector()->getScheduler()->pauseTarget ( this );
}
}
} /* namespace KoalaLib */
#endif
#ifndef KL_HTTP_REQUEST_H
#define KL_HTTP_REQUEST_H
#include "cocos2d.h"
#include "ExtensionMacros.h"
namespace KoalaLib
{
class HttpClient;
class HttpResponse;
typedef void ( CCObject::*SEL_KL_HttpResponse ) ( HttpClient* client,
HttpResponse* response );
#define kl_httpresponse_selector(_SELECTOR) (KoalaLib::SEL_KL_HttpResponse)(&_SELECTOR)
class HttpRequest : public CCObject
{
public:
typedef enum
{
kHttpGet,
kHttpPost,
kHttpPut,
kHttpDelete,
kHttpSize,
kHttpUnkown,
} HttpRequestType;
/** Constructor
Because HttpRequest object will be used between UI thead and network thread,
requestObj->autorelease() is forbidden to avoid crashes in CCAutoreleasePool
new/retain/release still works, which means you need to release it manually
Please refer to HttpRequestTest.cpp to find its usage
*/
HttpRequest()
{
_requestType = kHttpUnkown;
_url.clear();
_requestData.clear();
_tag.clear();
_pTarget = NULL;
_pSelector = NULL;
_pUserData = NULL;
}
/** Destructor */
virtual ~HttpRequest()
{
if ( _pTarget )
{
_pTarget->release();
}
}
CCObject* autorelease ( void )
{
CCAssert ( false, "HttpResponse is used between network thread and ui thread \
therefore, autorelease is forbidden here" );
return NULL;
}
// setter/getters for properties
/** Required field for HttpRequest object before being sent.
kHttpGet & kHttpPost is currently supported
*/
inline void setRequestType ( HttpRequestType type )
{
_requestType = type;
}
inline HttpRequestType getRequestType()
{
return _requestType;
}
inline void setUrl ( const char* url )
{
_url = url;
}
inline const char* getUrl()
{
return _url.c_str();
}
inline void setRequestData ( const char* buffer, unsigned int len )
{
_requestData.assign ( buffer, buffer + len );
}
inline char* getRequestData()
{
return & ( _requestData.front() );
}
inline int getRequestDataSize()
{
return _requestData.size();
}
inline void setTag ( const char* tag )
{
_tag = tag;
}
inline const char* getTag()
{
return _tag.c_str();
}
inline void setUserData ( void* pUserData )
{
_pUserData = pUserData;
}
inline void* getUserData()
{
return _pUserData;
}
inline void setResponseCallback ( CCObject* pTarget,
SEL_KL_HttpResponse pSelector )
{
_pTarget = pTarget;
_pSelector = pSelector;
if ( _pTarget )
{
_pTarget->retain();
}
}
inline CCObject* getTarget()
{
return _pTarget;
}
class _prxy
{
public:
_prxy ( SEL_KL_HttpResponse cb ) : _cb ( cb ) {}
~_prxy() {};
operator SEL_KL_HttpResponse() const
{
return _cb;
}
CC_DEPRECATED_ATTRIBUTE operator SEL_CallFuncND() const
{
return ( SEL_CallFuncND ) _cb;
}
protected:
SEL_KL_HttpResponse _cb;
};
inline _prxy getSelector()
{
return _prxy ( _pSelector );
}
inline void setHeaders ( std::vector<std::string> pHeaders )
{
_headers = pHeaders;
}
inline std::vector<std::string> getHeaders()
{
return _headers;
}
protected:
// properties
HttpRequestType
_requestType; /// kHttpRequestGet, kHttpRequestPost or other enums
std::string _url; /// target url that this request is sent to
std::vector<char> _requestData; /// used for POST
std::string
_tag; /// user defined tag, to identify different requests in response callback
CCObject* _pTarget; /// callback target of pSelector function
SEL_KL_HttpResponse
_pSelector; /// callback function, e.g. MyLayer::onHttpResponse(HttpClient *sender, HttpResponse * response)
void* _pUserData; /// You can add your customed data here
std::vector<std::string> _headers; /// custom http headers
};
} /* namespace KoalaLib */
#endif //KL_HTTP_REQUEST_H
#ifndef KL_HTTP_RESPONSE_H
#define KL_HTTP_RESPONSE_H
#include "cocos2d.h"
#include "ExtensionMacros.h"
#include "downloader/HttpRequest.h"
namespace KoalaLib
{
/**
@brief defines the object which users will receive at onHttpCompleted(sender, HttpResponse) callback
Please refer to samples/TestCpp/Classes/ExtensionTest/NetworkTest/HttpClientTest.cpp as a sample
@since v2.0.2
*/
class HttpResponse : public CCObject
{
public:
/** Constructor, it's used by CCHttpClient internal, users don't need to create HttpResponse manually
@param request the corresponding HttpRequest which leads to this response
*/
HttpResponse ( HttpRequest* request ) :
_responseCode ( 0 )
{
_pHttpRequest = request;
if ( _pHttpRequest )
{
_pHttpRequest->retain();
}
_succeed = false;
_responseData.clear();
_errorBuffer.clear();
}
/** Destructor, it will be called in CCHttpClient internal,
users don't need to desturct HttpResponse object manully
*/
virtual ~HttpResponse()
{
if ( _pHttpRequest )
{
_pHttpRequest->release();
}
}
/** Override autorelease method to prevent developers from calling it */
CCObject* autorelease ( void )
{
CCAssert ( false, "HttpResponse is used between network thread and ui thread \
therefore, autorelease is forbidden here" );
return NULL;
}
/** Get the corresponding HttpRequest object which leads to this response
There's no paired setter for it, coz it's already setted in class constructor
*/
inline HttpRequest* getHttpRequest()
{
return _pHttpRequest;
}
/** To see if the http reqeust is returned successfully,
Althrough users can judge if (http return code = 200), we want an easier way
If this getter returns false, you can call getResponseCode and getErrorBuffer to find more details
*/
inline bool isSucceed()
{
return _succeed;
}
/** Get the http response raw data */
inline std::vector<char>* getResponseData()
{
return &_responseData;
}
/** get the Rawheader **/
inline std::vector<char>* getResponseHeader()
{
return &_responseHeader;
}
/** Get the http response errorCode
* I know that you want to see http 200 :)
*/
inline int getResponseCode()
{
return _responseCode;
}
inline const char* getErrorBuffer()
{
return _errorBuffer.c_str();
}
/** Set if the http request is returned successfully,
Althrough users can judge if (http code == 200), we want a easier way
This setter is mainly used in CCHttpClient, users mustn't set it directly
*/
inline void setSucceed ( bool value )
{
_succeed = value;
}
inline void setResponseData ( std::vector<char>* data )
{
_responseData = *data;
}
inline void setResponseHeader ( std::vector<char>* data )
{
_responseHeader = *data;
}
inline void setResponseCode ( int value )
{
_responseCode = value;
}
inline void setErrorBuffer ( const char* value )
{
_errorBuffer.clear();
_errorBuffer.assign ( value );
};
protected:
bool initWithRequest ( HttpRequest* request );
// properties
HttpRequest*
_pHttpRequest; /// the corresponding HttpRequest pointer who leads to this response
bool _succeed; /// to indecate if the http reqeust is successful simply
std::vector<char>
_responseData; /// the returned raw data. You can also dump it as a string
std::vector<char>
_responseHeader; /// the returned raw header data. You can also dump it as a string
int _responseCode; /// the status code returned from libcurl, e.g. 200, 404
std::string
_errorBuffer; /// if _responseCode != 200, please read _errorBuffer to find the reason
};
} /* namespace KoalaLib */
#endif //__HTTP_RESPONSE_H__
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment