Version 1.2.1 alexa-client-sdk
Changes in this update * **Enhancements**.. * Added comments to `AlexaClientSDKConfig.json`. These descriptions provide additional guidance for what is expected for each field... * Enabled pause and resume controls for Pandora... * **Bug Fixes**.. * Bug fix for [issue #329](https://github.com/alexa/avs-device-sdk/issues/329) - `HTTP2Transport` instances no longer leak when `SERVER_SIDE * Bug fix for [issue #189](https://github.com/alexa/avs-device-sdk/issues/189) - Fixed a race condition in the `Timer` class that sometimes * Bug fix for a race condition that caused `SpeechSynthesizer` to ignore subsequent `Speak` directives... * Bug fix for corrupted mime attachments.
This commit is contained in:
parent
2cc16d8cc4
commit
a2b84e329c
|
@ -363,6 +363,9 @@ private:
|
|||
/// Whether or not the @c networkLoop is stopping. Serialized by @c m_mutex.
|
||||
bool m_isStopping;
|
||||
|
||||
/// Whether or not the onDisconnected() notification has been sent. Serialized by @c m_mutex.
|
||||
bool m_disconnectedSent;
|
||||
|
||||
/// Queue of @c MessageRequest instances to send. Serialized by @c m_mutex.
|
||||
std::deque<std::shared_ptr<avsCommon::avs::MessageRequest>> m_requestQueue;
|
||||
|
||||
|
|
|
@ -74,11 +74,13 @@ public:
|
|||
|
||||
void setObserver(std::shared_ptr<MessageRouterObserverInterface> observer) override;
|
||||
|
||||
void onConnected() override;
|
||||
void onConnected(std::shared_ptr<TransportInterface> transport) override;
|
||||
|
||||
void onDisconnected(avsCommon::sdkInterfaces::ConnectionStatusObserverInterface::ChangedReason reason) override;
|
||||
void onDisconnected(
|
||||
std::shared_ptr<TransportInterface> transport,
|
||||
avsCommon::sdkInterfaces::ConnectionStatusObserverInterface::ChangedReason reason) override;
|
||||
|
||||
void onServerSideDisconnect() override;
|
||||
void onServerSideDisconnect(std::shared_ptr<TransportInterface> transport) override;
|
||||
|
||||
void consumeMessage(const std::string& contextId, const std::string& message) override;
|
||||
|
||||
|
|
|
@ -60,9 +60,11 @@ public:
|
|||
void onContextAvailable(const std::string& jsonContext) override;
|
||||
void onContextFailure(const avsCommon::sdkInterfaces::ContextRequestError error) override;
|
||||
|
||||
void onServerSideDisconnect() override;
|
||||
void onConnected() override;
|
||||
void onDisconnected(avsCommon::sdkInterfaces::ConnectionStatusObserverInterface::ChangedReason reason) override;
|
||||
void onServerSideDisconnect(std::shared_ptr<TransportInterface> transport) override;
|
||||
void onConnected(std::shared_ptr<TransportInterface> transport) override;
|
||||
void onDisconnected(
|
||||
std::shared_ptr<TransportInterface> transport,
|
||||
avsCommon::sdkInterfaces::ConnectionStatusObserverInterface::ChangedReason reason) override;
|
||||
|
||||
void addObserver(std::shared_ptr<PostConnectObserverInterface> observer) override;
|
||||
void removeObserver(std::shared_ptr<PostConnectObserverInterface> observer) override;
|
||||
|
|
|
@ -36,19 +36,27 @@ public:
|
|||
|
||||
/**
|
||||
* Called when a connection to AVS is established.
|
||||
*
|
||||
* @param transport The transport that has connected.
|
||||
*/
|
||||
virtual void onConnected() = 0;
|
||||
virtual void onConnected(std::shared_ptr<TransportInterface> transport) = 0;
|
||||
|
||||
/**
|
||||
* Called when we disconnect from AVS.
|
||||
*
|
||||
* @param transport The transport that is no longer connected (or attempting to connect).
|
||||
* @param reason The reason that we disconnected.
|
||||
*/
|
||||
virtual void onDisconnected(avsCommon::sdkInterfaces::ConnectionStatusObserverInterface::ChangedReason reason) = 0;
|
||||
virtual void onDisconnected(
|
||||
std::shared_ptr<TransportInterface> transport,
|
||||
avsCommon::sdkInterfaces::ConnectionStatusObserverInterface::ChangedReason reason) = 0;
|
||||
|
||||
/**
|
||||
* Called when the server asks the client to reconnect
|
||||
*
|
||||
* @param transport The transport that has received the disconnect request.
|
||||
*/
|
||||
virtual void onServerSideDisconnect() = 0;
|
||||
virtual void onServerSideDisconnect(std::shared_ptr<TransportInterface> transport) = 0;
|
||||
};
|
||||
|
||||
} // namespace acl
|
||||
|
|
|
@ -169,6 +169,7 @@ HTTP2Transport::HTTP2Transport(
|
|||
m_isNetworkThreadRunning{false},
|
||||
m_isConnected{false},
|
||||
m_isStopping{false},
|
||||
m_disconnectedSent{false},
|
||||
m_postConnectObject{postConnectObject} {
|
||||
m_observers.insert(observer);
|
||||
|
||||
|
@ -700,9 +701,10 @@ void HTTP2Transport::setIsConnectedFalse() {
|
|||
auto disconnectReason = ConnectionStatusObserverInterface::ChangedReason::INTERNAL_ERROR;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_isConnected) {
|
||||
if (m_disconnectedSent) {
|
||||
return;
|
||||
}
|
||||
m_disconnectedSent = true;
|
||||
m_isConnected = false;
|
||||
disconnectReason = m_disconnectReason;
|
||||
}
|
||||
|
@ -775,7 +777,7 @@ void HTTP2Transport::notifyObserversOnServerSideDisconnect() {
|
|||
lock.unlock();
|
||||
|
||||
for (auto observer : observers) {
|
||||
observer->onServerSideDisconnect();
|
||||
observer->onServerSideDisconnect(shared_from_this());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -785,7 +787,7 @@ void HTTP2Transport::notifyObserversOnDisconnect(ConnectionStatusObserverInterfa
|
|||
lock.unlock();
|
||||
|
||||
for (auto observer : observers) {
|
||||
observer->onDisconnected(reason);
|
||||
observer->onDisconnected(shared_from_this(), reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -795,7 +797,7 @@ void HTTP2Transport::notifyObserversOnConnected() {
|
|||
lock.unlock();
|
||||
|
||||
for (auto observer : observers) {
|
||||
observer->onConnected();
|
||||
observer->onConnected(shared_from_this());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -112,7 +112,7 @@ void MessageRouter::setAVSEndpoint(const std::string& avsEndpoint) {
|
|||
}
|
||||
}
|
||||
|
||||
void MessageRouter::onConnected() {
|
||||
void MessageRouter::onConnected(std::shared_ptr<TransportInterface> transport) {
|
||||
std::unique_lock<std::mutex> lock{m_connectionMutex};
|
||||
if (m_isEnabled) {
|
||||
setConnectionStatusLocked(
|
||||
|
@ -121,45 +121,34 @@ void MessageRouter::onConnected() {
|
|||
}
|
||||
}
|
||||
|
||||
void MessageRouter::onDisconnected(ConnectionStatusObserverInterface::ChangedReason reason) {
|
||||
void MessageRouter::onDisconnected(
|
||||
std::shared_ptr<TransportInterface> transport,
|
||||
ConnectionStatusObserverInterface::ChangedReason reason) {
|
||||
std::lock_guard<std::mutex> lock{m_connectionMutex};
|
||||
if (ConnectionStatusObserverInterface::Status::CONNECTED == m_connectionStatus) {
|
||||
// Reset m_activeTransport of it is not longer connected.
|
||||
if (m_activeTransport && !m_activeTransport->isConnected()) {
|
||||
safelyResetActiveTransportLocked();
|
||||
}
|
||||
|
||||
// Trim m_transports to just those transports still connected. Also build list of disconnected transports.
|
||||
std::vector<std::shared_ptr<TransportInterface>> connected;
|
||||
std::vector<std::shared_ptr<TransportInterface>> disconnected;
|
||||
for (auto transport : m_transports) {
|
||||
if (transport->isConnected()) {
|
||||
connected.push_back(transport);
|
||||
} else {
|
||||
disconnected.push_back(transport);
|
||||
}
|
||||
}
|
||||
m_transports.clear();
|
||||
std::swap(m_transports, connected);
|
||||
|
||||
// Release all the disconnected transports.
|
||||
for (auto transport : disconnected) {
|
||||
for (auto it = m_transports.begin(); it != m_transports.end(); it++) {
|
||||
if (*it == transport) {
|
||||
m_transports.erase(it);
|
||||
safelyReleaseTransport(transport);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (transport == m_activeTransport) {
|
||||
m_activeTransport.reset();
|
||||
// Update status. If transitioning to PENDING, also initiate the reconnect.
|
||||
if (ConnectionStatusObserverInterface::Status::CONNECTED == m_connectionStatus) {
|
||||
if (m_isEnabled) {
|
||||
if (!m_activeTransport) {
|
||||
setConnectionStatusLocked(ConnectionStatusObserverInterface::Status::PENDING, reason);
|
||||
createActiveTransportLocked();
|
||||
}
|
||||
} else if (m_transports.empty()) {
|
||||
setConnectionStatusLocked(ConnectionStatusObserverInterface::Status::DISCONNECTED, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MessageRouter::onServerSideDisconnect() {
|
||||
void MessageRouter::onServerSideDisconnect(std::shared_ptr<TransportInterface> transport) {
|
||||
std::unique_lock<std::mutex> lock{m_connectionMutex};
|
||||
if (m_isEnabled) {
|
||||
setConnectionStatusLocked(
|
||||
|
|
|
@ -162,6 +162,11 @@ MimeParser::DataParsedStatus MimeParser::writeDataToAttachment(const char* buffe
|
|||
void MimeParser::partDataCallback(const char* buffer, size_t size, void* userData) {
|
||||
MimeParser* parser = static_cast<MimeParser*>(userData);
|
||||
|
||||
if (MimeParser::DataParsedStatus::INCOMPLETE == parser->m_dataParsedStatus) {
|
||||
ACSDK_DEBUG9(LX("partDataCallbackIgnored").d("reason", "attachmentWriterFullBuffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (parser->m_dataParsedStatus != MimeParser::DataParsedStatus::OK) {
|
||||
ACSDK_ERROR(
|
||||
LX("partDataCallbackFailed").d("reason", "mimeParsingError").d("status", parser->m_dataParsedStatus));
|
||||
|
@ -170,7 +175,7 @@ void MimeParser::partDataCallback(const char* buffer, size_t size, void* userDat
|
|||
|
||||
// If we've already processed any of this part in a previous incomplete iteration, let's not process it twice.
|
||||
if (!parser->shouldProcessBytes(size)) {
|
||||
ACSDK_DEBUG(LX("partDataCallbackSkipped").d("reason", "bytesAlreadyProcessed"));
|
||||
ACSDK_DEBUG9(LX("partDataCallbackSkipped").d("reason", "bytesAlreadyProcessed"));
|
||||
parser->updateCurrentByteProgress(size);
|
||||
parser->m_dataParsedStatus = MimeParser::DataParsedStatus::OK;
|
||||
return;
|
||||
|
@ -304,6 +309,7 @@ MimeParser::DataParsedStatus MimeParser::feed(char* data, size_t length) {
|
|||
}
|
||||
|
||||
// Initialize this before all the feed() callbacks happen (since this persists from previous call).
|
||||
m_currentByteProgress = 0;
|
||||
m_dataParsedStatus = DataParsedStatus::OK;
|
||||
|
||||
m_multipartReader.feed(data, length);
|
||||
|
@ -350,7 +356,7 @@ void MimeParser::setAttachmentWriterBufferFull(bool isFull) {
|
|||
if (isFull == m_isAttachmentWriterBufferFull) {
|
||||
return;
|
||||
}
|
||||
ACSDK_DEBUG0(LX("setAttachmentWriterBufferFull").d("full", isFull));
|
||||
ACSDK_DEBUG9(LX("setAttachmentWriterBufferFull").d("full", isFull));
|
||||
m_isAttachmentWriterBufferFull = isFull;
|
||||
}
|
||||
|
||||
|
|
|
@ -240,16 +240,18 @@ void PostConnectSynchronizer::notifyObservers() {
|
|||
}
|
||||
}
|
||||
|
||||
void PostConnectSynchronizer::onServerSideDisconnect() {
|
||||
void PostConnectSynchronizer::onServerSideDisconnect(std::shared_ptr<TransportInterface> transport) {
|
||||
ACSDK_DEBUG(LX("onServerSideDisconnect()"));
|
||||
doShutdown();
|
||||
}
|
||||
|
||||
void PostConnectSynchronizer::onConnected() {
|
||||
void PostConnectSynchronizer::onConnected(std::shared_ptr<TransportInterface> transport) {
|
||||
ACSDK_DEBUG(LX("onConnected()"));
|
||||
}
|
||||
|
||||
void PostConnectSynchronizer::onDisconnected(ConnectionStatusObserverInterface::ChangedReason reason) {
|
||||
void PostConnectSynchronizer::onDisconnected(
|
||||
std::shared_ptr<TransportInterface> transport,
|
||||
ConnectionStatusObserverInterface::ChangedReason reason) {
|
||||
ACSDK_DEBUG(LX("onDisconnected()"));
|
||||
doShutdown();
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ TEST_F(MessageRouterTest, getConnectionStatusReturnsConnectedAfterConnectionEsta
|
|||
}
|
||||
|
||||
TEST_F(MessageRouterTest, getConnectionStatusReturnsConnectedAfterDisconnected) {
|
||||
m_router->onDisconnected(ConnectionStatusObserverInterface::ChangedReason::ACL_DISABLED);
|
||||
m_router->onDisconnected(m_mockTransport, ConnectionStatusObserverInterface::ChangedReason::ACL_DISABLED);
|
||||
ASSERT_EQ(m_router->getConnectionStatus().first, ConnectionStatusObserverInterface::Status::DISCONNECTED);
|
||||
}
|
||||
|
||||
|
@ -74,7 +74,7 @@ TEST_F(MessageRouterTest, ensureTheMessageRouterObserverIsInformedOfTransportDis
|
|||
|
||||
auto reason = ConnectionStatusObserverInterface::ChangedReason::ACL_DISABLED;
|
||||
disconnectMockTransport(m_mockTransport.get());
|
||||
m_router->onDisconnected(reason);
|
||||
m_router->onDisconnected(m_mockTransport, reason);
|
||||
|
||||
// wait for the result to propagate by scheduling a task on the client executor
|
||||
waitOnMessageRouter(SHORT_TIMEOUT_MS);
|
||||
|
@ -179,7 +179,7 @@ TEST_F(MessageRouterTest, serverSideDisconnectCreatesANewTransport) {
|
|||
m_router->setMockTransport(newTransport);
|
||||
|
||||
// Reset the MessageRouterObserver, there should be no interactions with the observer
|
||||
m_router->onServerSideDisconnect();
|
||||
m_router->onServerSideDisconnect(oldTransport);
|
||||
|
||||
waitOnMessageRouter(SHORT_TIMEOUT_MS);
|
||||
|
||||
|
@ -191,7 +191,7 @@ TEST_F(MessageRouterTest, serverSideDisconnectCreatesANewTransport) {
|
|||
|
||||
// mock the new transports connection
|
||||
connectMockTransport(newTransport.get());
|
||||
m_router->onConnected();
|
||||
m_router->onConnected(newTransport);
|
||||
|
||||
waitOnMessageRouter(SHORT_TIMEOUT_MS);
|
||||
|
||||
|
@ -203,7 +203,7 @@ TEST_F(MessageRouterTest, serverSideDisconnectCreatesANewTransport) {
|
|||
|
||||
// mock the old transport disconnecting completely
|
||||
disconnectMockTransport(oldTransport.get());
|
||||
m_router->onDisconnected(ConnectionStatusObserverInterface::ChangedReason::ACL_CLIENT_REQUEST);
|
||||
m_router->onDisconnected(oldTransport, ConnectionStatusObserverInterface::ChangedReason::ACL_CLIENT_REQUEST);
|
||||
|
||||
auto messageRequest = createMessageRequest();
|
||||
|
||||
|
|
|
@ -120,7 +120,7 @@ public:
|
|||
|
||||
void setupStateToConnected() {
|
||||
setupStateToPending();
|
||||
m_router->onConnected();
|
||||
m_router->onConnected(m_mockTransport);
|
||||
connectMockTransport(m_mockTransport.get());
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
* MimeParserFuzzTest.cpp
|
||||
*
|
||||
* Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License").
|
||||
* You may not use this file except in compliance with the License.
|
||||
* A copy of the License is located at
|
||||
*
|
||||
* http://aws.amazon.com/apache2.0/
|
||||
*
|
||||
* or in the "license" file accompanying this file. This file 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.
|
||||
*/
|
||||
|
||||
/// @file MimeParserFuzzTest.cpp
|
||||
|
||||
#include <memory>
|
||||
#include <random>
|
||||
#include <thread>
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "ACL/Transport/MessageConsumerInterface.h"
|
||||
#include "ACL/Transport/HTTP2Stream.h"
|
||||
|
||||
#include <AVSCommon/AVS/Attachment/AttachmentReader.h>
|
||||
#include <AVSCommon/AVS/Attachment/AttachmentWriter.h>
|
||||
#include "AVSCommon/AVS/Attachment/InProcessAttachment.h"
|
||||
#include <AVSCommon/SDKInterfaces/MessageObserverInterface.h>
|
||||
#include <AVSCommon/Utils/Logger/Logger.h>
|
||||
|
||||
#include "Common/TestableAttachmentManager.h"
|
||||
#include "Common/Common.h"
|
||||
#include "Common/MimeUtils.h"
|
||||
#include "Common/TestableMessageObserver.h"
|
||||
|
||||
#include "MockMessageRequest.h"
|
||||
#include "TestableConsumer.h"
|
||||
|
||||
namespace alexaClientSDK {
|
||||
namespace acl {
|
||||
namespace test {
|
||||
|
||||
using namespace avsCommon::sdkInterfaces;
|
||||
using namespace avsCommon::avs::attachment;
|
||||
|
||||
/// String to identify log entries originating from this file.
|
||||
static const std::string TAG("MimeParserFuzzTest");
|
||||
|
||||
/**
|
||||
* Create a LogEntry using this file's TAG and the specified event string.
|
||||
*
|
||||
* @param The event string for this @c LogEntry.
|
||||
*/
|
||||
#define LX(event) alexaClientSDK::avsCommon::utils::logger::LogEntry(TAG, event)
|
||||
|
||||
/// The number of feed() calls to be made in @c MimeParserFuzzTest::feed()
|
||||
static const int FEED_COUNT = 0x100;
|
||||
/// Max pseudo-random buffer size to pass to @c feed() (large enough to assure we trigger attachment buffer full)
|
||||
static const size_t MAX_FEED_SIZE = (InProcessAttachment::SDS_BUFFER_DEFAULT_SIZE_IN_BYTES / FEED_COUNT) * 4;
|
||||
/// Max buffer size to read from the attachment reader (small enough trigger multiple reads per feed)
|
||||
static const size_t MAX_READ_SIZE = MAX_FEED_SIZE / 8;
|
||||
/// Consistent seed (for repeatable tests) with which to generate pseudo-random bytes.
|
||||
static const unsigned int BYTES_SEED = 1;
|
||||
/// Consistent seed (for repeatable tests) with which to generate pseudo-random feed sizes.
|
||||
static const unsigned int FEED_SIZE_SEED = 2;
|
||||
/// Consistent but different seed (for repeatable tests) with which to generate pseudo-random read sizes.
|
||||
static const unsigned int READ_SIZE_SEED = FEED_SIZE_SEED + 1;
|
||||
/// Content ID for mime part.
|
||||
static const std::string CONTENT_ID = "CONTENT_ID";
|
||||
/// Context ID for generating attachment ID.
|
||||
static const std::string CONTEXT_ID = "CONTEXT_ID";
|
||||
/// CR,LF to separate lines in mime headers and boundaries.
|
||||
static const std::string CRLF = "\r\n";
|
||||
/// Dashes used as a prefix or suffix to mime boundaries.
|
||||
static const std::string DASHES = "--";
|
||||
/// Mime Boundary string (without CR,LF or dashes).
|
||||
static const std::string BOUNDARY = "BoundaryBoundaryBoundaryBoundaryBoundaryBoundaryBoundary";
|
||||
/// Mime prefix to our attachment.
|
||||
static const std::string ATTACHMENT_PREFIX = CRLF + DASHES + BOUNDARY + CRLF + "Content-ID: " + CONTENT_ID + CRLF +
|
||||
"Content-Type: application/octet-stream" + CRLF + CRLF;
|
||||
/// Mime suffix terminating our attachment.
|
||||
static const std::string ATTACHMENT_SUFFIX = CRLF + DASHES + BOUNDARY + DASHES;
|
||||
|
||||
/**
|
||||
* Class to generate consistent pseudo-random byte stream.
|
||||
*/
|
||||
class ByteGenerator {
|
||||
public:
|
||||
ByteGenerator();
|
||||
|
||||
/**
|
||||
* Generate the next set of bytes in the stream.
|
||||
*
|
||||
* @param[out] buffer The buffer in which to place the generated bytes.
|
||||
* @param size The number of bytes to generate.
|
||||
*/
|
||||
void generateBytes(std::vector<uint8_t>* buffer, size_t size);
|
||||
|
||||
private:
|
||||
/// The underlying random byte generator.
|
||||
std::independent_bits_engine<std::minstd_rand, CHAR_BIT, uint8_t> m_engine;
|
||||
};
|
||||
|
||||
ByteGenerator::ByteGenerator() {
|
||||
m_engine.seed(BYTES_SEED);
|
||||
}
|
||||
|
||||
void ByteGenerator::generateBytes(std::vector<uint8_t>* buffer, size_t size) {
|
||||
for (size_t ix = 0; ix < size; ix++) {
|
||||
(*buffer)[ix] = m_engine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Our GTest class.
|
||||
*/
|
||||
class MimeParserFuzzTest : public ::testing::Test {
|
||||
public:
|
||||
/**
|
||||
* Construct the objects we will use across tests.
|
||||
*/
|
||||
void SetUp() override;
|
||||
|
||||
/**
|
||||
* Feed a pseudo-random stream of bytes to the mime parser, making a number of
|
||||
* calls with pseudo-random sizes.
|
||||
*/
|
||||
void feed();
|
||||
|
||||
/**
|
||||
* Read the attachment the mime parser is rendering to, requesting a pseudo-random
|
||||
* number of bytes with each read.
|
||||
*/
|
||||
void read();
|
||||
|
||||
/// The AttachmentManager.
|
||||
std::shared_ptr<AttachmentManager> m_attachmentManager;
|
||||
/// The MimeParser which we will be primarily testing.
|
||||
std::shared_ptr<MimeParser> m_parser;
|
||||
/// Flag to indicate the test has failed and loops should exit.
|
||||
std::atomic<bool> m_failed;
|
||||
};
|
||||
|
||||
void MimeParserFuzzTest::SetUp() {
|
||||
m_attachmentManager = std::make_shared<AttachmentManager>(AttachmentManager::AttachmentType::IN_PROCESS);
|
||||
auto testableConsumer = std::make_shared<TestableConsumer>();
|
||||
testableConsumer->setMessageObserver(std::make_shared<TestableMessageObserver>());
|
||||
m_parser = std::make_shared<MimeParser>(testableConsumer, m_attachmentManager);
|
||||
m_parser->setAttachmentContextId(CONTEXT_ID);
|
||||
m_parser->setBoundaryString(BOUNDARY);
|
||||
m_failed = false;
|
||||
}
|
||||
|
||||
void MimeParserFuzzTest::feed() {
|
||||
m_parser->feed(const_cast<char*>(ATTACHMENT_PREFIX.c_str()), ATTACHMENT_PREFIX.size());
|
||||
|
||||
ByteGenerator feedBytesSource;
|
||||
|
||||
std::default_random_engine feedSizeGenerator;
|
||||
feedSizeGenerator.seed(FEED_SIZE_SEED);
|
||||
std::uniform_int_distribution<int> feedSizeDistribution(1, MAX_FEED_SIZE);
|
||||
auto feedSizeSource = std::bind(feedSizeDistribution, feedSizeGenerator);
|
||||
|
||||
std::vector<uint8_t> feedBuffer(MAX_FEED_SIZE);
|
||||
|
||||
size_t totalBytesFed = 0;
|
||||
int incompleteCount = 0;
|
||||
for (int ix = 0; !m_failed && ix < FEED_COUNT; ix++) {
|
||||
auto feedSize = feedSizeSource();
|
||||
feedBytesSource.generateBytes(&feedBuffer, feedSize);
|
||||
while (true) {
|
||||
ACSDK_DEBUG9(LX("callingFeed").d("totalBytesFed", totalBytesFed).d("feedSize", feedSize));
|
||||
auto status = m_parser->feed((char*)(&feedBuffer[0]), feedSize);
|
||||
if (MimeParser::DataParsedStatus::OK == status) {
|
||||
totalBytesFed += feedSize;
|
||||
break;
|
||||
} else if (MimeParser::DataParsedStatus::INCOMPLETE == status) {
|
||||
ACSDK_DEBUG9(LX("feedIncomplete").d("action", "waitAndReDrive"));
|
||||
++incompleteCount;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
} else {
|
||||
ASSERT_NE(status, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m_parser->feed(const_cast<char*>(ATTACHMENT_SUFFIX.c_str()), ATTACHMENT_SUFFIX.size());
|
||||
|
||||
// Make sure there were enough INCOMPLETE return statuses.
|
||||
ACSDK_DEBUG9(LX("doneFeeding").d("incompleteCount", incompleteCount).d("FEED_COUNT", FEED_COUNT));
|
||||
ASSERT_GT(incompleteCount, FEED_COUNT);
|
||||
}
|
||||
|
||||
void MimeParserFuzzTest::read() {
|
||||
ByteGenerator verifyBytesSource;
|
||||
|
||||
std::default_random_engine readSizeGenerator;
|
||||
readSizeGenerator.seed(READ_SIZE_SEED);
|
||||
std::uniform_int_distribution<int> readSizeDistribution(1, MAX_READ_SIZE);
|
||||
auto readSizeSource = std::bind(readSizeDistribution, readSizeGenerator);
|
||||
|
||||
auto attachmentId = m_attachmentManager->generateAttachmentId(CONTEXT_ID, CONTENT_ID);
|
||||
auto reader = m_attachmentManager->createReader(attachmentId, AttachmentReader::Policy::BLOCKING);
|
||||
|
||||
std::vector<uint8_t> readBuffer(MAX_READ_SIZE);
|
||||
std::vector<uint8_t> verifyBuffer(MAX_READ_SIZE);
|
||||
size_t totalBytesRead = 0;
|
||||
|
||||
while (true) {
|
||||
// Delay reads so that AnotherMimeParserTest::feed() blocks on a full attachment buffer
|
||||
// in a reliable way. This makes the test results consistent run to run.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
|
||||
auto readSizeIn = readSizeSource();
|
||||
auto readStatus = AttachmentReader::ReadStatus::OK;
|
||||
ACSDK_DEBUG9(LX("callingRead").d("totalBytesRead", totalBytesRead).d("readSizeIn", readSizeIn));
|
||||
auto readSizeOut = reader->read(readBuffer.data(), readSizeIn, &readStatus);
|
||||
if (readSizeOut != 0) {
|
||||
ACSDK_DEBUG9(LX("readReturned").d("readSizeOut", readSizeOut));
|
||||
verifyBytesSource.generateBytes(&verifyBuffer, readSizeOut);
|
||||
for (size_t ix = 0; ix < readSizeOut; ix++) {
|
||||
auto good = readBuffer[ix] == verifyBuffer[ix];
|
||||
if (!good) {
|
||||
m_failed = true;
|
||||
ASSERT_EQ(readBuffer[ix], verifyBuffer[ix]) << "UnexpectedByte at: " << totalBytesRead + ix;
|
||||
}
|
||||
}
|
||||
totalBytesRead += readSizeOut;
|
||||
}
|
||||
|
||||
switch (readStatus) {
|
||||
case AttachmentReader::ReadStatus::OK:
|
||||
break;
|
||||
|
||||
case AttachmentReader::ReadStatus::CLOSED:
|
||||
ACSDK_DEBUG9(LX("readClosed"));
|
||||
return;
|
||||
|
||||
case AttachmentReader::ReadStatus::OK_WOULDBLOCK:
|
||||
case AttachmentReader::ReadStatus::OK_TIMEDOUT:
|
||||
case AttachmentReader::ReadStatus::ERROR_OVERRUN:
|
||||
case AttachmentReader::ReadStatus::ERROR_BYTES_LESS_THAN_WORD_SIZE:
|
||||
case AttachmentReader::ReadStatus::ERROR_INTERNAL:
|
||||
ASSERT_NE(readStatus, readStatus);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise MimeParser's re-drive, parsing, and attachment buffering by feeding in a large attachment
|
||||
* consisting of pseudo-random bytes sent in pseudo-random sized chunks. At the same time, read the
|
||||
* resulting attachment in pseudo-random sized requests. Expect that the data read matches the data
|
||||
* that was fed.
|
||||
*/
|
||||
TEST_F(MimeParserFuzzTest, testRandomFeedAndReadSizesOfRandomData) {
|
||||
auto readThread = std::thread(&MimeParserFuzzTest::read, this);
|
||||
feed();
|
||||
readThread.join();
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace acl
|
||||
} // namespace alexaClientSDK
|
|
@ -367,6 +367,7 @@ void Timer::callTask(
|
|||
break;
|
||||
}
|
||||
}
|
||||
m_stopping = false;
|
||||
m_running = false;
|
||||
}
|
||||
|
||||
|
|
|
@ -104,7 +104,7 @@ bool ConfigurationNode::initialize(const std::vector<std::istream*>& jsonStreams
|
|||
}
|
||||
IStreamWrapper wrapper(*jsonStream);
|
||||
Document overlay(&m_document.GetAllocator());
|
||||
overlay.ParseStream(wrapper);
|
||||
overlay.ParseStream<kParseCommentsFlag>(wrapper);
|
||||
if (overlay.HasParseError()) {
|
||||
ACSDK_ERROR(LX("initializeFailed")
|
||||
.d("reason", "parseFailure")
|
||||
|
|
|
@ -439,7 +439,24 @@ TEST_F(TimerTest, deleteDuringTask) {
|
|||
verifyTimestamps(t0, SHORT_DELAY, SHORT_DELAY, Timer::PeriodType::ABSOLUTE, SHORT_DELAY);
|
||||
}
|
||||
|
||||
/// This test verifies that
|
||||
/**
|
||||
* This test verifies that a call to start() succeeds on a timer which was previously stopped while running a task, but
|
||||
* which is inactive at the time the new start() is called.
|
||||
*/
|
||||
TEST_F(TimerTest, startRunningAfterStopDuringTask) {
|
||||
ASSERT_TRUE(m_timer->start(NO_DELAY, std::bind(&TimerTest::simpleTask, this, MEDIUM_DELAY)).valid());
|
||||
ASSERT_TRUE(m_timer->isActive());
|
||||
std::this_thread::sleep_for(SHORT_DELAY);
|
||||
m_timer->stop();
|
||||
ASSERT_TRUE(waitForInactive());
|
||||
m_timestamps.clear();
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
ASSERT_EQ(
|
||||
m_timer->start(MEDIUM_DELAY, std::bind(&TimerTest::simpleTask, this, NO_DELAY)).wait_for(TIMEOUT),
|
||||
std::future_status::ready);
|
||||
ASSERT_TRUE(waitForInactive());
|
||||
verifyTimestamps(t0, MEDIUM_DELAY, MEDIUM_DELAY, Timer::PeriodType::ABSOLUTE, NO_DELAY);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace timing
|
||||
|
|
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -1,5 +1,17 @@
|
|||
## ChangeLog
|
||||
|
||||
### [1.2.1] - 2017-11-16
|
||||
|
||||
* **Enhancements**
|
||||
* Added comments to `AlexaClientSDKConfig.json`. These descriptions provide additional guidance for what is expected for each field.
|
||||
* Enabled pause and resume controls for Pandora.
|
||||
|
||||
* **Bug Fixes**
|
||||
* Bug fix for [issue #329](https://github.com/alexa/avs-device-sdk/issues/329) - `HTTP2Transport` instances no longer leak when `SERVER_SIDE_DISCONNECT` is encountered.
|
||||
* Bug fix for [issue #189](https://github.com/alexa/avs-device-sdk/issues/189) - Fixed a race condition in the `Timer` class that sometimes caused `SpeechSynthesizer` to get stuck in the "Speaking" state.
|
||||
* Bug fix for a race condition that caused `SpeechSynthesizer` to ignore subsequent `Speak` directives.
|
||||
* Bug fix for corrupted mime attachments.
|
||||
|
||||
### [1.2.0] - 2017-10-27
|
||||
* **Enhancements**
|
||||
* Updated MediaPlayer to solve stability issues
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
cmake_minimum_required(VERSION 3.1 FATAL_ERROR)
|
||||
|
||||
# Set project information
|
||||
project(AlexaClientSDK VERSION 1.2.0 LANGUAGES CXX)
|
||||
project(AlexaClientSDK VERSION 1.2.1 LANGUAGES CXX)
|
||||
set(PROJECT_BRIEF "A cross-platform, modular SDK for interacting with the Alexa Voice Service")
|
||||
|
||||
include(build/BuildDefaults.cmake)
|
||||
|
|
|
@ -598,8 +598,13 @@ void AudioPlayer::executeOnFocusChanged(FocusState newFocus) {
|
|||
case PlayerActivity::STOPPED:
|
||||
case PlayerActivity::FINISHED:
|
||||
// We see a focus change to foreground in these states if we are starting to play a new song.
|
||||
if (!m_audioItems.empty()) {
|
||||
ACSDK_DEBUG1(LX("executeOnFocusChanged").d("action", "playNextItem"));
|
||||
playNextItem();
|
||||
} else {
|
||||
ACSDK_DEBUG1(LX("executeOnFocusChanged").d("action", "releaseChannel"));
|
||||
m_focusManager->releaseChannel(CHANNEL_NAME, shared_from_this());
|
||||
}
|
||||
return;
|
||||
case PlayerActivity::PAUSED: {
|
||||
// A focus change to foreground when paused means we should resume the current song.
|
||||
|
@ -784,7 +789,12 @@ void AudioPlayer::executeOnPlaybackError(SourceId id, const ErrorType& type, std
|
|||
}
|
||||
|
||||
sendPlaybackFailedEvent(m_token, type, error);
|
||||
executeStop();
|
||||
|
||||
/*
|
||||
* There's no need to call stop() here as the MediaPlayer has already stopped due to the playback error. Instead,
|
||||
* call executeOnPlaybackStopped() so that the states in AudioPlayer are reset properly.
|
||||
*/
|
||||
executeOnPlaybackStopped(m_sourceId);
|
||||
}
|
||||
|
||||
void AudioPlayer::executeOnPlaybackPaused(SourceId id) {
|
||||
|
|
|
@ -1147,11 +1147,14 @@ TEST_F(AudioPlayerTest, testFocusChangeToNoneInIdleState) {
|
|||
}
|
||||
|
||||
/**
|
||||
* DISABLE this test because the behavior has been changed such that we no longer expect
|
||||
* a PlaybackFailedEvent
|
||||
*
|
||||
* Test focus change to Foreground in IDLE state
|
||||
* Expect a PlaybackFailedEvent
|
||||
*/
|
||||
|
||||
TEST_F(AudioPlayerTest, testFocusChangeToForegroundInIdleState) {
|
||||
TEST_F(AudioPlayerTest, DISABLED_testFocusChangeToForegroundInIdleState) {
|
||||
bool messageSentResult = false;
|
||||
|
||||
// expect PlaybackFailed since there are no queued AudioItems
|
||||
|
@ -1215,11 +1218,14 @@ TEST_F(AudioPlayerTest, testFocusChangesInPlayingState) {
|
|||
}
|
||||
|
||||
/**
|
||||
* * DISABLE this test because the behavior has been changed such that we no longer expect
|
||||
* a PlaybackFailedEvent
|
||||
*
|
||||
* Test focus changes in STOPPED state
|
||||
* Expect a PlaybackFailedEvent when attempting to switch to FOREGROUND
|
||||
*/
|
||||
|
||||
TEST_F(AudioPlayerTest, testFocusChangesInStoppedState) {
|
||||
TEST_F(AudioPlayerTest, DISABLED_testFocusChangesInStoppedState) {
|
||||
sendPlayDirective();
|
||||
|
||||
// push AudioPlayer into stopped state
|
||||
|
|
|
@ -620,9 +620,19 @@ void SpeechSynthesizer::executePlaybackError(const avsCommon::utils::mediaPlayer
|
|||
}
|
||||
m_waitOnStateChange.notify_one();
|
||||
releaseForegroundFocus();
|
||||
sendExceptionEncounteredAndReportFailed(m_currentInfo, avsCommon::avs::ExceptionErrorType::INTERNAL_ERROR, error);
|
||||
resetCurrentInfo();
|
||||
resetMediaSourceId();
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
while (!m_speakInfoQueue.empty()) {
|
||||
auto speakInfo = m_speakInfoQueue.front();
|
||||
m_speakInfoQueue.pop_front();
|
||||
lock.unlock();
|
||||
sendExceptionEncounteredAndReportFailed(
|
||||
speakInfo, avsCommon::avs::ExceptionErrorType::INTERNAL_ERROR, error);
|
||||
lock.lock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string SpeechSynthesizer::buildState(std::string& token, int64_t offsetInMilliseconds) const {
|
||||
|
|
|
@ -259,6 +259,17 @@ public:
|
|||
/// Future to notify when @c sendMessage is called.
|
||||
std::future<void> m_wakeSendMessageFuture;
|
||||
|
||||
/**
|
||||
* Fulfills the @c m_wakeStoppedPromise. This is invoked in response to a @c stop call.
|
||||
*/
|
||||
void wakeOnStopped();
|
||||
|
||||
/// Promise to be fulfilled when @c stop is called.
|
||||
std::promise<void> m_wakeStoppedPromise;
|
||||
|
||||
/// Future to notify when @c stop is called.
|
||||
std::future<void> m_wakeStoppedFuture;
|
||||
|
||||
/// An exception sender used to send exception encountered events to AVS.
|
||||
std::shared_ptr<MockExceptionEncounteredSender> m_mockExceptionSender;
|
||||
|
||||
|
@ -278,7 +289,9 @@ SpeechSynthesizerTest::SpeechSynthesizerTest() :
|
|||
m_wakeSetFailedPromise{},
|
||||
m_wakeSetFailedFuture{m_wakeSetFailedPromise.get_future()},
|
||||
m_wakeSendMessagePromise{},
|
||||
m_wakeSendMessageFuture{m_wakeSendMessagePromise.get_future()} {
|
||||
m_wakeSendMessageFuture{m_wakeSendMessagePromise.get_future()},
|
||||
m_wakeStoppedPromise{},
|
||||
m_wakeStoppedFuture{m_wakeStoppedPromise.get_future()} {
|
||||
}
|
||||
|
||||
void SpeechSynthesizerTest::SetUp() {
|
||||
|
@ -334,6 +347,10 @@ void SpeechSynthesizerTest::wakeOnSendMessage() {
|
|||
m_wakeSendMessagePromise.set_value();
|
||||
}
|
||||
|
||||
void SpeechSynthesizerTest::wakeOnStopped() {
|
||||
m_wakeStoppedPromise.set_value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test call to handleDirectiveImmediately.
|
||||
* Expected result is that @c acquireChannel is called with the correct channel. On focus changed @c FOREGROUND, audio
|
||||
|
@ -632,6 +649,113 @@ TEST_F(SpeechSynthesizerTest, testBargeInWhilePlaying) {
|
|||
ASSERT_TRUE(std::future_status::ready == m_wakeAcquireChannelFuture.wait_for(WAIT_TIMEOUT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Testing calling stop() in @c MediaPlayer twice won't get the @c SpeechSynthesizer into a bad state.
|
||||
* Call preHandle with a valid SPEAK directive. Then call handleDirective. Expected result is that @c acquireChannel
|
||||
* is called once. On Focus Changed to foreground, audio should play.
|
||||
* Call cancel directive. Expect the stop() to be called once.
|
||||
* Call onFocusChanged, expect the stop() to be called again, and this time the @c MediaPlayer will return false to
|
||||
* report an error. Expect when handleDirectiveImmediately with a valid SPEAK directive is called, @c SpeechSynthesizer
|
||||
* will react correctly.
|
||||
*/
|
||||
TEST_F(SpeechSynthesizerTest, testCallingStopTwice) {
|
||||
auto avsMessageHeader = std::make_shared<AVSMessageHeader>(
|
||||
NAMESPACE_SPEECH_SYNTHESIZER, NAME_SPEAK, MESSAGE_ID_TEST, DIALOG_REQUEST_ID_TEST);
|
||||
std::shared_ptr<AVSDirective> directive =
|
||||
AVSDirective::create("", avsMessageHeader, PAYLOAD_TEST, m_attachmentManager, CONTEXT_ID_TEST);
|
||||
|
||||
auto avsMessageHeader2 =
|
||||
std::make_shared<AVSMessageHeader>(NAMESPACE_SPEECH_SYNTHESIZER, NAME_SPEAK, MESSAGE_ID_TEST_2);
|
||||
std::shared_ptr<AVSDirective> directive2 =
|
||||
AVSDirective::create("", avsMessageHeader2, PAYLOAD_TEST, m_attachmentManager, CONTEXT_ID_TEST_2);
|
||||
|
||||
EXPECT_CALL(*(m_mockFocusManager.get()), acquireChannel(CHANNEL_NAME, _, FOCUS_MANAGER_ACTIVITY_ID))
|
||||
.Times(AtLeast(1))
|
||||
.WillRepeatedly(InvokeWithoutArgs(this, &SpeechSynthesizerTest::wakeOnAcquireChannel));
|
||||
EXPECT_CALL(
|
||||
*(m_mockSpeechPlayer.get()),
|
||||
attachmentSetSource(A<std::shared_ptr<avsCommon::avs::attachment::AttachmentReader>>()))
|
||||
.Times(AtLeast(1));
|
||||
EXPECT_CALL(*(m_mockSpeechPlayer.get()), play(_)).Times(AtLeast(1));
|
||||
EXPECT_CALL(*(m_mockSpeechPlayer.get()), getOffset(_))
|
||||
.Times(AtLeast(1))
|
||||
.WillRepeatedly(Return(OFFSET_IN_CHRONO_MILLISECONDS_TEST));
|
||||
EXPECT_CALL(
|
||||
*(m_mockContextManager.get()),
|
||||
setState(NAMESPACE_AND_NAME_SPEECH_STATE, PLAYING_STATE_TEST, StateRefreshPolicy::ALWAYS, 0))
|
||||
.Times(AtLeast(1))
|
||||
.WillRepeatedly(InvokeWithoutArgs(this, &SpeechSynthesizerTest::wakeOnSetState));
|
||||
EXPECT_CALL(
|
||||
*(m_mockContextManager.get()),
|
||||
setState(NAMESPACE_AND_NAME_SPEECH_STATE, FINISHED_STATE_TEST, StateRefreshPolicy::NEVER, 0))
|
||||
.Times(AtLeast(1))
|
||||
.WillRepeatedly(InvokeWithoutArgs(this, &SpeechSynthesizerTest::wakeOnSetState));
|
||||
EXPECT_CALL(*(m_mockMessageSender.get()), sendMessage(_))
|
||||
.Times(AtLeast(1))
|
||||
.WillRepeatedly(InvokeWithoutArgs(this, &SpeechSynthesizerTest::wakeOnSendMessage));
|
||||
EXPECT_CALL(*(m_mockFocusManager.get()), releaseChannel(CHANNEL_NAME, _))
|
||||
.Times(AtLeast(1))
|
||||
.WillRepeatedly(InvokeWithoutArgs(this, &SpeechSynthesizerTest::wakeOnReleaseChannel));
|
||||
EXPECT_CALL(*(m_mockSpeechPlayer.get()), stop(_))
|
||||
.Times(AtLeast(1))
|
||||
.WillOnce(Invoke([this](avsCommon::utils::mediaPlayer::MediaPlayerInterface::SourceId id) {
|
||||
wakeOnStopped();
|
||||
return true;
|
||||
}))
|
||||
.WillOnce(Invoke([this](avsCommon::utils::mediaPlayer::MediaPlayerInterface::SourceId id) {
|
||||
wakeOnStopped();
|
||||
return false;
|
||||
}))
|
||||
.WillRepeatedly(Return(true));
|
||||
EXPECT_CALL(*m_mockDirHandlerResult, setFailed(_)).Times(AtLeast(1));
|
||||
|
||||
// send Speak directive and getting focus and wait until playback started
|
||||
m_speechSynthesizer->CapabilityAgent::preHandleDirective(directive, std::move(m_mockDirHandlerResult));
|
||||
m_speechSynthesizer->CapabilityAgent::handleDirective(MESSAGE_ID_TEST);
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeAcquireChannelFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeAcquireChannelPromise = std::promise<void>();
|
||||
m_wakeAcquireChannelFuture = m_wakeAcquireChannelPromise.get_future();
|
||||
m_speechSynthesizer->onFocusChanged(FocusState::FOREGROUND);
|
||||
ASSERT_TRUE(m_mockSpeechPlayer->waitUntilPlaybackStarted());
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeSetStateFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeSetStatePromise = std::promise<void>();
|
||||
m_wakeSetStateFuture = m_wakeSetStatePromise.get_future();
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeSendMessageFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeSendMessagePromise = std::promise<void>();
|
||||
m_wakeSendMessageFuture = m_wakeSendMessagePromise.get_future();
|
||||
|
||||
// cancel directive, this should result in calling stop()
|
||||
m_speechSynthesizer->CapabilityAgent::cancelDirective(MESSAGE_ID_TEST);
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeStoppedFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeStoppedPromise = std::promise<void>();
|
||||
m_wakeStoppedFuture = m_wakeStoppedPromise.get_future();
|
||||
|
||||
// goes to background, this should result in calling the 2nd stop() and MediaPlayer returning an error
|
||||
m_speechSynthesizer->onFocusChanged(FocusState::BACKGROUND);
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeStoppedFuture.wait_for(WAIT_TIMEOUT));
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeSetStateFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeSetStatePromise = std::promise<void>();
|
||||
m_wakeSetStateFuture = m_wakeSetStatePromise.get_future();
|
||||
|
||||
/*
|
||||
* onPlaybackStopped, this will result in an error with reason=nullptrDirectiveInfo. But this shouldn't break the
|
||||
* SpeechSynthesizer
|
||||
*/
|
||||
m_speechSynthesizer->onPlaybackStopped(m_mockSpeechPlayer->m_sourceId);
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeReleaseChannelFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeReleaseChannelPromise = std::promise<void>();
|
||||
m_wakeReleaseChannelFuture = m_wakeReleaseChannelPromise.get_future();
|
||||
|
||||
// send second speak directive and make sure it working
|
||||
m_speechSynthesizer->handleDirectiveImmediately(directive2);
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeAcquireChannelFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_speechSynthesizer->onFocusChanged(FocusState::FOREGROUND);
|
||||
ASSERT_TRUE(std::future_status::ready == m_wakeSetStateFuture.wait_for(WAIT_TIMEOUT));
|
||||
m_wakeSetStatePromise = std::promise<void>();
|
||||
m_wakeSetStateFuture = m_wakeSetStatePromise.get_future();
|
||||
ASSERT_TRUE(m_mockSpeechPlayer->waitUntilPlaybackStarted());
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace speechSynthesizer
|
||||
} // namespace capabilityAgents
|
||||
|
|
|
@ -1,28 +1,78 @@
|
|||
{
|
||||
"authDelegate":{
|
||||
// The Client Secret of the Product from developer.amazon.com
|
||||
"clientSecret":"${SDK_CONFIG_CLIENT_SECRET}",
|
||||
// Unique device serial number. e.g. 123456
|
||||
"deviceSerialNumber":"${SDK_CONFIG_DEVICE_SERIAL_NUMBER}",
|
||||
"refreshToken":"${SDK_CONFIG_REFRESH_TOKEN}",
|
||||
// Refresh Token populated by running AuthServer.py
|
||||
"refreshToken":"{SDK_CONFIG_REFRESH_TOKEN}",
|
||||
// The Client ID of the Product from developer.amazon.com
|
||||
"clientId":"${SDK_CONFIG_CLIENT_ID}",
|
||||
// Product ID from developer.amazon.com
|
||||
"productId":"${SDK_CONFIG_PRODUCT_ID}"
|
||||
},
|
||||
|
||||
"alertsCapabilityAgent":{
|
||||
// Path to Alerts database file. e.g. /home/ubuntu/Build/alerts.db
|
||||
// Note: The directory specified must be valid.
|
||||
// The database file (alerts.db) will be created by SampleApp, do not create it yourself.
|
||||
// The database file should only be used for alerts (don't use it for other components of SDK)
|
||||
"databaseFilePath":"${SDK_SQLITE_DATABASE_FILE_PATH}",
|
||||
// Path to default Alarm sound file. e.g. /home/ubuntu/alert_sounds/alarm_normal.mp3
|
||||
// Note: The audio file must exist and be a valid file.
|
||||
"alarmSoundFilePath":"${SDK_ALARM_DEFAULT_SOUND_FILE_PATH}",
|
||||
// Path to short Alarm sound file. e.g. /home/ubuntu/alert_sounds/alarm_short.wav
|
||||
// Note: The audio file must exist and be a valid file.
|
||||
"alarmShortSoundFilePath":"${SDK_ALARM_SHORT_SOUND_FILE_PATH}",
|
||||
// Path to default timer sound file. e.g. /home/ubuntu/alert_sounds/timer_normal.mp3
|
||||
// Note: The audio file must exist and be a valid file.
|
||||
"timerSoundFilePath":"${SDK_TIMER_DEFAULT_SOUND_FILE_PATH}",
|
||||
// Path to short timer sound file. e.g. /home/ubuntu/alert_sounds/timer_short.wav
|
||||
// Note: The audio file must exist and be a valid file.
|
||||
"timerShortSoundFilePath":"${SDK_TIMER_SHORT_SOUND_FILE_PATH}"
|
||||
},
|
||||
|
||||
"settings":{
|
||||
// Path to Settings database file. e.g. /home/ubuntu/Build/settings.db
|
||||
// Note: The directory specified must be valid.
|
||||
// The database file (settings.db) will be created by SampleApp, do not create it yourself.
|
||||
// The database file should only be used for settings (don't use it for other components of SDK)
|
||||
"databaseFilePath":"${SDK_SQLITE_SETTINGS_DATABASE_FILE_PATH}",
|
||||
"defaultAVSClientSettings":{
|
||||
// Default language for Alexa.
|
||||
// See https://developer.amazon.com/docs/alexa-voice-service/settings.html#settingsupdated for valid values.
|
||||
"locale":"${SETTING_LOCALE_VALUE}"
|
||||
}
|
||||
},
|
||||
|
||||
"certifiedSender":{
|
||||
"databaseFilePath":"{SDK_CERTIFIED_SENDER_DATABASE_FILE_PATH}"
|
||||
// Path to Certified Sender database file. e.g. /home/ubuntu/Build/certifiedsender.db
|
||||
// Note: The directory specified must be valid.
|
||||
// The database file (certifiedsender.db) will be created by SampleApp, do not create it yourself.
|
||||
// The database file should only be used for certifiedSender (don't use it for other components of SDK)
|
||||
"databaseFilePath":"${SDK_CERTIFIED_SENDER_DATABASE_FILE_PATH}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Notes for logging
|
||||
// The log levels are supported to debug when SampleApp is not working as expected.
|
||||
// There are 14 levels of logging with DEBUG9 providing the highest level of logging and CRITICAL providing
|
||||
// the lowest level of logging i.e. if DEBUG9 is specified while running the SampleApp, all the logs at DEBUG9 and
|
||||
// below are displayed, whereas if CRITICAL is specified, only logs of CRITICAL are displayed.
|
||||
// The 14 levels are:
|
||||
// DEBUG9, DEBUG8, DEBUG7, DEBUG6, DEBUG5, DEBUG4, DEBUG3, DEBUG2, DEBUG1, DEBUG0, INFO, WARN, ERROR, CRTITICAL.
|
||||
|
||||
// To selectively see the logging for a particular module, you can specify logging level in this json file.
|
||||
// Some examples are:
|
||||
// To only see logs of level INFO and below for ACL and MediaPlayer modules,
|
||||
// - grep for ACSDK_LOG_MODULE in source folder. Find the log module for ACL and MediaPlayer.
|
||||
// - Put the following in json:
|
||||
|
||||
// "acl":{
|
||||
// "logLevel":"INFO"
|
||||
// },
|
||||
// "mediaPlayer":{
|
||||
// "logLevel":"INFO"
|
||||
// }
|
||||
|
||||
// To enable DEBUG, build with cmake option -DCMAKE_BUILD_TYPE=DEBUG. By default it is built with RELEASE build.
|
||||
// And run the SampleApp similar to the following command.
|
||||
// e.g. TZ=UTC ./SampleApp /home/ubuntu/.../AlexaClientSDKConfig.json /home/ubuntu/KittAiModels/ DEBUG9"
|
||||
|
|
|
@ -954,11 +954,14 @@ TEST_F(AlertsTest, cancelAlertBeforeItIsActive) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Disabled until consistent failures can be investigated. Failures are due to the test's expectation of event
|
||||
* ordering.
|
||||
*
|
||||
* Test when the storage is removed before an alert is set
|
||||
*
|
||||
* Close the storage before asking for a 5 second timer. SetAlertFailed and DeleteAlertFailed events are then sent.
|
||||
*/
|
||||
TEST_F(AlertsTest, RemoveStorageBeforeAlarmIsSet) {
|
||||
TEST_F(AlertsTest, DISABLED_RemoveStorageBeforeAlarmIsSet) {
|
||||
m_alertStorage->close();
|
||||
|
||||
// Write audio to SDS saying "Set a timer for 5 seconds"
|
||||
|
|
|
@ -114,13 +114,16 @@ private:
|
|||
* The @c AudioPipeline consists of the following elements:
|
||||
* @li @c appsrc The appsrc element is used as the source to which audio data is provided.
|
||||
* @li @c decoder Decodebin is used as the decoder element to decode audio.
|
||||
* @li @c decodedQueue A queue is used to store the decoded data.
|
||||
* @li @c converter An audio-converter is used to convert between audio formats.
|
||||
* @li @c volume The volume element is used as a volume control.
|
||||
* @li @c audioSink Sink for the audio.
|
||||
* @li @c pipeline The pipeline is a bin consisting of the @c appsrc, the @c decoder, the @c converter, and the
|
||||
* @c audioSink.
|
||||
*
|
||||
* The data flow through the elements is appsrc -> decoder -> converter -> volume -> audioSink.
|
||||
* The data flow through the elements is appsrc -> decoder -> decodedQueue -> converter -> volume -> audioSink.
|
||||
* Ideally we would want to use playsink or playbin directly to automate as much as possible. However, this
|
||||
* causes problems with multiple pipelines and volume settings in pulse audio. Pending further investigation.
|
||||
*/
|
||||
struct AudioPipeline {
|
||||
/// The source element.
|
||||
|
@ -129,6 +132,9 @@ private:
|
|||
/// The decoder element.
|
||||
GstElement* decoder;
|
||||
|
||||
/// A queue for decoded elements.
|
||||
GstElement* decodedQueue;
|
||||
|
||||
/// The converter element.
|
||||
GstElement* converter;
|
||||
|
||||
|
@ -145,6 +151,7 @@ private:
|
|||
AudioPipeline() :
|
||||
appsrc{nullptr},
|
||||
decoder{nullptr},
|
||||
decodedQueue{nullptr},
|
||||
converter{nullptr},
|
||||
volume{nullptr},
|
||||
audioSink{nullptr},
|
||||
|
|
|
@ -533,6 +533,14 @@ bool MediaPlayer::init() {
|
|||
}
|
||||
|
||||
bool MediaPlayer::setupPipeline() {
|
||||
m_pipeline.decodedQueue = gst_element_factory_make("queue", "decodedQueue");
|
||||
// Do not send signals or messages. Let the decoder buffer messages dictate application logic.
|
||||
g_object_set(m_pipeline.decodedQueue, "silent", TRUE, NULL);
|
||||
if (!m_pipeline.decodedQueue) {
|
||||
ACSDK_ERROR(LX("setupPipelineFailed").d("reason", "createQueueElementFailed"));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_pipeline.converter = gst_element_factory_make("audioconvert", "converter");
|
||||
if (!m_pipeline.converter) {
|
||||
ACSDK_ERROR(LX("setupPipelineFailed").d("reason", "createConverterElementFailed"));
|
||||
|
@ -561,11 +569,17 @@ bool MediaPlayer::setupPipeline() {
|
|||
m_busWatchId = gst_bus_add_watch(bus, &MediaPlayer::onBusMessage, this);
|
||||
gst_object_unref(bus);
|
||||
|
||||
// Link only the volume, converter, and sink here. Src will be linked in respective source files.
|
||||
// Link only the queue, converter, volume, and sink here. Src will be linked in respective source files.
|
||||
gst_bin_add_many(
|
||||
GST_BIN(m_pipeline.pipeline), m_pipeline.converter, m_pipeline.volume, m_pipeline.audioSink, nullptr);
|
||||
GST_BIN(m_pipeline.pipeline),
|
||||
m_pipeline.decodedQueue,
|
||||
m_pipeline.converter,
|
||||
m_pipeline.volume,
|
||||
m_pipeline.audioSink,
|
||||
nullptr);
|
||||
|
||||
if (!gst_element_link_many(m_pipeline.converter, m_pipeline.volume, m_pipeline.audioSink, nullptr)) {
|
||||
if (!gst_element_link_many(
|
||||
m_pipeline.decodedQueue, m_pipeline.converter, m_pipeline.volume, m_pipeline.audioSink, nullptr)) {
|
||||
ACSDK_ERROR(LX("setupPipelineFailed").d("reason", "createVolumeToConverterToSinkLinkFailed"));
|
||||
return false;
|
||||
}
|
||||
|
@ -611,6 +625,7 @@ void MediaPlayer::resetPipeline() {
|
|||
m_pipeline.pipeline = nullptr;
|
||||
m_pipeline.appsrc = nullptr;
|
||||
m_pipeline.decoder = nullptr;
|
||||
m_pipeline.decodedQueue = nullptr;
|
||||
m_pipeline.converter = nullptr;
|
||||
m_pipeline.volume = nullptr;
|
||||
m_pipeline.audioSink = nullptr;
|
||||
|
@ -698,8 +713,7 @@ void MediaPlayer::onPadAdded(GstElement* decoder, GstPad* pad, gpointer pointer)
|
|||
|
||||
void MediaPlayer::handlePadAdded(std::promise<void>* promise, GstElement* decoder, GstPad* pad) {
|
||||
ACSDK_DEBUG9(LX("handlePadAddedSignalCalled"));
|
||||
GstElement* converter = m_pipeline.converter;
|
||||
gst_element_link(decoder, converter);
|
||||
gst_element_link(decoder, m_pipeline.decodedQueue);
|
||||
promise->set_value();
|
||||
}
|
||||
|
||||
|
@ -907,7 +921,7 @@ void MediaPlayer::handleSetAttachmentReaderSource(
|
|||
|
||||
/*
|
||||
* Once the source pad for the decoder has been added, the decoder emits the pad-added signal. Connect the signal
|
||||
* to the callback which performs the linking of the decoder source pad to the converter sink pad.
|
||||
* to the callback which performs the linking of the decoder source pad to decodedQueue sink pad.
|
||||
*/
|
||||
if (!g_signal_connect(m_pipeline.decoder, "pad-added", G_CALLBACK(onPadAdded), this)) {
|
||||
ACSDK_ERROR(LX("handleSetAttachmentReaderSourceFailed").d("reason", "connectPadAddedSignalFailed"));
|
||||
|
@ -938,7 +952,7 @@ void MediaPlayer::handleSetIStreamSource(
|
|||
|
||||
/*
|
||||
* Once the source pad for the decoder has been added, the decoder emits the pad-added signal. Connect the signal
|
||||
* to the callback which performs the linking of the decoder source pad to the converter sink pad.
|
||||
* to the callback which performs the linking of the decoder source pad to the decodedQueue sink pad.
|
||||
*/
|
||||
if (!g_signal_connect(m_pipeline.decoder, "pad-added", G_CALLBACK(onPadAdded), this)) {
|
||||
ACSDK_ERROR(LX("handleSetIStreamSourceFailed").d("reason", "connectPadAddedSignalFailed"));
|
||||
|
@ -966,7 +980,7 @@ void MediaPlayer::handleSetSource(std::string url, std::promise<MediaPlayer::Sou
|
|||
* The first pad that is added may not be the correct stream (ie may be a video stream), and will fail.
|
||||
*
|
||||
* Once the source pad for the decoder has been added, the decoder emits the pad-added signal. Connect the signal
|
||||
* to the callback which performs the linking of the decoder source pad to the converter sink pad.
|
||||
* to the callback which performs the linking of the decoder source pad to the decodedQueue sink pad.
|
||||
*/
|
||||
if (!g_signal_connect(m_pipeline.decoder, "pad-added", G_CALLBACK(onPadAdded), this)) {
|
||||
ACSDK_ERROR(LX("handleSetSourceForUrlFailed").d("reason", "connectPadAddedSignalFailed"));
|
||||
|
|
37
README.md
37
README.md
|
@ -86,39 +86,16 @@ Focus management is not specific to Capability Agents or Directive Handlers, and
|
|||
|
||||
**Note**: Features, updates, and resolved issues from previous releases are available to view in [CHANGELOG.md](https://github.com/alexa/alexa-client-sdk/blob/master/CHANGELOG.md).
|
||||
|
||||
v1.2 released 10/27/2017:
|
||||
v1.2.1 released 11/16/2017:
|
||||
|
||||
**Enhancements**
|
||||
|
||||
* Updated MediaPlayer to solve stability issues
|
||||
* All capability agents were refined to work with the updated MediaPlayer
|
||||
* Added the TemplateRuntime capability agent
|
||||
* Added the SpeakerManager capability agent
|
||||
* Added a configuration option ("sampleApp":"endpoint") that allows the endpoint that SampleApp connects to to be specified without changing code or rebuilding
|
||||
* Added very verbose capture of libcurl debug information
|
||||
* Added an observer interface to observer audio state changes from AudioPlayer
|
||||
* Added support for StreamMetadataExtracted Event. Stream tags found in the stream are represented in JSON and sent to AVS
|
||||
* Added to the SampleApp a simple GuiRenderer as an observer to the TemplateRuntime Capability Agent
|
||||
* Moved shared libcurl functionality to AVSCommon/Utils
|
||||
* Added a CMake option to exclude tests from the "make all" build. Use "cmake <absolute-path-to-source>
|
||||
-DACSDK_EXCLUDE_TEST_FROM_ALL=ON" to enable it. When this option is enabled "make unit" and "make integration" still could be used to build and run the tests
|
||||
* Added comments to `AlexaClientSDKConfig.json`. These descriptions provide additional guidance for what is expected for each field.
|
||||
* Enabled pause and resume controls for Pandora.
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
* Previously scheduled alerts now play following a restart
|
||||
* General stability fixes
|
||||
* Bug fix for CertifiedSender blocking forever if the network goes down while it's trying to send a message to AVS
|
||||
* Fixes for known issue of Alerts integration tests fail: AlertsTest.UserLongUnrelatedBargeInOnActiveTimer and AlertsTest.handleOneTimerWithVocalStop
|
||||
* Attempting to end a tap-to-talk interaction with the tap-to-talk button wouldn't work
|
||||
* SharedDataStream could encounter a race condition due to a combination of a blocking Reader and a Writer closing before writing any data
|
||||
* Bug-fix for the ordering of notifications within alerts scheduling. This fixes the issue where a local-stop on an alert would also stop a subsequent alert if it were to begin without delay
|
||||
|
||||
**Known Issues**
|
||||
|
||||
* Capability agent for Notifications is not included in this release
|
||||
* Inconsistent playback behavior when resuming content ("Alexa, pause." / "Alexa, resume."). Specifically, handling playback offsets, which causes the content to play from the start. This behavior is also occasionally seen with "Next" /
|
||||
"Previous" as well
|
||||
* `ACL`'s asynchronous receipt of audio attachments may manage resources poorly in scenarios where attachments are received but not consumed
|
||||
* Music playback failures may result in an error Text to Speech being rendered repeatedly
|
||||
* Reminders and named timers don't sound when there is no connection
|
||||
* GUI cards don't show for Kindle
|
||||
* Bug fix for [issue #329](https://github.com/alexa/avs-device-sdk/issues/329) - `HTTP2Transport` instances no longer leak when `SERVER_SIDE_DISCONNECT` is encountered.
|
||||
* Bug fix for [issue #189](https://github.com/alexa/avs-device-sdk/issues/189) - Fixed a race condition in the `Timer` class that sometimes caused `SpeechSynthesizer` to get stuck in the "Speaking" state.
|
||||
* Bug fix for a race condition that caused `SpeechSynthesizer` to ignore subsequent `Speak` directives.
|
||||
* Bug fix for corrupted mime attachments.
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
|
||||
**IMPORTANT**: Before you create an issue, please take a look at our [Issue Reporting Guide](https://github.com/alexa/avs-device-sdk/wiki/Issue-Reporting-Guide).
|
||||
|
||||
|
||||
## Briefly summarize your issue:
|
||||
<!--- Provide a more detailed introduction to the issue itself, and why you consider it to be a bug -->
|
||||
|
||||
## What is the expected behavior?
|
||||
<!--- Tell us what should happen -->
|
||||
|
||||
## What behavior are you observing?
|
||||
<!--- Tell us what's happening instead -->
|
||||
|
||||
## Provide the steps to reproduce the issue, if applicable:
|
||||
<!--- Provide the steps for reproduce -->
|
||||
|
||||
## Tell us about your environment:
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
### What version of the AVS Device SDK are you using?
|
||||
<x.y.z>
|
||||
|
||||
### Tell us what hardware you're using:
|
||||
- [ ] Desktop / Laptop
|
||||
- [ ] Raspberry Pi
|
||||
- [ ] Other - tell us more:
|
||||
|
||||
|
||||
### Tell us about your OS (Type & version):
|
||||
- [ ] Linux
|
||||
- [ ] MacOS
|
||||
- [ ] Raspbian Stretch
|
||||
- [ ] Raspbian Jessy
|
||||
- [ ] Other - tell us more:
|
|
@ -16,6 +16,7 @@
|
|||
from flask import Flask, redirect, request
|
||||
import requests
|
||||
import json
|
||||
import commentjson
|
||||
|
||||
from os.path import abspath, isfile, dirname
|
||||
import sys
|
||||
|
@ -47,6 +48,9 @@ amazonLwaApiHeaders = {'Content-Type': 'application/x-www-form-urlencoded'}
|
|||
# Default configuration filename, to be filled by CMake
|
||||
defaultConfigFilename = "${SDK_CONFIG_FILE_TARGET}"
|
||||
|
||||
# Default string for refresh token in configuration file.
|
||||
defaultRefreshTokenString = "{SDK_CONFIG_REFRESH_TOKEN}"
|
||||
|
||||
# JSON keys for config file
|
||||
CLIENT_ID = 'clientId'
|
||||
CLIENT_SECRET = 'clientSecret'
|
||||
|
@ -71,12 +75,13 @@ if not isfile(configFilename):
|
|||
|
||||
try:
|
||||
configFile = open(configFilename,'r')
|
||||
|
||||
except IOError:
|
||||
print 'File "' + configFilename + '" not found!'
|
||||
sys.exit(1)
|
||||
else:
|
||||
with configFile:
|
||||
configData = json.load(configFile)
|
||||
configData = commentjson.load(configFile)
|
||||
if not configData.has_key(authDelegateKey):
|
||||
print 'The config file "' + \
|
||||
configFilename + \
|
||||
|
@ -110,6 +115,10 @@ if authDelegateDict.has_key(REFRESH_TOKEN):
|
|||
amazonLwaApiUrl,
|
||||
data=urlencode(postData),
|
||||
headers=amazonLwaApiHeaders)
|
||||
defaultRefreshTokenString = authDelegateDict[REFRESH_TOKEN];
|
||||
if defaultRefreshTokenString == "":
|
||||
print 'Refresh token in the file cannot be empty. Please enter {SDK_CONFIG_REFRESH_TOKEN} in the refresh token'
|
||||
sys.exit(0)
|
||||
if 200 == tokenRefreshRequest.status_code:
|
||||
print 'You have a valid refresh token already in the file.'
|
||||
sys.exit(0)
|
||||
|
@ -159,7 +168,7 @@ def get_refresh_token():
|
|||
'<br/>' + shutdown()
|
||||
authDelegateDict['refreshToken'] = tokenRequest.json()['refresh_token']
|
||||
try:
|
||||
configFile = open(configFilename,'w')
|
||||
configFile = open(configFilename,'r')
|
||||
except IOError:
|
||||
print 'File "' + configFilename + '" cannot be opened!'
|
||||
return '<h1>The file "' + \
|
||||
|
@ -168,8 +177,15 @@ def get_refresh_token():
|
|||
shutdown() + \
|
||||
'</h1>'
|
||||
else:
|
||||
with configFile:
|
||||
json.dump(configData, configFile, indent=4, separators=(',',':'))
|
||||
fileContent = configFile.read()
|
||||
if defaultRefreshTokenString not in fileContent:
|
||||
print '"{defaultRefreshTokenString} not in {configFilename}"'
|
||||
return '<h1>' + defaultRefreshTokenString + ' string not present in ' + configFilename + \
|
||||
' please check the file ' + shutdown() + '</h1>'
|
||||
else:
|
||||
configFile = open(configFilename,'w')
|
||||
fileContent = fileContent.replace(defaultRefreshTokenString, tokenRequest.json()['refresh_token'])
|
||||
configFile.write(fileContent)
|
||||
return '<h1>The file is written successfully.<br/>' + shutdown() + '</h1>'
|
||||
|
||||
app.run(host='127.0.0.1',port='3000')
|
||||
|
|
Loading…
Reference in New Issue