diff --git a/libnymea-app-core/connection/awsclient.cpp b/libnymea-app-core/connection/awsclient.cpp index 116c0ee8..af0ba778 100644 --- a/libnymea-app-core/connection/awsclient.cpp +++ b/libnymea-app-core/connection/awsclient.cpp @@ -11,11 +11,10 @@ #include "qmqtt.h" #include "sigv4utils.h" -static QByteArray clientId = "8rjhfdlf9jf1suok2jcrltd6v"; -static QByteArray region = "eu-west-1"; -//static QByteArray service = "iotdevicegateway"; -static QByteArray service = "iotdata"; +// This is Symantec's root CA certificate and most platforms should +// have this in their certificate storage already, but as we can't +// be certain about the core's setup, let's deploy it ourselves. static QByteArray rootCA = "-----BEGIN CERTIFICATE-----\n\ MIIE0zCCA7ugAwIBAgIQGNrRniZ96LtKIVjNzGs7SjANBgkqhkiG9w0BAQUFADCB\n\ yjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL\n\ @@ -51,6 +50,31 @@ AWSClient::AWSClient(QObject *parent) : QObject(parent), { m_nam = new QNetworkAccessManager(this); + AWSConfiguration config; + // Community environment + config.clientId = "35duli0b13c7pet5k4bcv8pbu"; + config.poolId = "eu-west-1_WZVsaBsaY"; + config.identityPoolId = "eu-west-1:17449947-1a2f-4dda-aa49-7c5b1eec78d7"; + config.certificateEndpoint = "https://communityservice-cloud.nymea.io/certificatews/certificate"; + config.certificateApiKey = "aIRQv4yDdF6ASq12X1CPp7b6MpkdODfI3AOjOnkE"; + config.certificateVendorId = "d399290a-0599-4895-b4c3-34d2bdb579f4"; + config.mqttEndpoint = "a2d0ba9572wepp.iot.eu-west-1.amazonaws.com"; + config.region = "eu-west-1"; + config.apiEndpoint = "api-cloud.guh.io"; + m_configs.append(config); + + // Testing environment + config.clientId = "8rjhfdlf9jf1suok2jcrltd6v"; + config.poolId = "eu-west-1_6eX6YjmXr"; + config.identityPoolId = "eu-west-1:108a174c-5786-40f9-966a-1a0cd33d6801"; + config.certificateEndpoint = "https://testcommunityservice-cloud.nymea.io/certificatews/certificate"; + config.certificateApiKey = "VhmAUy75eZ9jXaUEjgWZh9PpSIykPGBK7AZFPimh"; + config.certificateVendorId = "testVendor001"; + config.mqttEndpoint = "a2addxakg5juii.iot.eu-west-1.amazonaws.com"; + config.region = "eu-west-1"; + config.apiEndpoint = "testapi-cloud.guh.io"; + m_configs.append(config); + QSettings settings; settings.beginGroup("cloud"); m_username = settings.value("username").toString(); @@ -59,6 +83,7 @@ AWSClient::AWSClient(QObject *parent) : QObject(parent), m_accessTokenExpiry = settings.value("accessTokenExpiry").toDateTime(); m_idToken = settings.value("idToken").toByteArray(); m_refreshToken = settings.value("refreshToken").toByteArray(); + m_confirmationPending = settings.value("confirmationPending", false).toBool(); m_identityId = settings.value("identityId").toByteArray(); @@ -88,16 +113,22 @@ AWSDevices *AWSClient::awsDevices() const return m_devices; } +bool AWSClient::confirmationPending() const +{ + return m_confirmationPending; +} + void AWSClient::login(const QString &username, const QString &password) { m_username = username; - // Ok... Please fogive me for this... AWS APIs are just unbearable... can't be bothered - // any more to walk through another chain of calls in order to have the refreshToken working. + // Due to an issue in AWS apis it's very complex to use the refresh token. Taking a shortcut here for now: // Will store the password in the config for now and re-login when the accessToken expires. // See: https://forums.aws.amazon.com/thread.jspa?threadID=287978 + // Ideally we'd use the refresh token and not store the password at all (see: refreshAccessToken()) m_password = password; - QUrl url("https://cognito-idp.eu-west-1.amazonaws.com/"); + QString host = QString("cognito-idp.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); QUrlQuery query; query.addQueryItem("Action", "InitiateAuth"); @@ -106,12 +137,12 @@ void AWSClient::login(const QString &username, const QString &password) QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); - request.setRawHeader("Host", "cognito-idp.eu-west-1.amazonaws.com"); + request.setRawHeader("Host", host.toUtf8()); request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityProviderService.InitiateAuth"); QVariantMap params; params.insert("AuthFlow", "USER_PASSWORD_AUTH"); - params.insert("ClientId", clientId); + params.insert("ClientId", m_configs.at(m_usedConfigIndex).clientId); QVariantMap authParams; authParams.insert("USERNAME", username); @@ -131,6 +162,7 @@ void AWSClient::login(const QString &username, const QString &password) qWarning() << "Error logging in to aws:" << reply->error() << reply->errorString(); m_username.clear(); m_password.clear(); + emit loginResult(LoginErrorUnknownError); return; } QByteArray data = reply->readAll(); @@ -140,6 +172,7 @@ void AWSClient::login(const QString &username, const QString &password) qWarning() << "Failed to parse AWS login response" << error.errorString(); m_username.clear(); m_password.clear(); + emit loginResult(LoginErrorUnknownError); return; } @@ -160,7 +193,7 @@ void AWSClient::login(const QString &username, const QString &password) settings.setValue("idToken", m_idToken); settings.setValue("refreshToken", m_refreshToken); - qDebug() << "AWS login successful" << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Indented)); + qDebug() << "AWS login successful";// << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Indented)); emit isLoggedInChanged(); qDebug() << "Getting cognito ID"; @@ -177,9 +210,310 @@ void AWSClient::logout() emit isLoggedInChanged(); } +void AWSClient::signup(const QString &username, const QString &password) +{ + m_userId = QUuid::createUuid().toString().remove(QRegExp("[{}]")); + m_username = username; + m_password = password; + + QString host = QString("cognito-idp.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); + + QUrlQuery query; + query.addQueryItem("Action", "SignUp"); + query.addQueryItem("Version", "2016-04-18"); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); + request.setRawHeader("Host", host.toUtf8()); + request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityProviderService.SignUp"); + + QVariantMap params; + params.insert("ClientId", m_configs.at(m_usedConfigIndex).clientId); + params.insert("Username", m_userId); + params.insert("Password", password); + + QVariantMap emailAttribute; + emailAttribute.insert("Name", "email"); + emailAttribute.insert("Value", username); + + QVariantList userAttributes; + userAttributes.append(emailAttribute); + params.insert("UserAttributes", userAttributes); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(params); + QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact); + + qDebug() << "Signing up to AWS as user:" << username << payload; + + QNetworkReply *reply = m_nam->post(request, payload); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + QByteArray data = reply->readAll(); + reply->deleteLater(); + qDebug() << "AWS signup reply:" << data; + + if (reply->error() == QNetworkReply::ProtocolInvalidOperationError) { + emit signupResult(LoginErrorInvalidUserOrPass); + return; + } + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Error signing up to aws:" << reply->error() << reply->errorString(); + m_username.clear(); + m_password.clear(); + emit signupResult(LoginErrorUnknownError); + return; + } + + emit signupResult(LoginErrorNoError); + + QSettings settings; + settings.beginGroup("cloud"); + settings.setValue("username", m_username); + settings.setValue("password", m_password); + settings.setValue("confirmationPending", true); + + m_confirmationPending = true; + emit confirmationPendingChanged(); + }); +} + +void AWSClient::confirmRegistration(const QString &code) +{ + QString host = QString("cognito-idp.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); + + QUrlQuery query; + query.addQueryItem("Action", "ConfirmSignUp"); + query.addQueryItem("Version", "2016-04-18"); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); + request.setRawHeader("Host", host.toUtf8()); + request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityProviderService.ConfirmSignUp"); + + QVariantMap params; + params.insert("ClientId", m_configs.at(m_usedConfigIndex).clientId); + params.insert("Username", m_userId); + params.insert("ConfirmationCode", code); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(params); + QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact); + + qDebug() << "Confirming registration for user:" << m_username; + + QNetworkReply *reply = m_nam->post(request, payload); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + QByteArray data = reply->readAll(); + reply->deleteLater(); + qDebug() << "AWS signup reply:" << data; + + if (reply->error() == QNetworkReply::ProtocolInvalidOperationError) { + QJsonParseError error; + QVariantMap result = QJsonDocument::fromJson(data, &error).toVariant().toMap(); + if (result.value("__type").toString() == "com.amazonaws.cognito.identity.idp.model#CodeMismatchException") { + emit confirmationResult(LoginErrorInvalidCode); + return; + } else if (result.value("__type").toString() == "com.amazonaws.cognito.identity.idp.model#AliasExistsException") { + emit confirmationResult(LoginErrorUserExists); + return; + } + emit confirmationResult(LoginErrorUnknownError); + return; + } + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Error confirming registration:" << reply->error() << reply->errorString(); + emit confirmationResult(LoginErrorUnknownError); + return; + } + + emit confirmationResult(LoginErrorNoError); + login(m_username, m_password); + fetchDevices(); + }); +} + +void AWSClient::forgotPassword(const QString &username) +{ + QString host = QString("cognito-idp.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); + + QUrlQuery query; + query.addQueryItem("Action", "ForgotPassword"); + query.addQueryItem("Version", "2016-04-18"); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); + request.setRawHeader("Host", host.toUtf8()); + request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityProviderService.ForgotPassword"); + + QVariantMap params; + params.insert("ClientId", m_configs.at(m_usedConfigIndex).clientId); + params.insert("Username", username); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(params); + QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact); + + qDebug() << "Forgot password for user:" << username << payload; + + QNetworkReply *reply = m_nam->post(request, payload); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (reply->error() == QNetworkReply::ProtocolInvalidOperationError) { + QJsonDocument jsonDoc = QJsonDocument::fromJson(data); + if (jsonDoc.toVariant().toMap().value("__type").toString() == "com.amazonaws.cognito.identity.idp.model#LimitExceededException") { + emit forgotPasswordResult(LoginErrorLimitExceeded); + return; + } + } + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Error calling ForgotPassword:" << reply->error() << reply->errorString() << data; + emit forgotPasswordResult(LoginErrorUnknownError); + return; + } + + qDebug() << "AWS forgotPassword success:" << data; + emit forgotPasswordResult(LoginErrorNoError); + + }); +} + +void AWSClient::confirmForgotPassword(const QString &username, const QString &code, const QString &newPassword) +{ + QString host = QString("cognito-idp.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); + + QUrlQuery query; + query.addQueryItem("Action", "ConfirmForgotPassword"); + query.addQueryItem("Version", "2016-04-18"); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); + request.setRawHeader("Host", host.toUtf8()); + request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityProviderService.ConfirmForgotPassword"); + + QVariantMap params; + params.insert("ClientId", m_configs.at(m_usedConfigIndex).clientId); + params.insert("ConfirmationCode", code); + params.insert("Username", username); + params.insert("Password", newPassword); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(params); + QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact); + + qDebug() << "ConfirmForgotPassword for user:" << username << payload; + + QNetworkReply *reply = m_nam->post(request, payload); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Error calling ConfirmForgotPassword:" << reply->error() << reply->errorString() << data; + emit confirmForgotPasswordResult(LoginErrorUnknownError); + return; + } + + qDebug() << "AWS ConfirmForgotPassword success:" << data; + emit confirmForgotPasswordResult(LoginErrorNoError); + + }); +} + +void AWSClient::deleteAccount() +{ + if (!isLoggedIn()) { + qWarning() << "Not logged in at AWS. Can't delete account"; + return; + } + if (tokensExpired()) { + qDebug() << "Cannot unpair device. Need to refresh our tokens"; + refreshAccessToken(); + m_callQueue.append(QueuedCall("deleteAccount")); + return; + } + qDebug() << "Deleting account"; + + QUrl url(QString("https://%1/users/profiles/%2").arg(m_configs.at(m_usedConfigIndex).apiEndpoint).arg(m_username)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("x-api-idToken", m_idToken); + + qDebug() << "DELETE" << url.toString(); + qDebug() << "HEADERS:"; + foreach (const QByteArray &hdr, request.rawHeaderList()) { + qDebug() << hdr << ":" << request.rawHeader(hdr); + } + + QNetworkReply *reply = m_nam->deleteResource(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + QByteArray data = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Error deleting cloud user account:" << reply->error() << reply->errorString() << qUtf8Printable(data); + return; + } + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse JSON from server" << error.errorString() << qUtf8Printable(data); + return; + } + logout(); + qDebug() << "Account deleted" << data; + }); +} + +void AWSClient::unpairDevice(const QString &boxId) +{ + if (!isLoggedIn()) { + qWarning() << "Not logged in at AWS. Can't unpair device"; + return; + } + if (tokensExpired()) { + qDebug() << "Cannot unpair device. Need to refresh our tokens"; + refreshAccessToken(); + m_callQueue.append(QueuedCall("unpairDevice", boxId)); + return; + } + qDebug() << "unpairing device"; + QUrl url(QString("https://%1/users/devices/%2").arg(m_configs.at(m_usedConfigIndex).apiEndpoint).arg(boxId)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("x-api-idToken", m_idToken); + + QNetworkReply *reply = m_nam->deleteResource(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, boxId]() { + reply->deleteLater(); + QByteArray data = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "Error unpairing cloud device:" << reply->error() << reply->errorString() << qUtf8Printable(data); + return; + } + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse JSON from server" << error.errorString() << qUtf8Printable(data); + return; + } + qDebug() << "Device unpaired" << data; + m_devices->remove(boxId); + + }); +} + void AWSClient::getId() { - QUrl url("https://cognito-identity.eu-west-1.amazonaws.com/"); + QString host = QString("cognito-identity.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); QUrlQuery query; query.addQueryItem("Action", "GetId"); @@ -188,19 +522,21 @@ void AWSClient::getId() QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); - request.setRawHeader("Host", "cognito-identity.eu-west-1.amazonaws.com"); + request.setRawHeader("Host", host.toUtf8()); request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityService.GetId"); QVariantMap logins; - logins.insert("cognito-idp.eu-west-1.amazonaws.com/eu-west-1_6eX6YjmXr", m_idToken); + logins.insert(QString("cognito-idp.%1.amazonaws.com/%2").arg(m_configs.at(m_usedConfigIndex).region).arg(m_configs.at(m_usedConfigIndex).poolId).toUtf8(), m_idToken); QVariantMap params; - params.insert("IdentityPoolId", "eu-west-1:108a174c-5786-40f9-966a-1a0cd33d6801"); + params.insert("IdentityPoolId", m_configs.at(m_usedConfigIndex).identityPoolId.toUtf8()); params.insert("Logins", logins); QJsonDocument jsonDoc = QJsonDocument::fromVariant(params); QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact); +// qDebug() << "Posting:" << request.url().toString(); +// qDebug() << "Payload:" << payload; QNetworkReply *reply = m_nam->post(request, payload); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); @@ -220,7 +556,7 @@ void AWSClient::getId() settings.beginGroup("cloud"); settings.setValue("identityId", m_identityId); - qDebug() << "Received cognito identity id" << m_identityId; + qDebug() << "Received cognito identity id" << m_identityId;// << qUtf8Printable(data); getCredentialsForIdentity(m_identityId); }); @@ -240,13 +576,13 @@ void AWSClient::fetchCertificate(const QString &uuid, std::functionget(request); - connect(reply, &QNetworkReply::finished, this, [reply, callback]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, callback]() { reply->deleteLater(); QByteArray data = reply->readAll(); if (reply->error() != QNetworkReply::NoError) { @@ -266,14 +602,29 @@ void AWSClient::fetchCertificate(const QString &uuid, std::functionpost(request, payload); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { qWarning() << "Error calling GetCredentialsForIdentity" << reply->errorString(); + emit loginResult(LoginErrorUnknownError); return; } QByteArray data = reply->readAll(); @@ -314,6 +666,7 @@ void AWSClient::getCredentialsForIdentity(const QString &identityId) QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); if (error.error != QJsonParseError::NoError) { qWarning() << "Error parsing JSON reply from GetCredentialsForIdentity" << error.errorString(); + emit loginResult(LoginErrorUnknownError); return; } QVariantMap credentialsMap = jsonDoc.toVariant().toMap().value("Credentials").toMap(); @@ -330,7 +683,8 @@ void AWSClient::getCredentialsForIdentity(const QString &identityId) settings.setValue("sessionToken", m_sessionToken); settings.setValue("sessionTokenExpiry", m_sessionTokenExpiry); - qDebug() << "AWS Credentials for Identity received."; + qDebug() << "AWS Credentials for Identity received.";// << data; + emit loginResult(LoginErrorNoError); while (!m_callQueue.isEmpty()) { QueuedCall qc = m_callQueue.takeFirst(); @@ -360,7 +714,6 @@ bool AWSClient::postToMQTT(const QString &boxId, std::function callb m_callQueue.append(QueuedCall("postToMQTT", boxId, callback)); return true; // So far it looks we're doing ok... let's return true } - QString host = "a2addxakg5juii.iot.eu-west-1.amazonaws.com"; QString topic = QString("%1/%2/proxy").arg(boxId).arg(QString(m_identityId)); // This is somehow broken in AWS... @@ -378,14 +731,14 @@ bool AWSClient::postToMQTT(const QString &boxId, std::function callb QByteArray payload = QJsonDocument::fromVariant(params).toJson(QJsonDocument::Compact); - QNetworkRequest request("https://" + host + path); + QNetworkRequest request("https://" + m_configs.at(m_usedConfigIndex).mqttEndpoint + path); request.setRawHeader("content-type", "application/json"); - request.setRawHeader("host", host.toUtf8()); + request.setRawHeader("host", m_configs.at(m_usedConfigIndex).mqttEndpoint.toUtf8()); - SigV4Utils::signRequest(QNetworkAccessManager::PostOperation, request, region, service, m_accessKeyId, m_secretKey, m_sessionToken, payload); + SigV4Utils::signRequest(QNetworkAccessManager::PostOperation, request, m_configs.at(m_usedConfigIndex).region, "iotdata", m_accessKeyId, m_secretKey, m_sessionToken, payload); // Workaround MQTT broker url weirdness as described above - request.setUrl("https://" + host + path1); + request.setUrl("https://" + m_configs.at(m_usedConfigIndex).mqttEndpoint + path1); qDebug() << "Posting to MQTT:" << request.url().toString(); qDebug() << "HEADERS:"; @@ -430,7 +783,7 @@ void AWSClient::fetchDevices() return; } qDebug() << "Fetching cloud devices"; - QUrl url("https://z6368zhf2m.execute-api.eu-west-1.amazonaws.com/dev/devices"); + QUrl url(QString("https://%1/users/devices").arg(m_configs.at(m_usedConfigIndex).apiEndpoint)); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setRawHeader("x-api-idToken", m_idToken); @@ -483,7 +836,8 @@ void AWSClient::refreshAccessToken() // Non-working block... Enable this if Amazon ever fixes their API... - QUrl url("https://cognito-idp.eu-west-1.amazonaws.com/"); + QString host = QString("cognito-idp.%1.amazonaws.com").arg(m_configs.at(m_usedConfigIndex).region); + QUrl url(QString("https://%1/").arg(host)); QUrlQuery query; query.addQueryItem("Action", "InitiateAuth"); @@ -492,12 +846,12 @@ void AWSClient::refreshAccessToken() QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0"); - request.setRawHeader("Host", "cognito-idp.eu-west-1.amazonaws.com"); + request.setRawHeader("Host", host.toUtf8()); request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityProviderService.InitiateAuth"); QVariantMap params; params.insert("AuthFlow", "REFRESH_TOKEN_AUTH"); - params.insert("ClientId", clientId); + params.insert("ClientId", m_configs.at(m_usedConfigIndex).clientId); QVariantMap authParams; authParams.insert("REFRESH_TOKEN", m_refreshToken); @@ -581,7 +935,7 @@ QHash AWSDevices::roleNames() const AWSDevice *AWSDevices::getDevice(const QString &uuid) const { for (int i = 0; i < m_list.count(); i++) { - if (m_list.at(i)->id() == uuid) { + if (QUuid(m_list.at(i)->id()) == QUuid(uuid)) { return m_list.at(i); } } @@ -605,6 +959,25 @@ void AWSDevices::insert(AWSDevice *device) emit countChanged(); } +void AWSDevices::remove(const QString &uuid) +{ + int idx = -1; + for (int i = 0; i < m_list.count(); i++) { + if (m_list.at(i)->id() == uuid) { + idx = i; + break; + } + } + if (idx == -1) { + qWarning() << "Cannot remove AWS with id" << uuid << "as there is no such device"; + return; + } + beginRemoveRows(QModelIndex(), idx, idx); + m_list.takeAt(idx)->deleteLater(); + endRemoveRows(); + emit countChanged(); +} + AWSDevice::AWSDevice(const QString &id, const QString &name, bool online, QObject *parent): QObject (parent), m_id(id), diff --git a/libnymea-app-core/connection/awsclient.h b/libnymea-app-core/connection/awsclient.h index 2ee63fa3..8d970043 100644 --- a/libnymea-app-core/connection/awsclient.h +++ b/libnymea-app-core/connection/awsclient.h @@ -46,31 +46,66 @@ public: Q_INVOKABLE AWSDevice* getDevice(const QString &uuid) const; Q_INVOKABLE AWSDevice* get(int index) const; void insert(AWSDevice *device); + void remove(const QString &uuid); signals: void countChanged(); private: QList m_list; }; +class AWSConfiguration { +public: + QByteArray clientId; + QString poolId; + QString identityPoolId; + QString certificateEndpoint; + QString certificateApiKey; + QString certificateVendorId; + QString mqttEndpoint; + QString region; + QString apiEndpoint; +}; + class AWSClient : public QObject { Q_OBJECT Q_PROPERTY(bool isLoggedIn READ isLoggedIn NOTIFY isLoggedInChanged) Q_PROPERTY(QString username READ username NOTIFY isLoggedInChanged) + Q_PROPERTY(bool confirmationPending READ confirmationPending NOTIFY confirmationPendingChanged) Q_PROPERTY(QByteArray userId READ userId NOTIFY isLoggedInChanged) Q_PROPERTY(QByteArray idToken READ idToken NOTIFY isLoggedInChanged) Q_PROPERTY(AWSDevices* awsDevices READ awsDevices CONSTANT) + Q_PROPERTY(int config READ config WRITE setConfig NOTIFY configChanged) + public: + enum LoginError { + LoginErrorNoError, + LoginErrorInvalidUserOrPass, + LoginErrorInvalidCode, + LoginErrorUserExists, + LoginErrorLimitExceeded, + LoginErrorUnknownError + }; + Q_ENUM(LoginError) + explicit AWSClient(QObject *parent = nullptr); bool isLoggedIn() const; QString username() const; QByteArray userId() const; AWSDevices* awsDevices() const; + bool confirmationPending() const; Q_INVOKABLE void login(const QString &username, const QString &password); Q_INVOKABLE void logout(); + Q_INVOKABLE void signup(const QString &username, const QString &password); + Q_INVOKABLE void confirmRegistration(const QString &code); + Q_INVOKABLE void forgotPassword(const QString &username); + Q_INVOKABLE void confirmForgotPassword(const QString &username, const QString &code, const QString &newPassword); + Q_INVOKABLE void deleteAccount(); + + Q_INVOKABLE void unpairDevice(const QString &boxId); Q_INVOKABLE void fetchDevices(); @@ -83,10 +118,22 @@ public: void fetchCertificate(const QString &uuid, std::function callback); + int config() const; + void setConfig(int index); + signals: + void loginResult(LoginError error); + void signupResult(LoginError error); + void confirmationResult(LoginError error); + void forgotPasswordResult(LoginError error); + void confirmForgotPasswordResult(LoginError error); + void isLoggedInChanged(); + void confirmationPendingChanged(); void devicesFetched(); + void configChanged(); + private: void refreshAccessToken(); void getCredentialsForIdentity(const QString &identityId); @@ -96,9 +143,12 @@ private: private: QNetworkAccessManager *m_nam = nullptr; + QString m_userId; QString m_username; QString m_password; + bool m_confirmationPending = false; + QByteArray m_accessToken; QDateTime m_accessTokenExpiry; QByteArray m_idToken; @@ -114,6 +164,7 @@ private: class QueuedCall { public: QueuedCall(const QString &method): method(method) { } + QueuedCall(const QString &method, const QString &boxId): method(method), boxId(boxId) { } QueuedCall(const QString &method, const QString &boxId, std::function callback): method(method), boxId(boxId), callback(callback) {} QString method; QString boxId; @@ -122,6 +173,8 @@ private: QList m_callQueue; + QList m_configs; + int m_usedConfigIndex = 0; AWSDevices *m_devices; }; diff --git a/libnymea-app-core/connection/cloudtransport.cpp b/libnymea-app-core/connection/cloudtransport.cpp index 9cdc6437..06666a51 100644 --- a/libnymea-app-core/connection/cloudtransport.cpp +++ b/libnymea-app-core/connection/cloudtransport.cpp @@ -34,7 +34,7 @@ CloudTransport::CloudTransport(AWSClient *awsClient, QObject *parent): QObject::connect(m_remoteproxyConnection, &RemoteProxyConnection::dataReady, this, [this](const QByteArray &data) { emit dataReady(data); }); - QObject::connect(m_remoteproxyConnection, &RemoteProxyConnection::errorOccured, this, [this] (RemoteProxyConnection::Error error) { + QObject::connect(m_remoteproxyConnection, &RemoteProxyConnection::errorOccured, this, [] (QAbstractSocket::SocketError error) { qDebug() << "Remote proxy Error:" << error; // emit NymeaTransportInterface::error(QAbstractSocket::ConnectionRefusedError); }); @@ -57,7 +57,8 @@ bool CloudTransport::connect(const QUrl &url) bool postResult = m_awsClient->postToMQTT(url.host(), [this](bool success) { if (success) { - m_remoteproxyConnection->connectServer(QUrl("wss://dev-remoteproxy.nymea.io")); + + m_remoteproxyConnection->connectServer(QUrl("wss://remoteproxy.nymea.io")); // m_remoteproxyConnection->connectServer(QUrl("wss://127.0.0.1:1212")); } }); @@ -87,9 +88,10 @@ NymeaTransportInterface::ConnectionState CloudTransport::connectionState() const case RemoteProxyConnection::StateConnected: case RemoteProxyConnection::StateAuthenticating: case RemoteProxyConnection::StateReady: - case RemoteProxyConnection::StateWaitTunnel: + case RemoteProxyConnection::StateAuthenticated: return NymeaTransportInterface::ConnectionStateConnecting; case RemoteProxyConnection::StateDisconnected: + case RemoteProxyConnection::StateDiconnecting: return NymeaTransportInterface::ConnectionStateDisconnected; } return ConnectionStateDisconnected; diff --git a/libnymea-app-core/discovery/discoverydevice.cpp b/libnymea-app-core/discovery/discoverydevice.cpp index fce6cd93..f6d5a45c 100644 --- a/libnymea-app-core/discovery/discoverydevice.cpp +++ b/libnymea-app-core/discovery/discoverydevice.cpp @@ -92,6 +92,8 @@ QVariant Connections::data(const QModelIndex &index, int role) const return m_connections.at(index.row())->bearerType(); case RoleSecure: return m_connections.at(index.row())->secure(); + case RoleOnline: + return m_connections.at(index.row())->online(); } return QVariant(); } @@ -111,10 +113,42 @@ void Connections::addConnection(Connection *connection) connection->setParent(this); beginInsertRows(QModelIndex(), m_connections.count(), m_connections.count()); m_connections.append(connection); + connect(connection, &Connection::onlineChanged, this, [this, connection]() { + int idx = m_connections.indexOf(connection); + if (idx < 0) { + return; + } + emit dataChanged(index(idx), index(idx), {RoleOnline}); + }); endInsertRows(); emit countChanged(); } +void Connections::removeConnection(Connection *connection) +{ + int idx = m_connections.indexOf(connection); + if (idx == -1) { + qWarning() << "Cannot remove connections as it's not in this model"; + return; + } + beginRemoveRows(QModelIndex(), idx, idx); + m_connections.takeAt(idx)->deleteLater(); + endRemoveRows(); + emit countChanged(); +} + +void Connections::removeConnection(int index) +{ + if (index < 0 || index >= m_connections.count()) { + qWarning() << "Index out of range. Not removing any connection"; + return; + } + beginRemoveRows(QModelIndex(), index, index); + m_connections.takeAt(index)->deleteLater(); + endRemoveRows(); + emit countChanged(); +} + Connection* Connections::get(int index) const { if (index >= 0 && index < m_connections.count()) { @@ -130,6 +164,7 @@ QHash Connections::roleNames() const roles.insert(RoleBearerType, "bearerType"); roles.insert(RoleName, "name"); roles.insert(RoleSecure, "secure"); + roles.insert(RoleOnline, "online"); return roles; } diff --git a/libnymea-app-core/discovery/discoverydevice.h b/libnymea-app-core/discovery/discoverydevice.h index bae24a61..4895eaba 100644 --- a/libnymea-app-core/discovery/discoverydevice.h +++ b/libnymea-app-core/discovery/discoverydevice.h @@ -75,7 +75,8 @@ public: RoleUrl, RoleName, RoleBearerType, - RoleSecure + RoleSecure, + RoleOnline }; Q_ENUM(Roles) Connections(QObject* parent = nullptr); @@ -83,6 +84,8 @@ public: QVariant data(const QModelIndex & index, int role = Qt::DisplayRole) const override; void addConnection(Connection *connection); + void removeConnection(Connection *connection); + void removeConnection(int index); Q_INVOKABLE Connection* find(const QUrl &url) const; Q_INVOKABLE Connection* get(int index) const; diff --git a/libnymea-app-core/discovery/discoverymodel.cpp b/libnymea-app-core/discovery/discoverymodel.cpp index 774ad4b8..d730a316 100644 --- a/libnymea-app-core/discovery/discoverymodel.cpp +++ b/libnymea-app-core/discovery/discoverymodel.cpp @@ -64,8 +64,24 @@ void DiscoveryModel::addDevice(DiscoveryDevice *device) emit countChanged(); } +void DiscoveryModel::removeDevice(DiscoveryDevice *device) +{ + int idx = m_devices.indexOf(device); + if (idx == -1) { + qWarning() << "Cannot remove DiscoveryDevice" << device << "as its nit in the model"; + return; + } + beginRemoveRows(QModelIndex(), idx, idx); + m_devices.takeAt(idx); + endRemoveRows(); + emit countChanged(); +} + DiscoveryDevice *DiscoveryModel::get(int index) const { + if (index < 0 || index >= m_devices.count()) { + return nullptr; + } return m_devices.at(index); } diff --git a/libnymea-app-core/discovery/discoverymodel.h b/libnymea-app-core/discovery/discoverymodel.h index 12f6aaab..47de7faa 100644 --- a/libnymea-app-core/discovery/discoverymodel.h +++ b/libnymea-app-core/discovery/discoverymodel.h @@ -46,6 +46,7 @@ public: QVariant data(const QModelIndex & index, int role = Qt::DisplayRole) const; void addDevice(DiscoveryDevice *device); + void removeDevice(DiscoveryDevice *device); Q_INVOKABLE DiscoveryDevice *get(int index) const; Q_INVOKABLE DiscoveryDevice *find(const QUuid &uuid); diff --git a/libnymea-app-core/discovery/nymeadiscovery.cpp b/libnymea-app-core/discovery/nymeadiscovery.cpp index cfe6a971..3dc00bfa 100644 --- a/libnymea-app-core/discovery/nymeadiscovery.cpp +++ b/libnymea-app-core/discovery/nymeadiscovery.cpp @@ -20,7 +20,13 @@ NymeaDiscovery::NymeaDiscovery(QObject *parent) : QObject(parent) m_bluetooth = new BluetoothServiceDiscovery(m_discoveryModel, this); #endif - connect(Engine::instance()->awsClient()->awsDevices(), &AWSDevices::countChanged, this, &NymeaDiscovery::syncCloudDevices); + m_cloudPollTimer.setInterval(5000); + connect(&m_cloudPollTimer, &QTimer::timeout, this, [](){ + if (Engine::instance()->awsClient()->isLoggedIn()) { + Engine::instance()->awsClient()->fetchDevices(); + } + }); + connect(Engine::instance()->awsClient(), &AWSClient::devicesFetched, this, &NymeaDiscovery::syncCloudDevices); } bool NymeaDiscovery::discovering() const @@ -44,11 +50,13 @@ void NymeaDiscovery::setDiscovering(bool discovering) syncCloudDevices(); Engine::instance()->awsClient()->fetchDevices(); } + m_cloudPollTimer.start(); } else { m_upnp->stopDiscovery(); if (m_bluetooth) { m_bluetooth->stopDiscovery(); } + m_cloudPollTimer.stop(); } emit discoveringChanged(); @@ -74,11 +82,31 @@ void NymeaDiscovery::syncCloudDevices() QUrl url; url.setScheme("cloud"); url.setHost(d->id()); - if (!device->connections()->find(url)) { - Connection *conn = new Connection(url, Connection::BearerTypeCloud, true, d->id()); - conn->setOnline(d->online()); + Connection *conn = device->connections()->find(url); + if (!conn) { + conn = new Connection(url, Connection::BearerTypeCloud, true, d->id()); device->connections()->addConnection(conn); } + conn->setOnline(d->online()); + } + + QList devicesToRemove; + for (int i = 0; i < m_discoveryModel->rowCount(); i++) { + DiscoveryDevice *device = m_discoveryModel->get(i); + for (int j = 0; j < device->connections()->rowCount(); j++) { + if (device->connections()->get(j)->bearerType() == Connection::BearerTypeCloud) { + if (Engine::instance()->awsClient()->awsDevices()->getDevice(device->uuid().toString()) == nullptr) { + device->connections()->removeConnection(j); + break; + } + } + } + if (device->connections()->rowCount() == 0) { + devicesToRemove.append(device); + } + } + while (!devicesToRemove.isEmpty()) { + m_discoveryModel->removeDevice(devicesToRemove.takeFirst()); } } diff --git a/libnymea-app-core/discovery/nymeadiscovery.h b/libnymea-app-core/discovery/nymeadiscovery.h index 95af1d07..faed4787 100644 --- a/libnymea-app-core/discovery/nymeadiscovery.h +++ b/libnymea-app-core/discovery/nymeadiscovery.h @@ -2,6 +2,7 @@ #define NYMEADISCOVERY_H #include +#include #include "connection/awsclient.h" @@ -10,6 +11,7 @@ class UpnpDiscovery; class ZeroconfDiscovery; class BluetoothServiceDiscovery; + class NymeaDiscovery : public QObject { Q_OBJECT @@ -38,6 +40,8 @@ private: ZeroconfDiscovery *m_zeroConf = nullptr; BluetoothServiceDiscovery *m_bluetooth = nullptr; + QTimer m_cloudPollTimer; + }; #endif // NYMEADISCOVERY_H diff --git a/libnymea-app-core/discovery/upnpdiscovery.cpp b/libnymea-app-core/discovery/upnpdiscovery.cpp index c35d02f9..87a9e861 100644 --- a/libnymea-app-core/discovery/upnpdiscovery.cpp +++ b/libnymea-app-core/discovery/upnpdiscovery.cpp @@ -255,6 +255,7 @@ void UpnpDiscovery::networkReplyFinished(QNetworkReply *reply) bool sslEnabled = url.scheme() == "nymeas" || url.scheme() == "wss"; QString displayName = QString("%1:%2").arg(url.host()).arg(url.port()); Connection *conn = new Connection(url, Connection::BearerTypeWifi, sslEnabled, displayName); + conn->setOnline(true); device->connections()->addConnection(conn); } } diff --git a/libnymea-app-core/discovery/zeroconfdiscovery.cpp b/libnymea-app-core/discovery/zeroconfdiscovery.cpp index f69f3490..d88aa44c 100644 --- a/libnymea-app-core/discovery/zeroconfdiscovery.cpp +++ b/libnymea-app-core/discovery/zeroconfdiscovery.cpp @@ -12,12 +12,14 @@ ZeroconfDiscovery::ZeroconfDiscovery(DiscoveryModel *discoveryModel, QObject *pa m_zeroconfJsonRPC = new QZeroConf(this); connect(m_zeroconfJsonRPC, &QZeroConf::serviceAdded, this, &ZeroconfDiscovery::serviceEntryAdded); connect(m_zeroconfJsonRPC, &QZeroConf::serviceUpdated, this, &ZeroconfDiscovery::serviceEntryAdded); + connect(m_zeroconfJsonRPC, &QZeroConf::serviceRemoved, this, &ZeroconfDiscovery::serviceEntryRemoved); m_zeroconfJsonRPC->startBrowser("_jsonrpc._tcp", QAbstractSocket::AnyIPProtocol); qDebug() << "ZeroConf: Created service browser for _jsonrpc._tcp:" << m_zeroconfJsonRPC->browserExists(); m_zeroconfWebSocket = new QZeroConf(this); connect(m_zeroconfWebSocket, &QZeroConf::serviceAdded, this, &ZeroconfDiscovery::serviceEntryAdded); connect(m_zeroconfWebSocket, &QZeroConf::serviceUpdated, this, &ZeroconfDiscovery::serviceEntryAdded); + connect(m_zeroconfWebSocket, &QZeroConf::serviceRemoved, this, &ZeroconfDiscovery::serviceEntryRemoved); m_zeroconfWebSocket->startBrowser("_ws._tcp", QAbstractSocket::AnyIPProtocol); qDebug() << "TeroConf: Created service browser for _ws._tcp:" << m_zeroconfWebSocket->browserExists(); #else @@ -84,14 +86,72 @@ void ZeroconfDiscovery::serviceEntryAdded(const QZeroConfService &entry) } else { url.setScheme(sslEnabled ? "wss" : "ws"); } -// entry url.setHost(!entry.ip().isNull() ? entry.ip().toString() : entry.ipv6().toString()); url.setPort(entry.port()); if (!device->connections()->find(url)){ qDebug() << "Zeroconf: Adding new connection to host:" << device->name() << url.toString(); QString displayName = QString("%1:%2").arg(url.host()).arg(url.port()); Connection *connection = new Connection(url, Connection::BearerTypeWifi, sslEnabled, displayName); + connection->setOnline(true); device->connections()->addConnection(connection); } } + +void ZeroconfDiscovery::serviceEntryRemoved(const QZeroConfService &entry) +{ + if (!entry.name().startsWith("nymea") || (entry.ip().isNull() && entry.ipv6().isNull())) { + return; + } + + QString uuid; + bool sslEnabled = false; + QString serverName; + QString version; + foreach (const QByteArray &key, entry.txt().keys()) { + QPair txtRecord = qMakePair(key, entry.txt().value(key)); + if (!sslEnabled && txtRecord.first == "sslEnabled") { + sslEnabled = (txtRecord.second == "true"); + } + if (txtRecord.first == "uuid") { + uuid = txtRecord.second; + } + if (txtRecord.first == "name") { + serverName = txtRecord.second; + } + if (txtRecord.first == "serverVersion") { + version = txtRecord.second; + } + } + +// qDebug() << "Zeroconf: Service entry removed" << entry.name(); + + DiscoveryDevice* device = m_discoveryModel->find(uuid); + if (!device) { + // Nothing to do... + return; + } + + QUrl url; + if (entry.type() == "_jsonrpc._tcp") { + url.setScheme(sslEnabled ? "nymeas" : "nymea"); + } else { + url.setScheme(sslEnabled ? "wss" : "ws"); + } + url.setHost(!entry.ip().isNull() ? entry.ip().toString() : entry.ipv6().toString()); + url.setPort(entry.port()); + Connection *connection = device->connections()->find(url); + if (!connection){ + // Connection url not found... + return; + } + + // Ok, now we need to remove it + device->connections()->removeConnection(connection); + + // And if there aren't any connections left, remove the entire device + if (device->connections()->rowCount() == 0) { + qDebug() << "Zeroconf: Removing connection from host:" << device->name() << url.toString(); + m_discoveryModel->removeDevice(device); + } +} #endif diff --git a/libnymea-app-core/discovery/zeroconfdiscovery.h b/libnymea-app-core/discovery/zeroconfdiscovery.h index 8c4361ac..905d009a 100644 --- a/libnymea-app-core/discovery/zeroconfdiscovery.h +++ b/libnymea-app-core/discovery/zeroconfdiscovery.h @@ -28,6 +28,7 @@ private: private slots: void serviceEntryAdded(const QZeroConfService &entry); + void serviceEntryRemoved(const QZeroConfService &entry); #endif }; diff --git a/libnymea-app-core/jsonrpc/jsonrpcclient.cpp b/libnymea-app-core/jsonrpc/jsonrpcclient.cpp index ccdab59f..b6609d31 100644 --- a/libnymea-app-core/jsonrpc/jsonrpcclient.cpp +++ b/libnymea-app-core/jsonrpc/jsonrpcclient.cpp @@ -236,6 +236,7 @@ int JsonRpcClient::requestPushButtonAuth(const QString &deviceName) void JsonRpcClient::setupRemoteAccess(const QString &idToken, const QString &userId) { + qDebug() << "Calling SetupRemoteAccess"; QVariantMap params; params.insert("idToken", idToken); params.insert("userId", userId); diff --git a/nymea-app/nymea-app.pro b/nymea-app/nymea-app.pro index cea9b13e..4fba9a75 100644 --- a/nymea-app/nymea-app.pro +++ b/nymea-app/nymea-app.pro @@ -92,3 +92,6 @@ BR=$$BRANDING target.path = /usr/bin INSTALLS += target + +DISTFILES += \ + ui/components/BusyOverlay.qml diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc index ca2c45f6..c58b97fe 100644 --- a/nymea-app/resources.qrc +++ b/nymea-app/resources.qrc @@ -155,12 +155,10 @@ ui/images/network-vpn.svg translations/nymea-app-de_DE.qm translations/nymea-app-en_US.qm - ui/AppSettingsPage.qml ui/images/stock_application.svg ui/delegates/ThingDelegate.qml ui/images/network-secure.svg ui/images/lock-broken.svg - ui/AboutPage.qml ui/images/sort-listitem.svg ui/devicepages/ShutterDevicePage.qml ui/images/shutter/shutter-000.svg @@ -242,8 +240,13 @@ ui/KeyboardLoader.qml ui/images/cloud.svg ui/system/CloudSettingsPage.qml - ui/connection/CloudLoginPage.qml ui/images/cloud-offline.svg ui/images/cloud-error.svg + ui/components/BusyOverlay.qml + ui/components/AWSPasswordTextField.qml + ui/appsettings/AboutPage.qml + ui/appsettings/AppSettingsPage.qml + ui/appsettings/DeveloperOptionsPage.qml + ui/appsettings/CloudLoginPage.qml diff --git a/nymea-app/ui/MainPage.qml b/nymea-app/ui/MainPage.qml index c7fe8202..30a45675 100644 --- a/nymea-app/ui/MainPage.qml +++ b/nymea-app/ui/MainPage.qml @@ -17,7 +17,7 @@ Page { model: ListModel { ListElement { iconSource: "../images/share.svg"; text: qsTr("Configure things"); page: "EditDevicesPage.qml" } ListElement { iconSource: "../images/magic.svg"; text: qsTr("Magic"); page: "MagicPage.qml" } - ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "AppSettingsPage.qml" } + ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "appsettings/AppSettingsPage.qml" } ListElement { iconSource: "../images/settings.svg"; text: qsTr("System settings"); page: "SettingsPage.qml" } } diff --git a/nymea-app/ui/Nymea.qml b/nymea-app/ui/Nymea.qml index 3f737188..03d0690d 100644 --- a/nymea-app/ui/Nymea.qml +++ b/nymea-app/ui/Nymea.qml @@ -39,6 +39,14 @@ ApplicationWindow { property string graphStyle: "bars" property string style: "light" property int currentMainViewIndex: 0 + property bool showHiddenOptions: false + property int cloudEnvironment: 0 + } + + Binding { + target: Engine.awsClient + property: "config" + value: settings.cloudEnvironment } Component.onCompleted: { diff --git a/nymea-app/ui/AboutPage.qml b/nymea-app/ui/appsettings/AboutPage.qml similarity index 72% rename from nymea-app/ui/AboutPage.qml rename to nymea-app/ui/appsettings/AboutPage.qml index c35f13a7..3d4102f0 100644 --- a/nymea-app/ui/AboutPage.qml +++ b/nymea-app/ui/appsettings/AboutPage.qml @@ -3,7 +3,7 @@ import QtQuick.Controls 2.1 import QtQuick.Controls.Material 2.1 import QtQuick.Layouts 1.1 import Nymea 1.0 -import "components" +import "../components" Page { id: root @@ -27,10 +27,29 @@ Page { spacing: app.margins Image { + id: logo Layout.preferredHeight: app.iconSize * 2 Layout.preferredWidth: height fillMode: Image.PreserveAspectFit source: "qrc:/styles/%1/logo.svg".arg(styleController.currentStyle) + + MouseArea { + anchors.fill: parent + property int clickCounter: 0 + onClicked: { + clickCounter++; + if (clickCounter >= 10) { + settings.showHiddenOptions = !settings.showHiddenOptions + var dialog = Qt.createComponent(Qt.resolvedUrl("../components/MeaDialog.qml")); + var text = settings.showHiddenOptions + ? qsTr("Developer options are now enabled. If you have found this by accident, it is most likely not of any use for you. It will just enable some nerdy developer gibberish in the app. Tap the icon another 10 times to disable it again.") + : qsTr("Developer options are now disabled.") + var popup = dialog.createObject(app, {headerIcon: "../images/dialog-warning-symbolic.svg", title: qsTr("Howdy cowboy!"), text: text}) + popup.open(); + clickCounter = 0; + } + } + } } GridLayout { @@ -83,60 +102,25 @@ Page { ColumnLayout { Layout.fillWidth: true - ItemDelegate { + MeaListItemDelegate { Layout.fillWidth: true - - contentItem: RowLayout { - Label { - Layout.fillWidth: true - text: qsTr("Visit the nymea website") - } - Image { - source: "images/next.svg" - Layout.preferredHeight: parent.height - Layout.preferredWidth: height - } - } - + text: qsTr("Visit the nymea website") onClicked: { Qt.openUrlExternally("https://nymea.io") } } - ItemDelegate { + MeaListItemDelegate { Layout.fillWidth: true - - contentItem: RowLayout { - Label { - Layout.fillWidth: true - text: qsTr("Visit GitHub page") - } - Image { - source: "images/next.svg" - Layout.preferredHeight: parent.height - Layout.preferredWidth: height - } - } + text: qsTr("Visit GitHub page") onClicked: { Qt.openUrlExternally("https://github.com/guh/nymea-app") } } - ItemDelegate { + MeaListItemDelegate { Layout.fillWidth: true - - contentItem: RowLayout { - Label { - Layout.fillWidth: true - text: qsTr("View license text") - } - Image { - source: "images/next.svg" - Layout.preferredHeight: parent.height - Layout.preferredWidth: height - } - } - + text: qsTr("View license text") onClicked: { pageStack.push(licenseTextComponent) } @@ -155,7 +139,7 @@ Page { Layout.preferredHeight: app.iconSize * 2 Layout.preferredWidth: height fillMode: Image.PreserveAspectFit - source: "images/Built_with_Qt_RGB_logo_vertical.svg" + source: "qrc:/ui/images/Built_with_Qt_RGB_logo_vertical.svg" sourceSize.width: app.iconSize * 2 sourceSize.height: app.iconSize * 2 } @@ -166,21 +150,9 @@ Page { wrapMode: Text.WordWrap } } - ItemDelegate { + MeaListItemDelegate { Layout.fillWidth: true - - contentItem: RowLayout { - Label { - Layout.fillWidth: true - text: qsTr("Visit the Qt website") - } - Image { - source: "images/next.svg" - Layout.preferredHeight: parent.height - Layout.preferredWidth: height - } - } - + text: qsTr("Visit the Qt website") onClicked: { Qt.openUrlExternally("https://www.qt.io") } diff --git a/nymea-app/ui/AppSettingsPage.qml b/nymea-app/ui/appsettings/AppSettingsPage.qml similarity index 92% rename from nymea-app/ui/AppSettingsPage.qml rename to nymea-app/ui/appsettings/AppSettingsPage.qml index ed5c8517..47816393 100644 --- a/nymea-app/ui/AppSettingsPage.qml +++ b/nymea-app/ui/appsettings/AppSettingsPage.qml @@ -3,7 +3,7 @@ import QtQuick.Controls 2.1 import QtQuick.Controls.Material 2.1 import QtQuick.Layouts 1.1 import Nymea 1.0 -import "components" +import "../components" Page { id: root @@ -114,7 +114,7 @@ Page { Layout.fillWidth: true text: qsTr("Cloud login") iconName: "../images/cloud.svg" - onClicked: pageStack.push(Qt.resolvedUrl("connection/CloudLoginPage.qml")) + onClicked: pageStack.push(Qt.resolvedUrl("CloudLoginPage.qml")) } MeaListItemDelegate { Layout.fillWidth: true @@ -122,6 +122,13 @@ Page { iconName: "../images/info.svg" onClicked: pageStack.push(Qt.resolvedUrl("AboutPage.qml")) } + MeaListItemDelegate { + Layout.fillWidth: true + visible: settings.showHiddenOption + text: qsTr("Developer options") + iconName: "../images/configure.svg" + onClicked: pageStack.push(Qt.resolvedUrl("DeveloperOptionsPage.qml")) + } } diff --git a/nymea-app/ui/appsettings/CloudLoginPage.qml b/nymea-app/ui/appsettings/CloudLoginPage.qml new file mode 100644 index 00000000..b7360e14 --- /dev/null +++ b/nymea-app/ui/appsettings/CloudLoginPage.qml @@ -0,0 +1,505 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import Nymea 1.0 +import "../components" + +Page { + id: root + header: GuhHeader { + text: qsTr("Cloud login") + onBackPressed: pageStack.pop() + } + + Component.onCompleted: { + if (Engine.awsClient.isLoggedIn) { + Engine.awsClient.fetchDevices(); + } + } + + Connections { + target: Engine.awsClient + onLoginResult: { + busyOverlay.shown = false; + if (error === AWSClient.LoginErrorNoError) { + Engine.awsClient.fetchDevices(); + } + } + } + + ColumnLayout { + anchors { left: parent.left; top: parent.top; right: parent.right } + visible: Engine.awsClient.isLoggedIn + Label { + Layout.fillWidth: true + Layout.topMargin: app.margins + Layout.leftMargin: app.margins + Layout.rightMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Logged in as %1").arg(Engine.awsClient.username) + } + + Button { + Layout.fillWidth: true + Layout.margins: app.margins + text: qsTr("Log out") + onClicked: { + logoutDialog.open() + } + } + + ThinDivider {} + + Label { + Layout.fillWidth: true + Layout.topMargin: app.margins + Layout.leftMargin: app.margins + Layout.rightMargin: app.margins + wrapMode: Text.WordWrap + text: Engine.awsClient.awsDevices.count === 0 ? + qsTr("There are no boxes connected to your cloud yet.") : + qsTr("There are %n boxes connected to your cloud", "", Engine.awsClient.awsDevices.count) + } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + model: Engine.awsClient.awsDevices + delegate: MeaListItemDelegate { + width: parent.width + text: model.name + subText: model.id + progressive: false + prominentSubText: false + canDelete: true + iconName: "../images/cloud.svg" + secondaryIconName: model.online ? "../images/cloud.svg" : "../images/cloud-offline.svg" + onDeleteClicked: { + Engine.awsClient.unpairDevice(model.id); + } + } + } + } + + MeaDialog { + id: logoutDialog + title: qsTr("Goodbye") + // Deleting user profile not working in cloud yet +// text: qsTr("Sorry to see you go. If you log out you won't be able to connect to %1 boxes remotely any more. However, you can come back any time, we'll keep your user account. If you whish to completely delete your account and all the data associated with it, check the box below before hitting ok.").arg(app.systemName) + text: qsTr("Sorry to see you go. If you log out you won't be able to connect to %1 boxes remotely any more. However, you can come back any time.").arg(app.systemName) + headerIcon: "../images/dialog-warning-symbolic.svg" + standardButtons: Dialog.Cancel | Dialog.Ok + +// RowLayout { +// CheckBox { +// id: deleteCheckbox +// } +// Label { +// Layout.fillWidth: true +// wrapMode: Text.WordWrap +// text: qsTr("Delete my account") +// } +// } + + onAccepted: { +// if (deleteCheckbox.checked) { +// Engine.awsClient.deleteAccount() +// } else { + Engine.awsClient.logout() +// } + } + } + + ColumnLayout { + anchors { left: parent.left; right: parent.right; top: parent.top } + visible: !Engine.awsClient.isLoggedIn + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Log in to %1:cloud in order to connect to %1 boxes from anywhere.").arg(app.systemName) + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: "Username (e-mail)" + } + TextField { + id: usernameTextField + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + placeholderText: "john.smith@cooldomain.com" + inputMethodHints: Qt.ImhEmailCharactersOnly + validator: RegExpValidator { regExp:/\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/ } + } + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: qsTr("Password") + } + TextField { + id: passwordTextField + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + Layout.fillWidth: true + echoMode: TextInput.Password + } + + Button { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: qsTr("OK") + enabled: usernameTextField.acceptableInput + onClicked: { + busyOverlay.shown = true + Engine.awsClient.login(usernameTextField.text, passwordTextField.text); + } + } + + Connections { + target: Engine.awsClient + onLoginResult: { + errorLabel.visible = (error !== AWSClient.LoginErrorNoError) + } + } + + Label { + id: errorLabel + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.bottomMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Failed to log in. Please try again. Do you perhaps have forgotten your password?") + font.pixelSize: app.smallFont + color: "red" + visible: false + onLinkActivated: { + pageStack.push(resetPasswordComponent, {email: usernameTextField.text}) + } + } + + ThinDivider {} + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Don't have a user yet?") + } + + Button { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: qsTr("Sign Up") + onClicked: { + pageStack.push(signupPageComponent) + } + } + } + + BusyOverlay { + id: busyOverlay + } + + Component { + id: signupPageComponent + Page { + id: signupPage + header: GuhHeader { + text: qsTr("Sign up") + onBackPressed: pageStack.pop() + } + + ColumnLayout { + anchors { left: parent.left; top: parent.top; right: parent.right } + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Welcome to %1:cloud.").arg(app.systemName) + color: app.accentColor + font.pixelSize: app.largeFont + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Please enter your email address and pick a password in order to create a new account.") + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: "Username (e-mail)" + } + TextField { + id: usernameTextField + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + placeholderText: "john.smith@cooldomain.com" + inputMethodHints: Qt.ImhEmailCharactersOnly + validator: RegExpValidator { regExp:/\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/ } + } + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: qsTr("Password") + } + AWSPasswordTextField { + id: passwordTextField + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + Layout.fillWidth: true + } + + Button { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: qsTr("Sign Up") + enabled: usernameTextField.acceptableInput && passwordTextField.isValidPassword + onClicked: { + Engine.awsClient.signup(usernameTextField.text, passwordTextField.password) + } + } + + Connections { + target: Engine.awsClient + onSignupResult: { + switch (error) { + case AWSClient.LoginErrorNoError: + signUpResultLabel.text = "" + pageStack.push(enterCodeComponent) + break; + case AWSClient.LoginErrorInvalidUserOrPass: + signUpResultLabel.text = qsTr("The given username or password are not valid.") + break; + default: + signUpResultLabel.text = qsTr("Uh oh, something went wrong. Please try again.") + } + } + } + + Label { + id: signUpResultLabel + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + } + } + } + } + + Component { + id: enterCodeComponent + Page { + header: GuhHeader { + text: qsTr("Confirm account") + onBackPressed: pageStack.pop() + } + + ColumnLayout { + anchors { left: parent.left; top: parent.top; right: parent.right } + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Thanks for signing up. We will send you an email with a confirmation code. Please enter that code in the field below.") + } + + TextField { + id: confirmationCodeTextField + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + inputMethodHints: Qt.ImhDigitsOnly + } + + Button { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + text: qsTr("OK") + onClicked: { + Engine.awsClient.confirmRegistration(confirmationCodeTextField.text) + } + } + + Connections { + target: Engine.awsClient + onConfirmationResult: { + switch (error) { + case AWSClient.LoginErrorNoError: + confirmResultLabel.text = "" + pageStack.pop(root) + break; + case AWSClient.LoginErrorUserExists: + confirmResultLabel.text = qsTr("The given user already exists. Did you forget the password?") + break; + case AWSClient.LoginErrorInvalidCode: + confirmResultLabel.text = qsTr("That wasn't the right code. Please try again.") + break; + case AWSClient.LoginErrorUnknownError: + confirmResultLabel.text = qsTr("Uh oh, something went wrong. Please try again.") + break; + } + } + } + + Label { + id: confirmResultLabel + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + } + } + } + } + + Component { + id: resetPasswordComponent + Page { + id: resetPasswordPage + + property alias email: emailTextField.text + + header: GuhHeader { + text: qsTr("Reset password") + onBackPressed: pageStack.pop() + } + + Connections { + target: Engine.awsClient + onForgotPasswordResult: { + busyOverlay.shown = false + if (error !== AWSClient.LoginErrorNoError) { + var errorDialog = Qt.createComponent(Qt.resolvedUrl("../components/ErrorDialog.qml")); + var text = qsTr("Sorry, this wasn't right. Did you misspell the email address?"); + if (error === AWSClient.LoginErrorLimitExceeded) { + text = qsTr("Sorry, there were too many attempts. Please try again after some time.") + } + var popup = errorDialog.createObject(app, {text: text}) + popup.open() + return; + } + pageStack.push(confirmResetPasswordComponent, {email: emailTextField.text }) + } + } + + ColumnLayout { + anchors { left: parent.left; top: parent.top; right: parent.right } + spacing: app.margins + + Label { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Password forgotten?") + font.pixelSize: app.largeFont + color: app.accentColor + } + Label { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("No problem. Enter your email address here and we'll send you a confirmation code to change your password.") + } + TextField { + id: emailTextField + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + } + Button { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + text: qsTr("Reset password") + onClicked: { + Engine.awsClient.forgotPassword(emailTextField.text) + busyOverlay.shown = true + } + } + } + + BusyOverlay { + id: busyOverlay + } + } + } + + Component { + id: confirmResetPasswordComponent + + Page { + id: confirmResetPasswordPage + + Connections { + target: Engine.awsClient + onConfirmForgotPasswordResult: { + busyOverlay.shown = false + if (error !== AWSClient.LoginErrorNoError) { + var errorDialog = Qt.createComponent(Qt.resolvedUrl("../components/ErrorDialog.qml")); + var popup = errorDialog.createObject(app, {text: qsTr("Sorry, couldn't reset your password. Did you enter the wrong confirmation code?")}) + popup.open() + return; + } + var dialog = Qt.createComponent(Qt.resolvedUrl("../components/MeaDialog.qml")); + var popup = dialog.createObject(app, {headerIcon: "../images/tick.svg", title: qsTr("Yay!"), text: qsTr("Your password has been reset.")}) + popup.accepted.connect(function() { + pageStack.pop(root); + }) + popup.open() + return; + } + } + + property string email + header: GuhHeader { + text: qsTr("Reset password") + onBackPressed: pageStack.pop() + } + ColumnLayout { + anchors { left: parent.left; top: parent.top; right: parent.right } + spacing: app.margins + + Label { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins + wrapMode: Text.WordWrap + text: qsTr("Check your email!") + color: app.accentColor + font.pixelSize: app.largeFont + } + + Label { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; + wrapMode: Text.WordWrap + text: qsTr("Enter the confirmation code you've received and a new password for your user %1.").arg(confirmResetPasswordPage.email) + } + + Label { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; + text: qsTr("Confirmation code:") + } + + TextField { + id: codeTextField + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + } + Label { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; + text: qsTr("Pick a new password:") + } + + AWSPasswordTextField { + id: passwordTextField + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + } + + Button { + Layout.fillWidth: true; Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + text: qsTr("Reset password") + enabled: passwordTextField.isValidPassword && codeTextField.text.length > 0 + onClicked: { + busyOverlay.shown = true + Engine.awsClient.confirmForgotPassword(confirmResetPasswordPage.email, codeTextField.text, passwordTextField.password) + } + } + BusyOverlay { + id: busyOverlay + } + } + } + } +} diff --git a/nymea-app/ui/appsettings/DeveloperOptionsPage.qml b/nymea-app/ui/appsettings/DeveloperOptionsPage.qml new file mode 100644 index 00000000..d2b5dcb9 --- /dev/null +++ b/nymea-app/ui/appsettings/DeveloperOptionsPage.qml @@ -0,0 +1,38 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import Nymea 1.0 +import "../components" + +Page { + id: root + header: GuhHeader { + text: qsTr("Developer options") + backButtonVisible: true + onBackPressed: pageStack.pop() + } + + ColumnLayout { + anchors { left: parent.left; top: parent.top; right: parent.right } + + RowLayout { + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + + Label { + Layout.fillWidth: true + text: qsTr("Cloud environment") + } + + ComboBox { + currentIndex: app.settings.cloudEnvironment + model: [qsTr("Community"), qsTr("Testing")] + onActivated: { + app.settings.cloudEnvironment = index; + } + } + } + } + + + +} diff --git a/nymea-app/ui/components/AWSPasswordTextField.qml b/nymea-app/ui/components/AWSPasswordTextField.qml new file mode 100644 index 00000000..d900efd4 --- /dev/null +++ b/nymea-app/ui/components/AWSPasswordTextField.qml @@ -0,0 +1,55 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.2 + +ColumnLayout { + id: root + + readonly property alias password: passwordTextField.text + + readonly property bool isValidPassword: isLongEnough && hasLower && hasUpper && hasNumbers && hasSpecialChar && confirmationMatches + + readonly property bool isLongEnough: passwordTextField.text.length >= 12 + readonly property bool hasLower: passwordTextField.text.search(/[a-z]/) >= 0 + readonly property bool hasUpper: passwordTextField.text.search(/[A-Z/]/) >= 0 + readonly property bool hasNumbers: passwordTextField.text.search(/[0-9]/) >= 0 + readonly property bool hasSpecialChar: passwordTextField.text.search(/[\*]/) >= 0 + readonly property bool confirmationMatches: passwordTextField.text === confirmationPasswordTextField.text + + TextField { + id: passwordTextField + Layout.fillWidth: true + echoMode: TextInput.Password + placeholderText: qsTr("Pick a password") + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + + // TRANSLATORS: %1 will be replaced with the normal text color, %2 the color for the length check + text: qsTr("The password needs to be least 12 characters long, contain lowercase, uppercase letters as well as numbers and special characters.") + .arg(app.accentColor) + .arg(!root.isLongEnough ? "red" : app.accentColor) + .arg(!root.hasLower ? "red" : app.accentColor) + .arg(!root.hasUpper ? "red" : app.accentColor) + .arg(!root.hasNumbers ? "red" : app.accentColor) + .arg(!root.hasSpecialChar ? "red" : app.accentColor) + font.pixelSize: app.smallFont + } + + TextField { + id: confirmationPasswordTextField + Layout.fillWidth: true + echoMode: TextInput.Password + placeholderText: qsTr("Confirm password") + } + + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + + text: root.confirmationMatches ? qsTr("The passwords match.").arg(app.accentColor) : qsTr("The passwords do not match.").arg(app.accentColor).arg("red") + font.pixelSize: app.smallFont + } +} diff --git a/nymea-app/ui/components/BusyOverlay.qml b/nymea-app/ui/components/BusyOverlay.qml new file mode 100644 index 00000000..b0f5a736 --- /dev/null +++ b/nymea-app/ui/components/BusyOverlay.qml @@ -0,0 +1,14 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.1 + +Rectangle { + anchors.fill: parent + color: "#55000000" + visible: shown + + property bool shown: false + + BusyIndicator { + anchors.centerIn: parent + } +} diff --git a/nymea-app/ui/connection/CloudLoginPage.qml b/nymea-app/ui/connection/CloudLoginPage.qml deleted file mode 100644 index dc2a2819..00000000 --- a/nymea-app/ui/connection/CloudLoginPage.qml +++ /dev/null @@ -1,96 +0,0 @@ -import QtQuick 2.9 -import QtQuick.Controls 2.2 -import QtQuick.Layouts 1.3 -import Nymea 1.0 -import "../components" - -Page { - id: root - header: GuhHeader { - text: qsTr("Cloud login") - onBackPressed: pageStack.pop() - } - - ColumnLayout { - anchors { left: parent.left; top: parent.top; right: parent.right } - visible: Engine.awsClient.isLoggedIn - Label { - Layout.fillWidth: true - Layout.topMargin: app.margins - Layout.leftMargin: app.margins - Layout.rightMargin: app.margins - wrapMode: Text.WordWrap - text: qsTr("Logged in as %1").arg(Engine.awsClient.username) - } - - Button { - Layout.fillWidth: true - Layout.margins: app.margins - text: qsTr("Log out") - onClicked: Engine.awsClient.logout(); - } - - ThinDivider {} - - Label { - Layout.fillWidth: true - Layout.topMargin: app.margins - Layout.leftMargin: app.margins - Layout.rightMargin: app.margins - wrapMode: Text.WordWrap - text: Engine.awsClient.awsDevices.count === 0 ? - qsTr("There are no boxes connected to your cloud yet.") : - qsTr("There (are|is) %1 boxe(s) connected to your cloud", "", Engine.awsClient.awsDevices.count) - } - Repeater { - model: Engine.awsClient.awsDevices - delegate: MeaListItemDelegate { - Layout.fillWidth: true - text: model.name - subText: model.uuid - progressive: false - prominentSubText: false - iconName: "../images/cloud.svg" - secondaryIconName: model.online ? "../images/cloud.svg" : "../images/cloud-offline.svg" - } - } - } - - ColumnLayout { - anchors { left: parent.left; right: parent.right; top: parent.top } - visible: !Engine.awsClient.isLoggedIn - Label { - Layout.fillWidth: true - Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins - text: "Username (e-mail)" - } - TextField { - id: usernameTextField - Layout.fillWidth: true - Layout.leftMargin: app.margins; Layout.rightMargin: app.margins - placeholderText: "john@dummy.com" - inputMethodHints: Qt.ImhEmailCharactersOnly - } - Label { - Layout.fillWidth: true - Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins - text: qsTr("Password") - } - TextField { - id: passwordTextField - Layout.leftMargin: app.margins; Layout.rightMargin: app.margins - Layout.fillWidth: true - echoMode: TextInput.Password - } - - Button { - Layout.fillWidth: true - Layout.leftMargin: app.margins; Layout.rightMargin: app.margins; Layout.topMargin: app.margins - text: qsTr("OK") - enabled: usernameTextField.displayText.length > 0 && passwordTextField.displayText.length > 0 - onClicked: { - Engine.awsClient.login(usernameTextField.text, passwordTextField.text); - } - } - } -} diff --git a/nymea-app/ui/connection/ConnectPage.qml b/nymea-app/ui/connection/ConnectPage.qml index 9d012ba4..f4e50791 100644 --- a/nymea-app/ui/connection/ConnectPage.qml +++ b/nymea-app/ui/connection/ConnectPage.qml @@ -24,6 +24,14 @@ Page { } } + function connectToHost(url) { + Engine.connection.connect(url) + var page = pageStack.push(Qt.resolvedUrl("ConnectingPage.qml")) + page.cancel.connect(function() { + Engine.connection.disconnect() + }) + } + NymeaDiscovery { id: discovery objectName: "discovery" @@ -87,15 +95,11 @@ Page { ListElement { iconSource: "../images/network-vpn.svg"; text: qsTr("Manual connection"); page: "ManualConnectPage.qml" } ListElement { iconSource: "../images/bluetooth.svg"; text: qsTr("Wireless setup"); page: "BluetoothDiscoveryPage.qml"; } ListElement { iconSource: "../images/private-browsing.svg"; text: qsTr("Demo mode"); page: "" } - ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "../AppSettingsPage.qml" } + ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "../appsettings/AppSettingsPage.qml" } } onClicked: { if (index === 2) { - Engine.connection.connect("nymea://nymea.nymea.io:2222") - var page = pageStack.push(Qt.resolvedUrl("ConnectingPage.qml")) - page.cancel.connect(function() { - Engine.connection.disconnect() - }) + root.connectToHost("nymea://nymea.nymea.io:2222") } else { pageStack.push(model.get(index).page); } @@ -162,11 +166,12 @@ Page { var bearerPreference = [Connection.BearerTypeEthernet, Connection.BearerTypeWifi, Connection.BearerTypeBluetooth, Connection.BearerTypeCloud] var oldBearerPriority = bearerPreference.indexOf(oldConfig.bearerType); var newBearerPriority = bearerPreference.indexOf(newConfig.bearerType); - if (newBearerPriority > oldBearerPriority) { + if (newBearerPriority < oldBearerPriority) { + print(discoveryDevice.name, "switching to preferred index", i, "of bearer type", newConfig.bearerType, "from", oldConfig.bearerType, "new prio:", newBearerPriority, "old:", oldBearerPriority) usedConfigIndex = i; continue; } - if (oldBearerPriority > newBearerPriority) { + if (oldBearerPriority < newBearerPriority) { continue; // discard new one the one we have is on a better bearer type } @@ -208,16 +213,15 @@ Page { progressive: false property bool isSecure: discoveryDevice.connections.get(defaultConnectionIndex).secure property bool isTrusted: Engine.connection.isTrusted(discoveryDeviceDelegate.discoveryDevice.connections.get(defaultConnectionIndex).url) - secondaryIconName: isSecure ? "../images/network-secure.svg" : "" - secondaryIconColor: isTrusted ? app.accentColor : Material.foreground + property bool isOnline: discoveryDevice.connections.get(defaultConnectionIndex).online + tertiaryIconName: isSecure ? "../images/network-secure.svg" : "" + tertiaryIconColor: isTrusted ? app.accentColor : Material.foreground + secondaryIconName: !isOnline ? "../images/cloud-error.svg" : "" + secondaryIconColor: "red" swipe.enabled: discoveryDeviceDelegate.discoveryDevice.deviceType === DiscoveryDevice.DeviceTypeNetwork onClicked: { - Engine.connection.connect(discoveryDeviceDelegate.discoveryDevice.connections.get(defaultConnectionIndex).url) - var page = pageStack.push(Qt.resolvedUrl("ConnectingPage.qml")) - page.cancel.connect(function() { - Engine.connection.disconnect() - }) + root.connectToHost(discoveryDeviceDelegate.discoveryDevice.connections.get(defaultConnectionIndex).url) } swipe.right: MouseArea { @@ -279,7 +283,7 @@ Page { Layout.rightMargin: app.margins text: qsTr("Cloud login") visible: !Engine.awsClient.isLoggedIn - onClicked: pageStack.push(Qt.resolvedUrl("CloudLoginPage.qml")) + onClicked: pageStack.push(Qt.resolvedUrl("../appsettings/CloudLoginPage.qml")) } Button { @@ -287,14 +291,10 @@ Page { Layout.leftMargin: app.margins Layout.rightMargin: app.margins Layout.bottomMargin: app.margins -// visible: discovery.discoveryModel.count === 0 + visible: discovery.discoveryModel.count === 0 text: qsTr("Demo mode (online)") onClicked: { - Engine.connection.connect("nymea://nymea.nymea.io:2222") - var page = pageStack.push(Qt.resolvedUrl("ConnectingPage.qml")) - page.cancel.connect(function() { - Engine.connection.disconnect() - }) + root.connectToHost("nymea://nymea.nymea.io:2222") } } @@ -420,7 +420,7 @@ Page { onAccepted: { Engine.connection.acceptCertificate(certDialog.url, certDialog.fingerprint) - Engine.connection.connect(certDialog.url) + root.connectToHost(certDialog.url) } } } @@ -532,12 +532,14 @@ Page { return "" } - secondaryIconName: model.secure ? "../images/network-secure.svg" : "" - secondaryIconColor: isTrusted ? app.accentColor : "gray" + tertiaryIconName: model.secure ? "../images/network-secure.svg" : "" + tertiaryIconColor: isTrusted ? app.accentColor : "gray" readonly property bool isTrusted: Engine.connection.isTrusted(url) + secondaryIconName: !model.online ? "../images/cloud-error.svg" : "" + secondaryIconColor: "red" onClicked: { - Engine.connection.connect(dialog.discoveryDevice.connections.get(index).url) + root.connectToHost(dialog.discoveryDevice.connections.get(index).url) dialog.close() } } diff --git a/nymea-app/ui/connection/ConnectingPage.qml b/nymea-app/ui/connection/ConnectingPage.qml index 4cc0bd8d..f1afa1ca 100644 --- a/nymea-app/ui/connection/ConnectingPage.qml +++ b/nymea-app/ui/connection/ConnectingPage.qml @@ -26,6 +26,13 @@ Page { wrapMode: Text.WordWrap horizontalAlignment: Text.AlignHCenter } + Label { + Layout.fillWidth: true + text: Engine.connection.url + font.pixelSize: app.smallFont + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + horizontalAlignment: Text.AlignHCenter + } } Button { diff --git a/nymea-app/ui/images/cloud.svg b/nymea-app/ui/images/cloud.svg index b8342eeb..c30161d8 100644 --- a/nymea-app/ui/images/cloud.svg +++ b/nymea-app/ui/images/cloud.svg @@ -9,15 +9,15 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="96" - height="96" - id="svg4874" + width="90" + height="90" + id="svg6138" version="1.1" inkscape:version="0.91+devel r" - viewBox="0 0 96 96.000001" - sodipodi:docname="weather-clouds-symbolic.svg"> + viewBox="0 0 90 90.000001" + sodipodi:docname="sync-idle.svg"> + id="defs6140" /> + inkscape:guide-bbox="true"> - - + id="grid6700" + empspacing="6" /> + position="62,87" + id="guide4084" /> + position="63,84" + id="guide4086" /> + position="63,81" + id="guide4088" /> + + + + + + - + position="60,3" + id="guide4102" /> + position="61,6" + id="guide4104" /> - - - - + position="62,9" + id="guide4106" /> + id="metadata6143"> @@ -124,37 +120,25 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" - transform="translate(67.857146,-78.50504)"> + transform="translate(-283.57144,-358.79068)"> - - - - - - + id="g6253" + inkscape:export-filename="planemode01.png" + inkscape:export-xdpi="90" + inkscape:export-ydpi="90" + transform="matrix(-1,0,0,1,547.57143,-1341.5715)"> + + diff --git a/nymea-app/ui/system/CloudSettingsPage.qml b/nymea-app/ui/system/CloudSettingsPage.qml index c0d481d0..f9f34930 100644 --- a/nymea-app/ui/system/CloudSettingsPage.qml +++ b/nymea-app/ui/system/CloudSettingsPage.qml @@ -11,21 +11,16 @@ Page { onBackPressed: pageStack.pop(); } - Connections { - target: Engine.basicConfiguration - onCloudEnabledChanged: { - if (Engine.jsonRpcClient.cloudConnectionState === JsonRpcClient.CloudConnectionStateUnconfigured) { - Engine.deployCertificate(); - } - } - } + Item { + id: d + property bool deploymentStarted: false - Connections { - target: Engine.jsonRpcClient - onCloudConnectionStateChanged: { - if (Engine.awsClient.isLoggedIn && Engine.awsClient.awsDevices.getDevice(Engine.jsonRpcClient.serverUuid) === null) { - print("Pairing user and box...") - Engine.jsonRpcClient.setupRemoteAccess(Engine.awsClient.idToken, Engine.awsClient.userId); + Connections { + target: Engine.jsonRpcClient + onCloudConnectionStateChanged: { + if (Engine.jsonRpcClient.cloudConnectionState == JsonRpcClient.CloudConnectionStateConnected) { + d.deploymentStarted = false; + } } } } @@ -51,33 +46,67 @@ Page { } } + ThinDivider {} + RowLayout { Layout.fillWidth: true Layout.leftMargin: app.margins Layout.rightMargin: app.margins - visible: Engine.basicConfiguration.cloudEnabled - BusyIndicator { - visible: Engine.jsonRpcClient.cloudConnectionState == JsonRpcClient.CloudConnectionStateUnconfigured || - Engine.jsonRpcClient.cloudConnectionState == JsonRpcClient.CloudConnectionStateConnecting + ColorIcon { + Layout.preferredHeight: busyIndicator.height + Layout.preferredWidth: height + name: Engine.jsonRpcClient.cloudConnectionState === JsonRpcClient.CloudConnectionStateConnected + ? "../images/cloud.svg" + : Engine.jsonRpcClient.cloudConnectionState === JsonRpcClient.CloudConnectionStateUnconfigured + ? "../images/cloud-error.svg" + : "../images/cloud-offline.svg" } + Label { Layout.fillWidth: true wrapMode: Text.WordWrap text: { switch (Engine.jsonRpcClient.cloudConnectionState) { case JsonRpcClient.CloudConnectionStateDisabled: - return "" + return qsTr("This box is not connected to %1:cloud").arg(app.systemName) case JsonRpcClient.CloudConnectionStateUnconfigured: - return qsTr("Configuring the box to connect to nymea:cloud..."); + if (d.deploymentStarted) { + return qsTr("Registering box in %1:cloud...").arg(app.systemName) + } + return qsTr("This box is not configured to connect to %1:cloud.").arg(app.systemName); case JsonRpcClient.CloudConnectionStateConnecting: - return qsTr("Connecting the box to nymea:cloud..."); + return qsTr("Connecting the box to %1:cloud...").arg(app.systemName); case JsonRpcClient.CloudConnectionStateConnected: - return qsTr("The box is connected to nymea:cloud."); + return qsTr("The box is connected to %1:cloud.").arg(app.systemName); } return Engine.jsonRpcClient.cloudConnectionState } } + BusyIndicator { + id: busyIndicator + visible: (Engine.jsonRpcClient.cloudConnectionState == JsonRpcClient.CloudConnectionStateUnconfigured && d.deploymentStarted) || + Engine.jsonRpcClient.cloudConnectionState == JsonRpcClient.CloudConnectionStateConnecting + } + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + visible: Engine.jsonRpcClient.cloudConnectionState === JsonRpcClient.CloudConnectionStateUnconfigured && !d.deploymentStarted + text: qsTr("This box is not configured to access the %1:cloud. In order for a box to connect to %1:cloud it needs to be registered first.").arg(app.systemName) + wrapMode: Text.WordWrap + } + + Button { + Layout.fillWidth: true + Layout.leftMargin: app.margins; Layout.rightMargin: app.margins + visible: Engine.jsonRpcClient.cloudConnectionState === JsonRpcClient.CloudConnectionStateUnconfigured && !d.deploymentStarted + text: qsTr("Register box") + onClicked: { + d.deploymentStarted = true + Engine.deployCertificate(); + } } diff --git a/nymea-remoteproxy b/nymea-remoteproxy index f6e2d9b3..3b97bc60 160000 --- a/nymea-remoteproxy +++ b/nymea-remoteproxy @@ -1 +1 @@ -Subproject commit f6e2d9b3b208362159dd22a4eb11527db25d8760 +Subproject commit 3b97bc60bbaf1ae5d4ced7eb1586d2d20fe5b0ab