2019-11-23 20:27:39 +08:00
|
|
|
/****************************************************************************
|
|
|
|
Copyright (c) 2012 greathqy
|
|
|
|
Copyright (c) 2012 cocos2d-x.org
|
|
|
|
Copyright (c) 2013-2016 Chukong Technologies Inc.
|
|
|
|
Copyright (c) 2017-2018 Xiamen Yaji Software Co., Ltd.
|
2021-06-24 17:04:04 +08:00
|
|
|
Copyright (c) 2021 Bytedance Inc.
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-25 08:18:32 +08:00
|
|
|
https://adxe.org
|
2019-11-23 20:27:39 +08:00
|
|
|
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
|
|
of this software and associated documentation files (the "Software"), to deal
|
|
|
|
in the Software without restriction, including without limitation the rights
|
|
|
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
|
|
copies of the Software, and to permit persons to whom the Software is
|
|
|
|
furnished to do so, subject to the following conditions:
|
|
|
|
|
|
|
|
The above copyright notice and this permission notice shall be included in
|
|
|
|
all copies or substantial portions of the Software.
|
|
|
|
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
|
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
|
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
|
|
THE SOFTWARE.
|
|
|
|
****************************************************************************/
|
|
|
|
|
|
|
|
#include "network/HttpClient.h"
|
|
|
|
#include <errno.h>
|
2021-06-24 17:04:04 +08:00
|
|
|
#include "base/ccUtils.h"
|
2019-11-23 20:27:39 +08:00
|
|
|
#include "base/CCDirector.h"
|
|
|
|
#include "platform/CCFileUtils.h"
|
2021-06-24 17:04:04 +08:00
|
|
|
#include "yasio/yasio.hpp"
|
|
|
|
#include "yasio/obstream.hpp"
|
|
|
|
|
|
|
|
using namespace yasio;
|
2019-11-23 20:27:39 +08:00
|
|
|
|
|
|
|
NS_CC_BEGIN
|
|
|
|
|
|
|
|
namespace network {
|
|
|
|
|
|
|
|
static HttpClient* _httpClient = nullptr; // pointer to singleton
|
|
|
|
|
2020-10-05 02:40:38 +08:00
|
|
|
template<typename _Cont, typename _Fty>
|
2021-06-24 17:04:04 +08:00
|
|
|
static void __clearQueueUnsafe(_Cont& queue, _Fty pred) {
|
|
|
|
for (auto it = queue.unsafe_begin(); it != queue.unsafe_end();)
|
2020-10-05 02:40:38 +08:00
|
|
|
{
|
|
|
|
if (!pred || pred((*it)))
|
|
|
|
{
|
|
|
|
(*it)->release();
|
2021-06-24 17:04:04 +08:00
|
|
|
it = queue.unsafe_erase(it);
|
2020-10-05 02:40:38 +08:00
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
|
|
|
++it;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-11-23 20:27:39 +08:00
|
|
|
// HttpClient implementation
|
|
|
|
HttpClient* HttpClient::getInstance()
|
|
|
|
{
|
|
|
|
if (_httpClient == nullptr)
|
|
|
|
{
|
|
|
|
_httpClient = new (std::nothrow) HttpClient();
|
|
|
|
}
|
|
|
|
|
|
|
|
return _httpClient;
|
|
|
|
}
|
|
|
|
|
|
|
|
void HttpClient::destroyInstance()
|
|
|
|
{
|
|
|
|
if (nullptr == _httpClient)
|
|
|
|
{
|
|
|
|
CCLOG("HttpClient singleton is nullptr");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
CCLOG("HttpClient::destroyInstance begin");
|
2021-06-24 17:04:04 +08:00
|
|
|
delete _httpClient;
|
2019-11-23 20:27:39 +08:00
|
|
|
_httpClient = nullptr;
|
|
|
|
|
|
|
|
CCLOG("HttpClient::destroyInstance() finished!");
|
|
|
|
}
|
|
|
|
|
|
|
|
void HttpClient::enableCookies(const char* cookieFile)
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_cookieFileMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
if (cookieFile)
|
|
|
|
{
|
|
|
|
_cookieFilename = std::string(cookieFile);
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{
|
2021-05-05 19:49:30 +08:00
|
|
|
_cookieFilename = (FileUtils::getInstance()->getNativeWritableAbsolutePath() + "cookieFile.txt");
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void HttpClient::setSSLVerification(const std::string& caFile)
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_sslCaFileMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
_sslCaFilename = caFile;
|
2021-06-24 17:04:04 +08:00
|
|
|
_service->set_option(yasio::YOPT_S_SSL_CACERT, _sslCaFilename.c_str());
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
HttpClient::HttpClient()
|
|
|
|
: _isInited(false)
|
2020-10-08 00:00:14 +08:00
|
|
|
, _dispatchOnWorkThread(false)
|
2019-11-23 20:27:39 +08:00
|
|
|
, _timeoutForConnect(30)
|
2021-06-25 08:18:32 +08:00
|
|
|
, _timeoutForRead(60)
|
2019-11-23 20:27:39 +08:00
|
|
|
, _cookie(nullptr)
|
|
|
|
, _clearResponsePredicate(nullptr)
|
|
|
|
{
|
|
|
|
CCLOG("In the constructor of HttpClient!");
|
|
|
|
_scheduler = Director::getInstance()->getScheduler();
|
2021-06-24 17:04:04 +08:00
|
|
|
|
|
|
|
_service = new yasio::io_service(HttpClient::MAX_CHANNELS);
|
|
|
|
_service->set_option(yasio::YOPT_S_DEFERRED_EVENT, 0);
|
|
|
|
_service->start([=](yasio::event_ptr&& e) { handleNetworkEvent(e.get()); });
|
|
|
|
|
|
|
|
for (int i = 0; i < HttpClient::MAX_CHANNELS; ++i) {
|
|
|
|
_availChannelQueue.push_back(i);
|
|
|
|
}
|
|
|
|
|
|
|
|
_isInited = true;
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
HttpClient::~HttpClient()
|
|
|
|
{
|
2021-06-24 17:04:04 +08:00
|
|
|
delete _service;
|
2019-11-23 20:27:39 +08:00
|
|
|
CCLOG("HttpClient destructor");
|
|
|
|
}
|
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
bool HttpClient::send(HttpRequest* request)
|
2019-11-23 20:27:39 +08:00
|
|
|
{
|
|
|
|
if (!request)
|
2021-06-24 17:04:04 +08:00
|
|
|
return false;
|
2021-06-24 15:54:02 +08:00
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
auto response = new HttpResponse(request);
|
|
|
|
processResponse(response, request->getUrl());
|
|
|
|
response->release();
|
|
|
|
return true;
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
int HttpClient::tryTakeAvailChannel() {
|
|
|
|
auto lck = _availChannelQueue.get_lock();
|
|
|
|
if (!_availChannelQueue.empty()) {
|
|
|
|
int channel = _availChannelQueue.front();
|
|
|
|
_availChannelQueue.pop_front();
|
|
|
|
return channel;
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
2021-06-24 17:04:04 +08:00
|
|
|
return -1;
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
void HttpClient::processResponse(HttpResponse* response, const std::string& url) {
|
|
|
|
auto channelIndex = tryTakeAvailChannel();
|
|
|
|
response->retain();
|
|
|
|
|
|
|
|
if (channelIndex != -1) {
|
|
|
|
if (response->prepareForProcess(url)) {
|
|
|
|
auto& requestUri = response->getRequestUri();
|
|
|
|
auto channelHandle = _service->channel_at(channelIndex);
|
|
|
|
channelHandle->ud_.ptr = response;
|
|
|
|
_service->set_option(YOPT_C_REMOTE_ENDPOINT, channelIndex, requestUri.getHost().c_str(), (int) requestUri.getPort());
|
|
|
|
if (requestUri.isSecure())
|
|
|
|
_service->open(channelIndex, YCK_SSL_CLIENT);
|
|
|
|
else
|
|
|
|
_service->open(channelIndex, YCK_TCP_CLIENT);
|
|
|
|
} else {
|
|
|
|
finishResponse(response);
|
2021-06-24 15:54:02 +08:00
|
|
|
}
|
2021-06-24 17:04:04 +08:00
|
|
|
} else {
|
|
|
|
_responseQueue.push_back(response);
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
}
|
2021-06-24 17:04:04 +08:00
|
|
|
|
|
|
|
void HttpClient::handleNetworkEvent(yasio::io_event* event) {
|
|
|
|
int channelIndex = event->cindex();
|
|
|
|
auto channel = _service->channel_at(event->cindex());
|
|
|
|
HttpResponse* response = (HttpResponse*) channel->ud_.ptr;
|
|
|
|
if (!response)
|
|
|
|
return;
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
bool responseFinished = response->isFinished();
|
|
|
|
switch (event->kind()) {
|
|
|
|
case YEK_ON_PACKET:
|
|
|
|
if (!responseFinished)
|
|
|
|
response->handleInput(event->packet());
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-25 18:29:16 +08:00
|
|
|
if (response->isFinished()) {
|
|
|
|
response->updateInternalCode(yasio::errc::eof);
|
2021-06-24 17:04:04 +08:00
|
|
|
_service->close(event->cindex());
|
2021-06-25 18:29:16 +08:00
|
|
|
}
|
2019-11-23 20:27:39 +08:00
|
|
|
break;
|
2021-06-24 17:04:04 +08:00
|
|
|
case YEK_ON_OPEN:
|
|
|
|
if (event->status() == 0) {
|
|
|
|
obstream obs;
|
2021-06-25 08:18:32 +08:00
|
|
|
bool usePostData = false;
|
2021-06-24 17:04:04 +08:00
|
|
|
auto request = response->getHttpRequest();
|
|
|
|
switch (request->getRequestType()) {
|
|
|
|
case HttpRequest::Type::GET:
|
|
|
|
obs.write_bytes("GET");
|
|
|
|
break;
|
|
|
|
case HttpRequest::Type::POST:
|
|
|
|
obs.write_bytes("POST");
|
2021-06-25 07:07:59 +08:00
|
|
|
usePostData = true;
|
2021-06-24 17:04:04 +08:00
|
|
|
break;
|
|
|
|
case HttpRequest::Type::DELETE:
|
|
|
|
obs.write_bytes("DELETE");
|
|
|
|
break;
|
|
|
|
case HttpRequest::Type::PUT:
|
|
|
|
obs.write_bytes("PUT");
|
2021-06-25 07:07:59 +08:00
|
|
|
usePostData = true;
|
2021-06-24 17:04:04 +08:00
|
|
|
break;
|
|
|
|
default:
|
|
|
|
obs.write_bytes("GET");
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
obs.write_bytes(" ");
|
2021-06-25 07:07:59 +08:00
|
|
|
|
|
|
|
auto& uri = response->getRequestUri();
|
2021-06-24 17:04:04 +08:00
|
|
|
obs.write_bytes(uri.getPath());
|
2021-06-25 07:07:59 +08:00
|
|
|
if (!usePostData) {
|
2021-06-24 17:04:04 +08:00
|
|
|
auto& query = uri.getQuery();
|
|
|
|
if (!query.empty()) {
|
|
|
|
obs.write_byte('?');
|
2021-06-25 07:07:59 +08:00
|
|
|
obs.write_bytes(query);
|
2021-06-24 17:04:04 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
obs.write_bytes(" HTTP/1.1\r\n");
|
|
|
|
|
|
|
|
obs.write_bytes("Host: ");
|
|
|
|
obs.write_bytes(uri.getHost());
|
|
|
|
obs.write_bytes("\r\n");
|
|
|
|
|
|
|
|
// custom headers
|
|
|
|
auto& headers = request->getHeaders();
|
2021-07-05 14:24:04 +08:00
|
|
|
|
|
|
|
bool haveContentTypeFromCustomHeaders = false;
|
2021-06-24 17:04:04 +08:00
|
|
|
if(!headers.empty()) {
|
2021-07-05 14:24:04 +08:00
|
|
|
using namespace cxx17; // for string_view literal
|
|
|
|
for (auto& header : headers) {
|
2021-06-24 17:04:04 +08:00
|
|
|
obs.write_bytes(header);
|
|
|
|
obs.write_bytes("\r\n");
|
2021-07-05 14:24:04 +08:00
|
|
|
if (usePostData && cxx20::ic::starts_with(cxx17::string_view{header}, "Content-Type:"_sv))
|
|
|
|
haveContentTypeFromCustomHeaders = true;
|
2021-06-24 17:04:04 +08:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
obs.write_bytes("User-Agent: ");
|
|
|
|
obs.write_bytes("yasio-http");
|
|
|
|
obs.write_bytes("\r\n");
|
|
|
|
}
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
obs.write_bytes("Accept: */*;q=0.8\r\n");
|
2021-06-25 07:07:59 +08:00
|
|
|
obs.write_bytes("Connection: Close\r\n");
|
|
|
|
|
|
|
|
if (usePostData) {
|
|
|
|
// obs.write_bytes("Origin: yasio\r\n");
|
2021-07-05 14:24:04 +08:00
|
|
|
if (!haveContentTypeFromCustomHeaders)
|
|
|
|
obs.write_bytes("Content-Type: application/x-www-form-urlencoded;charset=UTF-8\r\n");
|
2021-06-25 07:07:59 +08:00
|
|
|
|
|
|
|
char strContentLength[128] = {0};
|
|
|
|
auto requestData = request->getRequestData();
|
|
|
|
auto requestDataSize = request->getRequestDataSize();
|
|
|
|
sprintf(strContentLength, "Content-Length: %d\r\n\r\n", static_cast<int>(requestDataSize));
|
|
|
|
obs.write_bytes(strContentLength);
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-25 07:07:59 +08:00
|
|
|
if (requestData && requestDataSize > 0)
|
|
|
|
obs.write_bytes(cxx17::string_view{requestData, static_cast<size_t>(requestDataSize)});
|
|
|
|
} else {
|
|
|
|
obs.write_bytes("\r\n");
|
2021-06-24 17:04:04 +08:00
|
|
|
}
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
_service->write(event->transport(), std::move(obs.buffer()));
|
|
|
|
|
|
|
|
auto& timerForRead = channel->get_user_timer();
|
|
|
|
timerForRead.cancel(*_service);
|
|
|
|
timerForRead.expires_from_now(std::chrono::seconds(this->_timeoutForRead));
|
|
|
|
timerForRead.async_wait(*_service, [=](io_service& s) {
|
2021-06-25 18:29:16 +08:00
|
|
|
response->updateInternalCode(yasio::errc::read_timeout);
|
2021-06-24 17:04:04 +08:00
|
|
|
s.close(channelIndex); // timeout
|
|
|
|
return true;
|
|
|
|
});
|
|
|
|
} else {
|
2021-06-25 18:29:16 +08:00
|
|
|
handleNetworkEOF(response, channel, event->status());
|
2021-06-24 17:04:04 +08:00
|
|
|
}
|
2019-11-23 20:27:39 +08:00
|
|
|
break;
|
2021-06-24 17:04:04 +08:00
|
|
|
case YEK_ON_CLOSE:
|
2021-06-25 18:29:16 +08:00
|
|
|
handleNetworkEOF(response, channel, event->status());
|
2019-11-23 20:27:39 +08:00
|
|
|
break;
|
|
|
|
}
|
2021-06-24 17:04:04 +08:00
|
|
|
}
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-25 18:29:16 +08:00
|
|
|
void HttpClient::handleNetworkEOF(HttpResponse* response, yasio::io_channel* channel, int internalErrorCode) {
|
2021-06-24 17:04:04 +08:00
|
|
|
channel->get_user_timer().cancel(*_service);
|
2021-06-25 18:29:16 +08:00
|
|
|
response->updateInternalCode(internalErrorCode);
|
2021-06-24 17:04:04 +08:00
|
|
|
auto responseCode = response->getResponseCode();
|
|
|
|
switch (responseCode) {
|
|
|
|
case 301:
|
|
|
|
case 307:
|
|
|
|
case 302:
|
|
|
|
if (response->increaseRedirectCount() < HttpClient::MAX_REDIRECT_COUNT) {
|
|
|
|
auto iter = response->_responseHeaders.find("LOCATION");
|
|
|
|
if (iter != response->_responseHeaders.end()) {
|
|
|
|
_availChannelQueue.push_back(channel->index());
|
|
|
|
processResponse(response, iter->second);
|
|
|
|
response->release();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
2021-06-24 17:04:04 +08:00
|
|
|
|
|
|
|
finishResponse(response);
|
|
|
|
|
|
|
|
// recycle channel
|
|
|
|
_availChannelQueue.push_back(channel->index());
|
|
|
|
|
|
|
|
// try process pending response
|
|
|
|
auto lck = _responseQueue.get_lock();
|
|
|
|
if (!_responseQueue.unsafe_empty()) {
|
|
|
|
auto pendingResponse = _responseQueue.unsafe_front();
|
|
|
|
_responseQueue.unsafe_pop_front();
|
|
|
|
lck.unlock();
|
|
|
|
|
|
|
|
processResponse(pendingResponse, pendingResponse->getHttpRequest()->getUrl());
|
|
|
|
pendingResponse->release();
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
void HttpClient::finishResponse(HttpResponse* response) {
|
|
|
|
auto cbNotify = [=]() {
|
|
|
|
HttpRequest* request = response->getHttpRequest();
|
|
|
|
const ccHttpRequestCallback& callback = request->getCallback();
|
|
|
|
Ref* pTarget = request->getTarget();
|
|
|
|
SEL_HttpResponse pSelector = request->getSelector();
|
2019-11-23 20:27:39 +08:00
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
if (callback != nullptr) {
|
|
|
|
callback(this, response);
|
|
|
|
} else if (pTarget && pSelector) {
|
|
|
|
(pTarget->*pSelector)(this, response);
|
|
|
|
}
|
2021-06-24 12:33:07 +08:00
|
|
|
|
2021-06-24 17:04:04 +08:00
|
|
|
response->release();
|
|
|
|
};
|
|
|
|
|
|
|
|
if (_dispatchOnWorkThread || std::this_thread::get_id() == Director::getInstance()->getCocos2dThreadId())
|
|
|
|
cbNotify();
|
|
|
|
else
|
|
|
|
_scheduler->performFunctionInCocosThread(cbNotify);
|
|
|
|
}
|
|
|
|
|
|
|
|
void HttpClient::clearResponseQueue() {
|
|
|
|
auto lck = _responseQueue.get_lock();
|
|
|
|
__clearQueueUnsafe(_responseQueue, ClearResponsePredicate{});
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
void HttpClient::setTimeoutForConnect(int value)
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_timeoutForConnectMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
_timeoutForConnect = value;
|
2021-06-24 17:04:04 +08:00
|
|
|
_service->set_option(YOPT_S_CONNECT_TIMEOUT, value);
|
2019-11-23 20:27:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
int HttpClient::getTimeoutForConnect()
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_timeoutForConnectMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
return _timeoutForConnect;
|
|
|
|
}
|
|
|
|
|
|
|
|
void HttpClient::setTimeoutForRead(int value)
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_timeoutForReadMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
_timeoutForRead = value;
|
|
|
|
}
|
|
|
|
|
|
|
|
int HttpClient::getTimeoutForRead()
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_timeoutForReadMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
return _timeoutForRead;
|
|
|
|
}
|
|
|
|
|
|
|
|
const std::string& HttpClient::getCookieFilename()
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_cookieFileMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
return _cookieFilename;
|
|
|
|
}
|
|
|
|
|
|
|
|
const std::string& HttpClient::getSSLVerification()
|
|
|
|
{
|
2020-10-08 00:00:14 +08:00
|
|
|
std::lock_guard<std::recursive_mutex> lock(_sslCaFileMutex);
|
2019-11-23 20:27:39 +08:00
|
|
|
return _sslCaFilename;
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
NS_CC_END
|
|
|
|
|