Error handling for Yggdrasil accounts

This commit is contained in:
Evan Goode 2024-10-26 13:40:09 -04:00
parent c32aaf244d
commit d40b6d8eb3
10 changed files with 167 additions and 53 deletions

View File

@ -45,8 +45,8 @@ set(CORE_SOURCES
ResourceDownloadTask.h ResourceDownloadTask.h
ResourceDownloadTask.cpp ResourceDownloadTask.cpp
CreateAuthlibInjectorAccount.cpp GetAuthlibInjectorApiLocation.cpp
CreateAuthlibInjectorAccount.h GetAuthlibInjectorApiLocation.h
# Use tracking separate from memory management # Use tracking separate from memory management
Usable.h Usable.h

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#include "CreateAuthlibInjectorAccount.h" #include "GetAuthlibInjectorApiLocation.h"
#include <QDebug> #include <QDebug>
#include <QJsonDocument> #include <QJsonDocument>
@ -28,40 +28,40 @@
#include "Application.h" #include "Application.h"
CreateAuthlibInjectorAccount::CreateAuthlibInjectorAccount(QUrl url, MinecraftAccountPtr account, QString username) GetAuthlibInjectorApiLocation::GetAuthlibInjectorApiLocation(QUrl url, MinecraftAccountPtr account, QString username)
: NetRequest(), m_account(account), m_username(username) : NetRequest(), m_account(account), m_username(username)
{ {
m_url = url; m_url = url;
m_sink.reset(new Sink(*this)); m_sink.reset(new Sink(*this));
} }
QNetworkReply* CreateAuthlibInjectorAccount::getReply(QNetworkRequest& request) QNetworkReply* GetAuthlibInjectorApiLocation::getReply(QNetworkRequest& request)
{ {
setStatus(tr("Getting authlib-injector server details")); setStatus(tr("Getting authlib-injector server details"));
return m_network->get(request); return m_network->get(request);
} }
CreateAuthlibInjectorAccount::Ptr CreateAuthlibInjectorAccount::make(QUrl url, MinecraftAccountPtr account, QString username) GetAuthlibInjectorApiLocation::Ptr GetAuthlibInjectorApiLocation::make(QUrl url, MinecraftAccountPtr account, QString username)
{ {
return CreateAuthlibInjectorAccount::Ptr(new CreateAuthlibInjectorAccount(url, account, username)); return GetAuthlibInjectorApiLocation::Ptr(new GetAuthlibInjectorApiLocation(url, account, username));
} }
auto CreateAuthlibInjectorAccount::Sink::init(QNetworkRequest& request) -> Task::State auto GetAuthlibInjectorApiLocation::Sink::init(QNetworkRequest& request) -> Task::State
{ {
return Task::State::Running; return Task::State::Running;
} }
auto CreateAuthlibInjectorAccount::Sink::write(QByteArray& data) -> Task::State auto GetAuthlibInjectorApiLocation::Sink::write(QByteArray& data) -> Task::State
{ {
return Task::State::Running; return Task::State::Running;
} }
auto CreateAuthlibInjectorAccount::Sink::abort() -> Task::State auto GetAuthlibInjectorApiLocation::Sink::abort() -> Task::State
{ {
return Task::State::Failed; return Task::State::Failed;
} }
auto CreateAuthlibInjectorAccount::Sink::finalize(QNetworkReply& reply) -> Task::State auto GetAuthlibInjectorApiLocation::Sink::finalize(QNetworkReply& reply) -> Task::State
{ {
QVariant header = reply.rawHeader("X-Authlib-Injector-API-Location"); QVariant header = reply.rawHeader("X-Authlib-Injector-API-Location");
QUrl url = m_outer.m_url; QUrl url = m_outer.m_url;
@ -76,7 +76,7 @@ auto CreateAuthlibInjectorAccount::Sink::finalize(QNetworkReply& reply) -> Task:
return Task::State::Succeeded; return Task::State::Succeeded;
} }
MinecraftAccountPtr CreateAuthlibInjectorAccount::getAccount() MinecraftAccountPtr GetAuthlibInjectorApiLocation::getAccount()
{ {
return m_account; return m_account;
} }

View File

@ -21,20 +21,20 @@
#include "minecraft/auth/MinecraftAccount.h" #include "minecraft/auth/MinecraftAccount.h"
#include "net/NetRequest.h" #include "net/NetRequest.h"
class CreateAuthlibInjectorAccount : public Net::NetRequest { class GetAuthlibInjectorApiLocation : public Net::NetRequest {
Q_OBJECT Q_OBJECT
public: public:
using Ptr = shared_qobject_ptr<CreateAuthlibInjectorAccount>; using Ptr = shared_qobject_ptr<GetAuthlibInjectorApiLocation>;
CreateAuthlibInjectorAccount(QUrl url, MinecraftAccountPtr account, QString username); GetAuthlibInjectorApiLocation(QUrl url, MinecraftAccountPtr account, QString username);
virtual ~CreateAuthlibInjectorAccount() = default; virtual ~GetAuthlibInjectorApiLocation() = default;
static CreateAuthlibInjectorAccount::Ptr make(QUrl url, MinecraftAccountPtr account, QString username); static GetAuthlibInjectorApiLocation::Ptr make(QUrl url, MinecraftAccountPtr account, QString username);
MinecraftAccountPtr getAccount(); MinecraftAccountPtr getAccount();
class Sink : public Net::Sink { class Sink : public Net::Sink {
public: public:
Sink(CreateAuthlibInjectorAccount& outer) : m_outer(outer) {} Sink(GetAuthlibInjectorApiLocation& outer) : m_outer(outer) {}
virtual ~Sink() = default; virtual ~Sink() = default;
public: public:
@ -45,7 +45,7 @@ class CreateAuthlibInjectorAccount : public Net::NetRequest {
auto hasLocalData() -> bool override { return false; } auto hasLocalData() -> bool override { return false; }
private: private:
CreateAuthlibInjectorAccount& m_outer; GetAuthlibInjectorApiLocation& m_outer;
}; };
protected slots: protected slots:

View File

@ -131,7 +131,7 @@ bool AuthFlow::changeState(AccountTaskState newState, QString reason)
return false; return false;
} }
case AccountTaskState::STATE_FAILED_HARD: { case AccountTaskState::STATE_FAILED_HARD: {
setStatus(tr("Failed to authenticate. The session has expired.")); setStatus(tr("Failed to authenticate."));
m_data->errorString = reason; m_data->errorString = reason;
m_data->accountState = AccountState::Expired; m_data->accountState = AccountState::Expired;
emitFailed(reason); emitFailed(reason);

View File

@ -7,7 +7,7 @@ YggdrasilStep::YggdrasilStep(AccountData* data, std::optional<QString> password)
QString YggdrasilStep::describe() QString YggdrasilStep::describe()
{ {
return tr("Logging in with Mojang account."); return tr("Logging in with Yggdrasil.");
} }
void YggdrasilStep::perform() void YggdrasilStep::perform()
@ -64,6 +64,7 @@ void YggdrasilStep::login(QString password)
m_task.reset(new NetJob("YggdrasilStep", APPLICATION->network())); m_task.reset(new NetJob("YggdrasilStep", APPLICATION->network()));
m_task->setAskRetry(false); m_task->setAskRetry(false);
m_task->setAutoRetryLimit(0);
m_task->addNetAction(m_request); m_task->addNetAction(m_request);
connect(m_task.get(), &Task::finished, this, &YggdrasilStep::onRequestDone); connect(m_task.get(), &Task::finished, this, &YggdrasilStep::onRequestDone);
@ -102,6 +103,7 @@ void YggdrasilStep::refresh()
m_task.reset(new NetJob("YggdrasilStep", APPLICATION->network())); m_task.reset(new NetJob("YggdrasilStep", APPLICATION->network()));
m_task->setAskRetry(false); m_task->setAskRetry(false);
m_task->setAutoRetryLimit(0);
m_task->addNetAction(m_request); m_task->addNetAction(m_request);
connect(m_task.get(), &Task::finished, this, &YggdrasilStep::onRequestDone); connect(m_task.get(), &Task::finished, this, &YggdrasilStep::onRequestDone);
@ -111,10 +113,90 @@ void YggdrasilStep::refresh()
void YggdrasilStep::onRequestDone() void YggdrasilStep::onRequestDone()
{ {
// TODO handle errors qDebug() << "Yggdrasil request done";
switch (m_request->error()) {
case QNetworkReply::NoError:
break;
case QNetworkReply::AuthenticationRequiredError:
// These cases will be handled as usual
break;
case QNetworkReply::TimeoutError:
emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Authentication operation timed out."));
return;
case QNetworkReply::OperationCanceledError:
emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Authentication operation cancelled."));
return;
case QNetworkReply::SslHandshakeFailedError:
emit finished(AccountTaskState::STATE_FAILED_SOFT,
tr("<b>SSL Handshake failed.</b><br/>There might be a few causes for it:<br/>"
"<ul>"
"<li>You use Windows and need to update your root certificates, please install any outstanding updates.</li>"
"<li>Some device on your network is interfering with SSL traffic. In that case, "
"you have bigger worries than Minecraft not starting.</li>"
"<li>Possibly something else. Check the log file for details</li>"
"</ul>"));
return;
// used for invalid credentials and similar errors. Fall through.
case QNetworkReply::ContentAccessDenied:
case QNetworkReply::ContentOperationNotPermittedError:
break;
case QNetworkReply::ContentGoneError: {
emit finished(AccountTaskState::STATE_FAILED_GONE,
tr("The Mojang account no longer exists. It may have been migrated to a Microsoft account."));
return;
}
default:
emit finished(AccountTaskState::STATE_FAILED_SOFT, tr("Authentication operation failed due to a network error: %1 (%2)")
.arg(m_request->errorString())
.arg(m_request->error()));
return;
}
// Try to parse the response regardless of the response code.
// Sometimes the auth server will give more information and an error code.
// Check the response code.
QJsonParseError jsonError; QJsonParseError jsonError;
QJsonDocument doc = QJsonDocument::fromJson(*m_response, &jsonError); QJsonDocument doc = QJsonDocument::fromJson(*m_response, &jsonError);
// Check the response code.
int responseCode = m_request->replyStatusCode();
if (responseCode == 200) {
// If the response code was 200, then there shouldn't be an error. Make sure
// anyways.
// Also, sometimes an empty reply indicates success. If there was no data received,
// pass an empty json object to the processResponse function.
if (jsonError.error == QJsonParseError::NoError || m_response->size() == 0) {
processResponse(m_response->size() > 0 ? doc.object() : QJsonObject());
return;
} else {
emit finished(AccountTaskState::STATE_FAILED_SOFT,
tr("Failed to parse authentication server response JSON response: %1 at offset %2.")
.arg(jsonError.errorString())
.arg(jsonError.offset));
qCritical() << *m_response;
}
return;
}
// If the response code was not 200, then Yggdrasil may have given us information
// about the error.
// If we can parse the response, then get information from it. Otherwise just say
// there was an unknown error.
if (jsonError.error == QJsonParseError::NoError) {
// We were able to parse the server's response. Woo!
// Call processError. If a subclass has overridden it then they'll handle their
// stuff there.
qDebug() << "The request failed, but the server gave us an error message. Processing error.";
processError(doc.object());
} else {
// The server didn't say anything regarding the error. Give the user an unknown
// error.
qDebug() << "The request failed and the server gave no error message. Unknown error.";
emit finished(
AccountTaskState::STATE_FAILED_SOFT,
tr("An unknown error occurred when trying to communicate with the authentication server: %1").arg(m_request->errorString()));
}
YggdrasilStep::processResponse(doc.object()); YggdrasilStep::processResponse(doc.object());
} }
@ -122,9 +204,7 @@ void YggdrasilStep::processResponse(QJsonObject responseData)
{ {
// Read the response data. We need to get the client token, access token, and the selected // Read the response data. We need to get the client token, access token, and the selected
// profile. // profile.
qDebug() << "Processing authentication response.";
// qDebug() << responseData;
// If we already have a client token, make sure the one the server gave us matches our // If we already have a client token, make sure the one the server gave us matches our
// existing one. // existing one.
QString clientToken = responseData.value("clientToken").toString(""); QString clientToken = responseData.value("clientToken").toString("");
@ -192,15 +272,15 @@ void YggdrasilStep::processResponse(QJsonObject responseData)
void YggdrasilStep::processError(QJsonObject responseData) void YggdrasilStep::processError(QJsonObject responseData)
{ {
/*QJsonValue errorVal = responseData.value("error");*/ QJsonValue errorVal = responseData.value("error");
/*QJsonValue errorMessageValue = responseData.value("errorMessage");*/ QJsonValue errorMessageValue = responseData.value("errorMessage");
/*QJsonValue causeVal = responseData.value("cause");*/ QJsonValue causeVal = responseData.value("cause");
/**/
/*if (errorVal.isString() && errorMessageValue.isString()) {*/ if (errorVal.isString() && errorMessageValue.isString()) {
/*m_error = std::shared_ptr<Error>(new Error{ errorVal.toString(""), errorMessageValue.toString(""), causeVal.toString("") });*/ /*m_error = std::shared_ptr<Error>(new Error{ errorVal.toString(""), errorMessageValue.toString(""), causeVal.toString("") });*/
/* changeState(AccountTaskState::STATE_FAILED_HARD, m_error->m_errorMessageVerbose);*/ emit finished(AccountTaskState::STATE_FAILED_HARD, errorMessageValue.toString(""));
/*} else {*/ } else {
/* // Error is not in standard format. Don't set m_error and return unknown error.*/ // Error is not in standard format. Don't set m_error and return unknown error.
/* changeState(AccountTaskState::STATE_FAILED_HARD, tr("An unknown Yggdrasil error occurred."));*/ emit finished(AccountTaskState::STATE_FAILED_HARD, tr("An unknown Yggdrasil error occurred."));
/*}*/ }
} }

View File

@ -66,8 +66,8 @@ auto NetJob::addNetAction(Net::NetRequest::Ptr action) -> bool
void NetJob::executeNextSubTask() void NetJob::executeNextSubTask()
{ {
// We're finished, check for failures and retry if we can (up to 3 times) // We're finished, check for failures and retry if we can (up to m_auto_retry_limit times)
if (isRunning() && m_queue.isEmpty() && m_doing.isEmpty() && !m_failed.isEmpty() && m_try < 3) { if (isRunning() && m_queue.isEmpty() && m_doing.isEmpty() && !m_failed.isEmpty() && m_try < m_auto_retry_limit) {
m_try += 1; m_try += 1;
while (!m_failed.isEmpty()) { while (!m_failed.isEmpty()) {
auto task = m_failed.take(*m_failed.keyBegin()); auto task = m_failed.take(*m_failed.keyBegin());
@ -188,3 +188,8 @@ void NetJob::setAskRetry(bool askRetry)
{ {
m_ask_retry = askRetry; m_ask_retry = askRetry;
} }
void NetJob::setAutoRetryLimit(int autoRetryLimit)
{
m_auto_retry_limit = autoRetryLimit;
}

View File

@ -63,6 +63,7 @@ class NetJob : public ConcurrentTask {
auto getFailedActions() -> QList<Net::NetRequest*>; auto getFailedActions() -> QList<Net::NetRequest*>;
auto getFailedFiles() -> QList<QString>; auto getFailedFiles() -> QList<QString>;
void setAskRetry(bool askRetry); void setAskRetry(bool askRetry);
void setAutoRetryLimit(int autoRetryLimit);
public slots: public slots:
// Qt can't handle auto at the start for some reason? // Qt can't handle auto at the start for some reason?
@ -81,5 +82,6 @@ class NetJob : public ConcurrentTask {
int m_try = 1; int m_try = 1;
bool m_ask_retry = true; bool m_ask_retry = true;
int m_auto_retry_limit = 3;
int m_manual_try = 0; int m_manual_try = 0;
}; };

View File

@ -20,14 +20,17 @@
#include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/CustomMessageBox.h"
#include "ui_AuthlibInjectorLoginDialog.h" #include "ui_AuthlibInjectorLoginDialog.h"
#include "CreateAuthlibInjectorAccount.h" #include "Application.h"
#include "GetAuthlibInjectorApiLocation.h"
#include <QtWidgets/QPushButton> #include <QtWidgets/QPushButton>
AuthlibInjectorLoginDialog::AuthlibInjectorLoginDialog(QWidget* parent) : QDialog(parent), ui(new Ui::AuthlibInjectorLoginDialog) AuthlibInjectorLoginDialog::AuthlibInjectorLoginDialog(QWidget* parent) : QDialog(parent), ui(new Ui::AuthlibInjectorLoginDialog)
{ {
ui->setupUi(this); ui->setupUi(this);
ui->userTextBox->setFocus();
ui->loadingLabel->setVisible(false); ui->loadingLabel->setVisible(false);
ui->errorMessage->setVisible(false);
ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
setAcceptDrops(true); setAcceptDrops(true);
@ -88,6 +91,7 @@ void AuthlibInjectorLoginDialog::dropEvent(QDropEvent* event)
// Stage 1: User interaction // Stage 1: User interaction
void AuthlibInjectorLoginDialog::accept() void AuthlibInjectorLoginDialog::accept()
{ {
ui->errorMessage->setVisible(false);
auto fixedAuthlibInjectorUrl = AuthlibInjectorLoginDialog::fixUrl(ui->authlibInjectorTextBox->text()); auto fixedAuthlibInjectorUrl = AuthlibInjectorLoginDialog::fixUrl(ui->authlibInjectorTextBox->text());
auto response = CustomMessageBox::selectable(this, QObject::tr("Confirm account creation"), auto response = CustomMessageBox::selectable(this, QObject::tr("Confirm account creation"),
@ -108,15 +112,16 @@ void AuthlibInjectorLoginDialog::accept()
// Get the authlib-injector API root // Get the authlib-injector API root
auto netJob = NetJob::Ptr(new NetJob("Get authlib-injector API root", APPLICATION->network())); auto netJob = NetJob::Ptr(new NetJob("Get authlib-injector API root", APPLICATION->network()));
netJob->setAskRetry(false); netJob->setAskRetry(false);
netJob->setAutoRetryLimit(0);
auto username = ui->userTextBox->text(); auto username = ui->userTextBox->text();
m_createAuthlibInjectorAccountTask = CreateAuthlibInjectorAccount::make(fixedAuthlibInjectorUrl, m_account, username); m_apiLocationRequest = GetAuthlibInjectorApiLocation::make(fixedAuthlibInjectorUrl, m_account, username);
netJob->addNetAction(m_createAuthlibInjectorAccountTask); netJob->addNetAction(m_apiLocationRequest);
m_createAuthlibInjectorAccountNetJob.reset(netJob); m_apiLocationTask.reset(netJob);
connect(netJob.get(), &NetJob::succeeded, this, &AuthlibInjectorLoginDialog::onUrlTaskSucceeded); connect(netJob.get(), &NetJob::succeeded, this, &AuthlibInjectorLoginDialog::onApiLocationTaskSucceeded);
connect(netJob.get(), &NetJob::failed, this, &AuthlibInjectorLoginDialog::onTaskFailed); connect(netJob.get(), &NetJob::failed, this, &AuthlibInjectorLoginDialog::onApiLocationTaskFailed);
m_createAuthlibInjectorAccountNetJob->start(); m_apiLocationTask->start();
} }
void AuthlibInjectorLoginDialog::setUserInputsEnabled(bool enable) void AuthlibInjectorLoginDialog::setUserInputsEnabled(bool enable)
@ -144,6 +149,11 @@ void AuthlibInjectorLoginDialog::on_authlibInjectorTextBox_textEdited(const QStr
->setEnabled(!newText.isEmpty() && !ui->passTextBox->text().isEmpty() && !ui->authlibInjectorTextBox->text().isEmpty()); ->setEnabled(!newText.isEmpty() && !ui->passTextBox->text().isEmpty() && !ui->authlibInjectorTextBox->text().isEmpty());
} }
void AuthlibInjectorLoginDialog::onApiLocationTaskFailed(const QString& reason)
{
onTaskFailed(m_apiLocationRequest->errorString());
}
void AuthlibInjectorLoginDialog::onTaskFailed(const QString& reason) void AuthlibInjectorLoginDialog::onTaskFailed(const QString& reason)
{ {
// Set message // Set message
@ -156,16 +166,17 @@ void AuthlibInjectorLoginDialog::onTaskFailed(const QString& reason)
processed += "<br />"; processed += "<br />";
} }
} }
ui->label->setText(processed); ui->errorMessage->setText(processed);
ui->errorMessage->setVisible(true);
// Re-enable user-interaction // Re-enable user-interaction
setUserInputsEnabled(true); setUserInputsEnabled(true);
ui->loadingLabel->setVisible(false); ui->loadingLabel->setVisible(false);
} }
void AuthlibInjectorLoginDialog::onUrlTaskSucceeded() void AuthlibInjectorLoginDialog::onApiLocationTaskSucceeded()
{ {
m_account = m_createAuthlibInjectorAccountTask->getAccount(); m_account = m_apiLocationRequest->getAccount();
m_loginTask = m_account->login(false, ui->passTextBox->text()); m_loginTask = m_account->login(false, ui->passTextBox->text());
connect(m_loginTask.get(), &Task::failed, this, &AuthlibInjectorLoginDialog::onTaskFailed); connect(m_loginTask.get(), &Task::failed, this, &AuthlibInjectorLoginDialog::onTaskFailed);
connect(m_loginTask.get(), &Task::succeeded, this, &AuthlibInjectorLoginDialog::onTaskSucceeded); connect(m_loginTask.get(), &Task::succeeded, this, &AuthlibInjectorLoginDialog::onTaskSucceeded);
@ -180,7 +191,7 @@ void AuthlibInjectorLoginDialog::onTaskSucceeded()
void AuthlibInjectorLoginDialog::onTaskStatus(const QString& status) void AuthlibInjectorLoginDialog::onTaskStatus(const QString& status)
{ {
ui->label->setText(status); ui->errorMessage->setText(status);
} }
// Public interface // Public interface

View File

@ -22,8 +22,7 @@
#include <QtWidgets/QDialog> #include <QtWidgets/QDialog>
#include <net/NetJob.h> #include <net/NetJob.h>
#include "Application.h" #include "GetAuthlibInjectorApiLocation.h"
#include "CreateAuthlibInjectorAccount.h"
#include "minecraft/auth/MinecraftAccount.h" #include "minecraft/auth/MinecraftAccount.h"
#include "tasks/Task.h" #include "tasks/Task.h"
@ -47,8 +46,9 @@ class AuthlibInjectorLoginDialog : public QDialog {
protected slots: protected slots:
void accept(); void accept();
void onApiLocationTaskSucceeded();
void onApiLocationTaskFailed(const QString&);
void onTaskFailed(const QString& reason); void onTaskFailed(const QString& reason);
void onUrlTaskSucceeded();
void onTaskSucceeded(); void onTaskSucceeded();
void onTaskStatus(const QString& status); void onTaskStatus(const QString& status);
@ -65,6 +65,6 @@ class AuthlibInjectorLoginDialog : public QDialog {
Ui::AuthlibInjectorLoginDialog* ui; Ui::AuthlibInjectorLoginDialog* ui;
MinecraftAccountPtr m_account; MinecraftAccountPtr m_account;
Task::Ptr m_loginTask; Task::Ptr m_loginTask;
Task::Ptr m_createAuthlibInjectorAccountNetJob; Task::Ptr m_apiLocationTask;
CreateAuthlibInjectorAccount::Ptr m_createAuthlibInjectorAccountTask; GetAuthlibInjectorApiLocation::Ptr m_apiLocationRequest;
}; };

View File

@ -80,6 +80,22 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="QLabel" name="errorMessage">
<property name="text">
<string notr="true">Error message label placeholder.</string>
</property>
<property name="textFormat">
<enum>Qt::RichText</enum>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item> <item>
<widget class="QDialogButtonBox" name="buttonBox"> <widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation"> <property name="orientation">