diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index e474aa9b2..00c0041cc 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -32,8 +32,10 @@ set(MM_SRCS ios/iosutils.cpp position/providers/abstractpositionprovider.cpp position/providers/internalpositionprovider.cpp + position/providers/networkpositionprovider.cpp position/providers/positionprovidersmodel.cpp position/providers/simulatedpositionprovider.cpp + position/providers/nmeaparser.cpp position/tracking/abstracttrackingbackend.cpp position/tracking/internaltrackingbackend.cpp position/tracking/positiontrackinghighlight.cpp @@ -122,8 +124,10 @@ set(MM_HDRS ios/iosutils.h position/providers/abstractpositionprovider.h position/providers/internalpositionprovider.h + position/providers/networkpositionprovider.h position/providers/positionprovidersmodel.h position/providers/simulatedpositionprovider.h + position/providers/nmeaparser.h position/tracking/abstracttrackingbackend.h position/tracking/internaltrackingbackend.h position/tracking/positiontrackinghighlight.h diff --git a/app/appsettings.cpp b/app/appsettings.cpp index 58e340ec1..e781e108a 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -215,6 +215,7 @@ QVariantList AppSettings::savedPositionProviders() const QStringList provider; provider << settings.value( "providerName" ).toString(); provider << settings.value( "providerAddress" ).toString(); + provider << settings.value( "providerType" ).toString(); providers.push_back( provider ); } @@ -238,15 +239,16 @@ void AppSettings::savePositionProviders( const QVariantList &providers ) { QVariantList provider = providers[i].toList(); - if ( provider.length() < 2 ) + if ( provider.length() < 3 ) { CoreUtils::log( QStringLiteral( "AppSettings" ), QStringLiteral( "Tried to save provider without sufficient data" ) ); continue; } settings.setArrayIndex( i ); - settings.setValue( "providerName", providers[i].toList()[0] ); - settings.setValue( "providerAddress", providers[i].toList()[1] ); + settings.setValue( "providerName", provider[0] ); + settings.setValue( "providerAddress", provider[1] ); + settings.setValue( "providerType", provider[2] ); } settings.endArray(); } diff --git a/app/icons/Bluetooth.svg b/app/icons/Bluetooth.svg new file mode 100644 index 000000000..f18e6d485 --- /dev/null +++ b/app/icons/Bluetooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/Network.svg b/app/icons/Network.svg new file mode 100644 index 000000000..3ff4e7b3d --- /dev/null +++ b/app/icons/Network.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/icons/icons.qrc b/app/icons/icons.qrc index 8d9a78db9..261db4fb3 100644 --- a/app/icons/icons.qrc +++ b/app/icons/icons.qrc @@ -7,6 +7,7 @@ ArrowLinkRight.svg ArrowUp.svg Back.svg + Bluetooth.svg Briefcase.svg Calendar.svg Checkmark.svg @@ -57,6 +58,7 @@ Mouth.svg NaturalResources.svg Next.svg + Network.svg Other.svg Others.svg Personal.svg diff --git a/app/images/ExternalBluetoothProvider.svg b/app/images/ExternalBluetoothProvider.svg new file mode 100644 index 000000000..35f92c7bd --- /dev/null +++ b/app/images/ExternalBluetoothProvider.svg @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/images/ExternalGpsGreen.svg b/app/images/ExternalGpsGreen.svg deleted file mode 100644 index d5a833bd7..000000000 --- a/app/images/ExternalGpsGreen.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/images/ExternalGpsRed.svg b/app/images/ExternalGpsRed.svg index 9fd5a52d2..67f981956 100644 --- a/app/images/ExternalGpsRed.svg +++ b/app/images/ExternalGpsRed.svg @@ -1,14 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + - + diff --git a/app/images/ExternalNetworkProvider.svg b/app/images/ExternalNetworkProvider.svg new file mode 100644 index 000000000..a2602f99f --- /dev/null +++ b/app/images/ExternalNetworkProvider.svg @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/images/images.qrc b/app/images/images.qrc index b0b772a30..2d2cf69f2 100644 --- a/app/images/images.qrc +++ b/app/images/images.qrc @@ -19,8 +19,9 @@ UploadImage.svg WarnLogoImage.svg NoMapThemesImage.svg + ExternalBluetoothProvider.svg ExternalGpsRed.svg - ExternalGpsGreen.svg + ExternalNetworkProvider.svg NegativeMMSymbol.svg PositiveMMSymbol.svg NeutralMMSymbol.svg diff --git a/app/mmstyle.h b/app/mmstyle.h index 1dc74b4f6..f51f4a567 100644 --- a/app/mmstyle.h +++ b/app/mmstyle.h @@ -112,6 +112,7 @@ class MMStyle: public QObject Q_PROPERTY( QUrl arrowDownIcon READ arrowDownIcon CONSTANT ) Q_PROPERTY( QUrl arrowLinkRightIcon READ arrowLinkRightIcon CONSTANT ) Q_PROPERTY( QUrl arrowUpIcon READ arrowUpIcon CONSTANT ) + Q_PROPERTY( QUrl bluetoothIcon READ bluetoothIcon CONSTANT ) Q_PROPERTY( QUrl backIcon READ backIcon CONSTANT ) Q_PROPERTY( QUrl briefcaseIcon READ briefcaseIcon CONSTANT ) Q_PROPERTY( QUrl calendarIcon READ calendarIcon CONSTANT ) @@ -144,6 +145,7 @@ class MMStyle: public QObject Q_PROPERTY( QUrl mouthIcon READ mouthIcon CONSTANT ) Q_PROPERTY( QUrl naturalResourcesIcon READ naturalResourcesIcon CONSTANT ) Q_PROPERTY( QUrl nextIcon READ nextIcon CONSTANT ) + Q_PROPERTY( QUrl networkIcon READ networkIcon CONSTANT ) Q_PROPERTY( QUrl otherIcon READ otherIcon CONSTANT ) Q_PROPERTY( QUrl othersIcon READ othersIcon CONSTANT ) Q_PROPERTY( QUrl plusIcon READ plusIcon CONSTANT ) @@ -227,8 +229,9 @@ class MMStyle: public QObject Q_PROPERTY( QUrl positionTrackingRunningImage READ positionTrackingRunningImage CONSTANT ) Q_PROPERTY( QUrl positionTrackingStartImage READ positionTrackingStartImage CONSTANT ) Q_PROPERTY( QUrl syncImage READ syncImage CONSTANT ) - Q_PROPERTY( QUrl externalGpsGreenImage READ externalGpsGreenImage CONSTANT ) Q_PROPERTY( QUrl externalGpsRedImage READ externalGpsRedImage CONSTANT ) + Q_PROPERTY( QUrl externalBluetoothGreenImage READ externalBluetoothGreenImage CONSTANT ) + Q_PROPERTY( QUrl externalNetworkGreenImage READ externalNetworkGreenImage CONSTANT ) Q_PROPERTY( QUrl negativeMMSymbolImage READ negativeMMSymbolImage CONSTANT ) Q_PROPERTY( QUrl positiveMMSymbolImage READ positiveMMSymbolImage CONSTANT ) Q_PROPERTY( QUrl neutralMMSymbolImage READ neutralMMSymbolImage CONSTANT ) @@ -424,6 +427,7 @@ class MMStyle: public QObject QUrl arrowLinkRightIcon() const {return QUrl( "qrc:/ArrowLinkRight.svg" );} QUrl arrowUpIcon() const {return QUrl( "qrc:/ArrowUp.svg" );} QUrl backIcon() const {return QUrl( "qrc:/Back.svg" );} + QUrl bluetoothIcon() const {return QUrl( "qrc:/Bluetooth.svg" );} QUrl briefcaseIcon() const {return QUrl( "qrc:/Briefcase.svg" );} QUrl calendarIcon() const {return QUrl( "qrc:/Calendar.svg" );} QUrl checkmarkIcon() const {return QUrl( "qrc:/Checkmark.svg" );} @@ -454,6 +458,7 @@ class MMStyle: public QObject QUrl remoteImageLoadErrorIcon() const {return QUrl( "qrc:/RemoteImageLoadError.svg" );} QUrl mouthIcon() const {return QUrl( "qrc:/Mouth.svg" );} QUrl measurementToolIcon() const {return QUrl( "qrc:/Measure.svg" );} + QUrl networkIcon() const {return QUrl( "qrc:/Network.svg" );} QUrl closeShapeIcon() const {return QUrl( "qrc:/CloseShape.svg" );} QUrl naturalResourcesIcon() const {return QUrl( "qrc:/NaturalResources.svg" );} QUrl nextIcon() const {return QUrl( "qrc:/Next.svg" );} @@ -530,8 +535,9 @@ class MMStyle: public QObject QUrl positionTrackingRunningImage() const {return QUrl( "qrc:/images/PositionTrackingRunning.svg" );} QUrl positionTrackingStartImage() const {return QUrl( "qrc:/images/PositionTrackingStart.svg" );} QUrl syncImage() const {return QUrl( "qrc:/images/SyncImage.svg" );} - QUrl externalGpsGreenImage() const {return QUrl( "qrc:/images/ExternalGpsGreen.svg" );} QUrl externalGpsRedImage() const {return QUrl( "qrc:/images/ExternalGpsRed.svg" );} + QUrl externalBluetoothGreenImage() const {return QUrl( "qrc:/images/ExternalBluetoothProvider.svg" );} + QUrl externalNetworkGreenImage() const {return QUrl( "qrc:/images/ExternalNetworkProvider.svg" );} QUrl negativeMMSymbolImage() const {return QUrl( "qrc:/images/NegativeMMSymbol.svg" );} QUrl positiveMMSymbolImage() const {return QUrl( "qrc:/images/PositiveMMSymbol.svg" );} QUrl neutralMMSymbolImage() const {return QUrl( "qrc:/images/NeutralMMSymbol.svg" );} diff --git a/app/position/positionkit.cpp b/app/position/positionkit.cpp index 85c6677a5..509144c80 100644 --- a/app/position/positionkit.cpp +++ b/app/position/positionkit.cpp @@ -18,6 +18,7 @@ #include "position/providers/internalpositionprovider.h" #include "position/providers/simulatedpositionprovider.h" +#include "providers/networkpositionprovider.h" #ifdef ANDROID #include "position/providers/androidpositionprovider.h" #include @@ -127,54 +128,57 @@ QString PositionKit::positionProviderName() const AbstractPositionProvider *PositionKit::constructProvider( const QString &type, const QString &id, const QString &name ) { QString providerType( type ); - - // currently the only external provider is bluetooth, so manually set internal provider for platforms that - // do not support reading bluetooth serial -#ifndef HAVE_BLUETOOTH - providerType = QStringLiteral( "internal" ); -#endif - - if ( providerType == QStringLiteral( "external" ) ) + if ( providerType == QStringLiteral( "external_bt" ) ) { #ifdef HAVE_BLUETOOTH AbstractPositionProvider *provider = new BluetoothPositionProvider( id, name, *mPositionTransformer ); QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); return provider; +#else + providerType = QStringLiteral( "internal" ); #endif } - else // type == internal + + if ( providerType == QStringLiteral( "external_ip" ) ) { - if ( id == QStringLiteral( "simulated" ) ) - { - AbstractPositionProvider *provider = new SimulatedPositionProvider( *mPositionTransformer ); - QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); - return provider; - } + AbstractPositionProvider *provider = new NetworkPositionProvider( id, name, *mPositionTransformer ); + QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); + return provider; + } + + // type == internal + if ( id == QStringLiteral( "simulated" ) ) + { + AbstractPositionProvider *provider = new SimulatedPositionProvider( *mPositionTransformer ); + QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); + return provider; + } + #ifdef ANDROID - if ( id == QStringLiteral( "android_fused" ) || id == QStringLiteral( "android_gps" ) ) + if ( id == QStringLiteral( "android_fused" ) || id == QStringLiteral( "android_gps" ) ) + { + const bool fused = ( id == QStringLiteral( "android_fused" ) ); + if ( fused && !AndroidPositionProvider::isFusedAvailable() ) { - const bool fused = ( id == QStringLiteral( "android_fused" ) ); - if ( fused && !AndroidPositionProvider::isFusedAvailable() ) - { - // TODO: inform user + use AndroidPositionProvider::fusedErrorString() output? + // TODO: inform user + use AndroidPositionProvider::fusedErrorString() output? - // fallback to the default - at this point the Qt Positioning implementation - AbstractPositionProvider *provider = new InternalPositionProvider( *mPositionTransformer ); - QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); - return provider; - } - __android_log_print( ANDROID_LOG_INFO, "CPP", "MAKE PROVIDER %d", fused ); - AbstractPositionProvider *provider = new AndroidPositionProvider( fused, *mPositionTransformer ); - QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); - return provider; - } -#endif - else // id == devicegps - { + // fallback to the default - at this point the Qt Positioning implementation AbstractPositionProvider *provider = new InternalPositionProvider( *mPositionTransformer ); QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); return provider; } + __android_log_print( ANDROID_LOG_INFO, "CPP", "MAKE PROVIDER %d", fused ); + AbstractPositionProvider *provider = new AndroidPositionProvider( fused, *mPositionTransformer ); + QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); + return provider; + } +#endif + + // id == devicegps + { + AbstractPositionProvider *provider = new InternalPositionProvider( *mPositionTransformer ); + QQmlEngine::setObjectOwnership( provider, QQmlEngine::CppOwnership ); + return provider; } } @@ -207,8 +211,10 @@ AbstractPositionProvider *PositionKit::constructActiveProvider( AppSettings *app } else { - // find name of the active provider + // find name & type of the active provider QString providerName; + // Migration from single external provider to multiple currently, missing type == bluetooth provider + QString providerType = QStringLiteral( "external_bt" ); QVariantList providers = appsettings->savedPositionProviders(); for ( const auto &provider : providers ) @@ -223,10 +229,14 @@ AbstractPositionProvider *PositionKit::constructActiveProvider( AppSettings *app if ( providerData[1] == providerId ) { providerName = providerData[0].toString(); + if ( !providerData.at( 2 ).isNull() ) + { + providerType = providerData[2].toString(); + } } } - return constructProvider( QStringLiteral( "external" ), providerId, providerName ); + return constructProvider( providerType, providerId, providerName ); } } diff --git a/app/position/positiontransformer.cpp b/app/position/positiontransformer.cpp index 3d5dd559c..52071dd16 100644 --- a/app/position/positiontransformer.cpp +++ b/app/position/positiontransformer.cpp @@ -50,6 +50,11 @@ GeoPosition PositionTransformer::processBluetoothPosition( GeoPosition geoPositi return geoPosition; } +GeoPosition PositionTransformer::processNetworkPosition( const GeoPosition &geoPosition ) +{ + return processBluetoothPosition( geoPosition ); +} + GeoPosition PositionTransformer::processAndroidPosition( GeoPosition geoPosition ) { if ( geoPosition.elevation != std::numeric_limits::quiet_NaN() ) diff --git a/app/position/positiontransformer.h b/app/position/positiontransformer.h index 87e4a2c6a..0bac4de0f 100644 --- a/app/position/positiontransformer.h +++ b/app/position/positiontransformer.h @@ -49,6 +49,14 @@ class PositionTransformer : QObject */ GeoPosition processBluetoothPosition( GeoPosition geoPosition ); + /** + * Transform the elevation if the user sets custom vertical CRS. The elevation gets recalculated to ellipsoid elevation + * and then back to orthometric based on specified CRS. + * \note This method should be used only with NetworkPositionProvider to mitigate unnecessary transformations + * \return Copy of passed geoPosition with processed elevation and elevation separation. + */ + GeoPosition processNetworkPosition( const GeoPosition &geoPosition ); + /** * Transform the elevation from EPSG:4979 (WGS84 (EPSG:4326) + ellipsoidal height) to specified geoid model * (by default EPSG:9707 (WGS84 + EGM96)) diff --git a/app/position/providers/bluetoothpositionprovider.cpp b/app/position/providers/bluetoothpositionprovider.cpp index c96a235db..380c595fe 100644 --- a/app/position/providers/bluetoothpositionprovider.cpp +++ b/app/position/providers/bluetoothpositionprovider.cpp @@ -19,19 +19,8 @@ #include #endif -NmeaParser::NmeaParser() : QgsNmeaConnection( new QBluetoothSocket() ) -{ -} - -QgsGpsInformation NmeaParser::parseNmeaString( const QString &nmeaString ) -{ - mStringBuffer = nmeaString; - processStringBuffer(); - return mLastGPSInformation; -} - BluetoothPositionProvider::BluetoothPositionProvider( const QString &addr, const QString &name, PositionTransformer &positionTransformer, QObject *parent ) - : AbstractPositionProvider( addr, QStringLiteral( "external" ), name, positionTransformer, parent ) + : AbstractPositionProvider( addr, QStringLiteral( "external_bt" ), name, positionTransformer, parent ) , mTargetAddress( addr ) { mSocket = std::unique_ptr( new QBluetoothSocket( QBluetoothServiceInfo::RfcommProtocol ) ); diff --git a/app/position/providers/bluetoothpositionprovider.h b/app/position/providers/bluetoothpositionprovider.h index 4670131da..8797c8ec1 100644 --- a/app/position/providers/bluetoothpositionprovider.h +++ b/app/position/providers/bluetoothpositionprovider.h @@ -10,29 +10,12 @@ #ifndef BLUETOOTHPOSITIONPROVIDER_H #define BLUETOOTHPOSITIONPROVIDER_H -#include "abstractpositionprovider.h" - -#include "qgsnmeaconnection.h" - #include #include #include -/** - * NmeaParser is a big hack how to reuse QGIS NmeaConnection function in order to (a) keep ownership of bluetooth - * socket, (b) do not have multiple unique_ptrs holding the same pointer and to avoid some possible crashes. - * - * Note: This way of reusing makes the parser highly dependent on QgsNmeaConnection class and any change inside the class - * can lead to misbehavior's. See implementation of QgsNmeaConnection and QgsGpsConnection for more details. - */ -class NmeaParser : public QgsNmeaConnection -{ - public: - NmeaParser(); - - // Takes nmea string and returns gps position - QgsGpsInformation parseNmeaString( const QString &nmeaString ); -}; +#include "abstractpositionprovider.h" +#include "nmeaparser.h" /** * BluetoothPositionProvider initiates connection to bluetooth device diff --git a/app/position/providers/networkpositionprovider.cpp b/app/position/providers/networkpositionprovider.cpp new file mode 100644 index 000000000..e7363d6ea --- /dev/null +++ b/app/position/providers/networkpositionprovider.cpp @@ -0,0 +1,222 @@ +/*************************************************************************** +* * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "networkpositionprovider.h" + +#include +#include + +static int ONE_SECOND_MS = 1000; + +NetworkPositionProvider::NetworkPositionProvider( const QString &addr, const QString &name, PositionTransformer &positionTransformer, QObject *parent ) + : AbstractPositionProvider( addr, QStringLiteral( "external_ip" ), name, positionTransformer, parent ), + mSecondsLeftToReconnect( ReconnectDelay::ShortDelay / ONE_SECOND_MS ) +{ + const QStringList targetAddress = addr.split( ":" ); + mTargetAddress = targetAddress.at( 0 ); + mTargetPort = targetAddress.at( 1 ).toInt(); + + mTcpSocket = std::make_unique(); + mUdpSocket = std::make_unique(); + + connect( mTcpSocket.get(), &QTcpSocket::readyRead, this, &NetworkPositionProvider::positionUpdateReceived ); + connect( mUdpSocket.get(), &QUdpSocket::readyRead, this, &NetworkPositionProvider::positionUpdateReceived ); + + connect( mTcpSocket.get(), &QTcpSocket::stateChanged, this, &NetworkPositionProvider::socketStateChanged ); + connect( mUdpSocket.get(), &QUdpSocket::stateChanged, this, &NetworkPositionProvider::socketStateChanged ); + + mReconnectTimer.setSingleShot( false ); + mReconnectTimer.setInterval( ONE_SECOND_MS ); + connect( &mReconnectTimer, &QTimer::timeout, this, &NetworkPositionProvider::reconnectTimeout ); + mUdpReconnectTimer.setSingleShot( true ); + connect( &mUdpReconnectTimer, &QTimer::timeout, this, [this] + { + if ( mTcpSocket->state() != QAbstractSocket::ConnectedState ) + { + setState( tr( "No connection" ), State::NoConnection ); + startReconnectTimer(); + // let's also invalidate current position since we no longer have connection + emit positionChanged( GeoPosition() ); + } + } ); + + NetworkPositionProvider::startUpdates(); +} + +void NetworkPositionProvider::startUpdates() +{ + // TODO: QHostAddress doesn't support hostname lookup (QHostInfo does) + mTcpSocket->connectToHost( mTargetAddress, mTargetPort ); + mUdpSocket->bind( QHostAddress::LocalHost, mTargetPort ); + mUdpReconnectTimer.start( ReconnectDelay::ExtraLongDelay ); +} + +void NetworkPositionProvider::stopUpdates() +{ + if ( mTcpSocket->state() != QAbstractSocket::UnconnectedState && mTcpSocket->state() != QAbstractSocket::ClosingState ) + { + mTcpSocket->disconnectFromHost(); + } + if ( mUdpSocket->state() != QAbstractSocket::UnconnectedState && mUdpSocket->state() != QAbstractSocket::ClosingState ) + { + mUdpSocket->disconnectFromHost(); + } +} + +void NetworkPositionProvider::closeProvider() +{ + mTcpSocket->close(); + mUdpSocket->close(); + if ( mTcpSocket ) mTcpSocket->disconnect(); + if ( mUdpSocket ) mUdpSocket->disconnect(); + + mUdpReconnectTimer.stop(); + mReconnectTimer.stop(); +} + +void NetworkPositionProvider::positionUpdateReceived() +{ + QAbstractSocket *socket = dynamic_cast( sender() ); + + // if udp is not connected to the host yet, connect + // this approach will let us use QIODevice functions for both sockets + if ( socket->socketType() == QAbstractSocket::UdpSocket && mUdpSocket->state() != QAbstractSocket::ConnectedState ) + { + mUdpReconnectTimer.stop(); + + // if by any chance we showed wrong message in the status like "no connection", fix it here + // we know the connection is working because we just received data from the device + setState( tr( "Connected" ), State::Connected ); + + QHostAddress peerAddress; + int peerPort; + // process the incoming data as it will break the signal emitting if unprocessed + while ( mUdpSocket->hasPendingDatagrams() ) + { + QNetworkDatagram datagram = mUdpSocket->receiveDatagram(); + peerAddress = datagram.senderAddress(); + peerPort = datagram.senderPort(); + const QByteArray rawNmeaData = datagram.data(); + const QString nmeaData( rawNmeaData ); + const QgsGpsInformation gpsInfo = mNmeaParser.parseNmeaString( nmeaData ); + GeoPosition transformedPosition = mPositionTransformer->processNetworkPosition( GeoPosition::fromQgsGpsInformation( gpsInfo ) ); + + emit positionChanged( transformedPosition ); + } + + // "connect" to peer if we are not already connecting + if ( mUdpSocket->state() != QAbstractSocket::ConnectedState && mUdpSocket->state() != QAbstractSocket::ConnectingState ) + { + mUdpSocket->connectToHost( peerAddress.toString(), peerPort ); + } + return; + } + + // stop the UDP silence timer, we just received data + // kills the timer when the app was minimized, and we were able to reconnect in the meantime + if ( socket->socketType() == QAbstractSocket::UdpSocket ) + { + mUdpReconnectTimer.stop(); + } + + const QByteArray rawNmeaData = socket->readAll(); + + if ( rawNmeaData.isEmpty() || !rawNmeaData.contains( '$' ) ) + { + return; + } + + // if by any chance we showed wrong message in the status like "no connection", fix it here + // we know the connection is working because we just received data from the device + setState( tr( "Connected" ), State::Connected ); + + const QString nmeaData( rawNmeaData ); + const QgsGpsInformation gpsInfo = mNmeaParser.parseNmeaString( nmeaData ); + GeoPosition transformedPosition = mPositionTransformer->processNetworkPosition( GeoPosition::fromQgsGpsInformation( gpsInfo ) ); + + emit positionChanged( GeoPosition::fromQgsGpsInformation( gpsInfo ) ); +} + +void NetworkPositionProvider::socketStateChanged( const QAbstractSocket::SocketState state ) +{ + const QAbstractSocket *socket = dynamic_cast( sender() ); + + if ( state == QAbstractSocket::ConnectingState || state == QAbstractSocket::HostLookupState ) + { + setState( tr( "Connecting to %1" ).arg( mProviderName ), State::Connecting ); + } + // Only with TCP we can be sure in ConnectedState that we are connected, with UDP we wait until the first datagram arrives + else if ( state == QAbstractSocket::ConnectedState && socket->socketType() == QAbstractSocket::TcpSocket ) + { + setState( tr( "Connected" ), State::Connected ); + } + else if ( state == QAbstractSocket::UnconnectedState ) + { + const bool isUdpSocketListening = mUdpSocket->state() == QAbstractSocket::ConnectedState || mUdpSocket->state() == QAbstractSocket::BoundState || mUdpReconnectTimer.isActive(); + if ( socket->socketType() == QAbstractSocket::TcpSocket && !isUdpSocketListening && QApplication::applicationState() == Qt::ApplicationActive ) + { + setState( tr( "No connection" ), State::NoConnection ); + startReconnectTimer(); + // let's also invalidate current position since we no longer have connection + emit positionChanged( GeoPosition() ); + } + else if ( socket->socketType() == QAbstractSocket::UdpSocket && QApplication::applicationState() == Qt::ApplicationActive ) + { + setState( tr( "No connection" ), State::NoConnection ); + startReconnectTimer(); + // let's also invalidate current position since we no longer have connection + emit positionChanged( GeoPosition() ); + } + } +} + +void NetworkPositionProvider::reconnectTimeout() +{ + if ( mSecondsLeftToReconnect <= 1 ) + { + reconnect(); + } + else + { + mSecondsLeftToReconnect--; + setState( tr( "No connection, reconnecting in (%1)" ).arg( mSecondsLeftToReconnect ), State::WaitingToReconnect ); + mReconnectTimer.start(); + } +} + +QString NetworkPositionProvider::getIpAddress() const +{ + return mTargetAddress; +} + +void NetworkPositionProvider::reconnect() +{ + mReconnectTimer.stop(); + + setState( tr( "Reconnecting" ), State::Connecting ); + + stopUpdates(); + mReconnectTimer.stop(); + + startUpdates(); +} + +void NetworkPositionProvider::startReconnectTimer() +{ + mSecondsLeftToReconnect = mReconnectDelay / ONE_SECOND_MS; + setState( tr( "No connection, reconnecting in (%1)" ).arg( mSecondsLeftToReconnect ), State::WaitingToReconnect ); + + mReconnectTimer.start(); + + // first time do reconnect in short time, then each other in long time + if ( mReconnectDelay == NetworkPositionProvider::ShortDelay ) + { + mReconnectDelay = NetworkPositionProvider::LongDelay; + } +} diff --git a/app/position/providers/networkpositionprovider.h b/app/position/providers/networkpositionprovider.h new file mode 100644 index 000000000..3c87ddbbf --- /dev/null +++ b/app/position/providers/networkpositionprovider.h @@ -0,0 +1,69 @@ +/*************************************************************************** +* * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + + +#ifndef NETWORKPOSITIONPROVIDER_H +#define NETWORKPOSITIONPROVIDER_H + +#include +#include +#include + +#include "abstractpositionprovider.h" +#include "nmeaparser.h" + + +class NetworkPositionProvider : public AbstractPositionProvider +{ + Q_OBJECT + + // signalizes in how many [ms] we will try to reconnect to GPS again + enum ReconnectDelay + { + ShortDelay = 3000, // 3 secs + LongDelay = 5000, // 5 secs + ExtraLongDelay = 10000 // 10 secs + }; + + public: + NetworkPositionProvider( const QString &addr, const QString &name, PositionTransformer &positionTransformer, QObject *parent = nullptr ); + + void startUpdates() override; + void stopUpdates() override; + void closeProvider() override; + Q_INVOKABLE QString getIpAddress() const; // returns the IP address we try to connect/ are connected to + + public slots: + // processes the received nmea data and emits new position + void positionUpdateReceived(); + // changes the provider state depending on the socket states + void socketStateChanged( QAbstractSocket::SocketState state ); + // checks if enough time passed since last reconnect and triggers it if necessary + void reconnectTimeout(); + + private: + // trigger the reconnection flow for both sockets + void reconnect(); + // start the reconnection timeout + void startReconnectTimer(); + + std::unique_ptr mTcpSocket; + std::unique_ptr mUdpSocket; + + int mReconnectDelay = ReconnectDelay::ShortDelay; // in how many [ms] we will try to reconnect again + int mSecondsLeftToReconnect; // how many seconds are left to reconnect. Reconnects if less than or equal to one + QTimer mReconnectTimer; // timer that times out each second and lowers the mSecondsLeftToReconnect by one + QTimer mUdpReconnectTimer; // timer that times out after ExtraLongDelay and triggers reconnect + + QString mTargetAddress; // IP address or hostname of the receiver + int mTargetPort; // active port of the receiver + + NmeaParser mNmeaParser; // parser to decode received NMEA strings +}; +#endif //NETWORKPOSITIONPROVIDER_H diff --git a/app/position/providers/nmeaparser.cpp b/app/position/providers/nmeaparser.cpp new file mode 100644 index 000000000..69a8f1015 --- /dev/null +++ b/app/position/providers/nmeaparser.cpp @@ -0,0 +1,23 @@ +/*************************************************************************** +* * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "nmeaparser.h" + +#include + +NmeaParser::NmeaParser() : QgsNmeaConnection( new QBuffer() ) +{ +} + +QgsGpsInformation NmeaParser::parseNmeaString( const QString &nmeaString ) +{ + mStringBuffer = nmeaString; + processStringBuffer(); + return mLastGPSInformation; +} \ No newline at end of file diff --git a/app/position/providers/nmeaparser.h b/app/position/providers/nmeaparser.h new file mode 100644 index 000000000..fc47d7e82 --- /dev/null +++ b/app/position/providers/nmeaparser.h @@ -0,0 +1,32 @@ +/*************************************************************************** +* * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef NMEAPARSER_H +#define NMEAPARSER_H + +#include + +/** + * NmeaParser is a big hack how to reuse QGIS NmeaConnection function in order to (a) keep ownership of bluetooth + * socket, (b) do not have multiple unique_ptrs holding the same pointer and to avoid some possible crashes. + * + * Note: This way of reusing makes the parser highly dependent on QgsNmeaConnection class and any change inside the class + * can lead to misbehavior's. See implementation of QgsNmeaConnection and QgsGpsConnection for more details. + */ +class NmeaParser : public QgsNmeaConnection +{ + public: + NmeaParser(); + +// Takes nmea string and returns gps position + QgsGpsInformation parseNmeaString( const QString &nmeaString ); +}; + + +#endif //NMEAPARSER_H \ No newline at end of file diff --git a/app/position/providers/positionprovidersmodel.cpp b/app/position/providers/positionprovidersmodel.cpp index e070e4576..c9a91b6ea 100644 --- a/app/position/providers/positionprovidersmodel.cpp +++ b/app/position/providers/positionprovidersmodel.cpp @@ -134,16 +134,17 @@ void PositionProvidersModel::removeProvider( const QString &providerId ) } } -void PositionProvidersModel::addProvider( const QString &name, const QString &providerId ) +void PositionProvidersModel::addProvider( const QString &name, const QString &providerId, const QString &providerType ) { if ( providerId.isEmpty() ) return; PositionProvider toAdd; + const QString deviceDesc = providerType == QStringLiteral( "external_bt" ) ? tr( " Bluetooth device" ) : tr( " Network device" ); toAdd.name = name; toAdd.providerId = providerId; - toAdd.description = providerId + " " + tr( " Bluetooth device" ); - toAdd.providerType = "external"; + toAdd.description = providerId + " " + deviceDesc; + toAdd.providerType = providerType; if ( mProviders.contains( toAdd ) ) return; @@ -196,10 +197,12 @@ void PositionProvidersModel::setAppSettings( AppSettings *as ) } PositionProvider provider; + const QString providerType = providerData[2].isNull() ? QStringLiteral( "external_bt" ) : QStringLiteral( "external_ip" ); + const QString deviceDesc = providerType == QStringLiteral( "external_bt" ) ? tr( " Bluetooth device" ) : tr( " Network device" ); provider.name = providerData[0].toString(); provider.providerId = providerData[1].toString(); - provider.description = provider.providerId + " " + tr( "Bluetooth device" ); - provider.providerType = "external"; + provider.description = provider.providerId + deviceDesc; + provider.providerType = providerType; mProviders.append( provider ); } @@ -218,8 +221,8 @@ QVariantList PositionProvidersModel::toVariantList() const if ( mProviders[i].providerType == QStringLiteral( "internal" ) ) continue; - QStringList a = { mProviders[i].name, mProviders[i].providerId }; - out.push_back( a ); + QStringList provider = { mProviders[i].name, mProviders[i].providerId, mProviders[i].providerType }; + out.push_back( provider ); } return out; diff --git a/app/position/providers/positionprovidersmodel.h b/app/position/providers/positionprovidersmodel.h index dc24171fe..a05589504 100644 --- a/app/position/providers/positionprovidersmodel.h +++ b/app/position/providers/positionprovidersmodel.h @@ -67,7 +67,7 @@ class PositionProvidersModel : public QAbstractListModel QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override; Q_INVOKABLE void removeProvider( const QString &providerId ); - Q_INVOKABLE void addProvider( const QString &providerName, const QString &providerId ); + Q_INVOKABLE void addProvider( const QString &providerName, const QString &providerId, const QString &providerType ); AppSettings *appSettings() const; void setAppSettings( AppSettings * ); diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index d9662b019..a2697371d 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -119,9 +119,11 @@ set(MM_QML form/editors/MMFormValueMapEditor.qml form/editors/MMFormValueRelationEditor.qml gps/MMAddPositionProviderDrawer.qml - gps/MMBluetoothConnectionDrawer.qml + gps/MMExternalProviderConnectionDrawer.qml gps/MMGpsDataDrawer.qml + gps/MMNetworkProviderDrawer.qml gps/MMPositionProviderPage.qml + gps/MMProviderTypeDrawer.qml gps/MMStakeoutDrawer.qml gps/MMMeasureDrawer.qml gps/MMSelectionDrawer.qml diff --git a/app/qml/account/components/MMIconCheckBoxHorizontal.qml b/app/qml/account/components/MMIconCheckBoxHorizontal.qml index cd3a02452..f9327bb18 100644 --- a/app/qml/account/components/MMIconCheckBoxHorizontal.qml +++ b/app/qml/account/components/MMIconCheckBoxHorizontal.qml @@ -17,21 +17,25 @@ CheckBox { id: control property string sourceIcon: "" + property string description: "" + property bool showBorder: false property bool small: false - height: (control.small ? 50 : 80) * __dp + height: (description !== "" ? 96 : (control.small ? 50 : 80)) * __dp + + leftPadding: (description !== "" ? iconBgRectangle.x : 0) + iconBgRectangle.width + 30 * __dp + rightPadding: 20 * __dp indicator: Rectangle { id: iconBgRectangle - width: (control.small ? 24 : 40) * __dp - height: (control.small ? 24 : 40) * __dp + width: (control.small ? 24 : (control.description !== "" ? 50 : 40)) * __dp + height: (control.small ? 24 : (control.description !== "" ? 50 : 40)) * __dp x: 20 * __dp y: control.height / 2 - height / 2 radius: width / 2 color: control.checked ? __style.polarColor : __style.lightGreenColor MMIcon { - id: icon size: control.small ? __style.icon16 : __style.icon24 anchors.centerIn: parent source: control.sourceIcon @@ -39,18 +43,49 @@ CheckBox { } } - contentItem: Text { - text: control.text - font: __style.t3 - color: control.checked ? __style.polarColor : __style.nightColor - elide: Text.ElideRight - verticalAlignment: Text.AlignVCenter - leftPadding: control.indicator.width + 30 * __dp - rightPadding: 20 * __dp + contentItem: Item { + implicitWidth: titleText.implicitWidth + + Text { + id: titleText + visible: control.description === "" + width: parent.width + height: parent.height + text: control.text + font: __style.t3 + color: control.checked ? __style.polarColor : __style.nightColor + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + Column { + id: textColumn + visible: control.description !== "" + anchors.verticalCenter: parent.verticalCenter + width: parent.width + spacing: 10 * __dp + + Text { + width: parent.width + text: control.text + font: __style.t3 + color: control.checked ? __style.polarColor : __style.nightColor + elide: Text.ElideRight + } + + Text { + width: parent.width + text: control.description + font: __style.p6 + color: control.checked ? __style.polarColor : __style.nightColor + elide: Text.ElideRight + } + } } background: Rectangle { radius: __style.radius12 - color: control.checked ? __style.forestColor: __style.polarColor + color: control.checked ? __style.forestColor : __style.polarColor + border.color: showBorder ? ( control.checked ? __style.transparentColor : __style.mediumGreenColor ) : __style.transparentColor } } diff --git a/app/qml/gps/MMAddPositionProviderDrawer.qml b/app/qml/gps/MMAddPositionProviderDrawer.qml index ab4771bcf..5531a52ca 100644 --- a/app/qml/gps/MMAddPositionProviderDrawer.qml +++ b/app/qml/gps/MMAddPositionProviderDrawer.qml @@ -103,16 +103,30 @@ MMComponents.MMListDrawer { Column { width: ListView.view.width - spacing: 0 + spacing: __style.spacing16 MMComponents.MMListSpacer { height: __style.margin40 } + Image { + anchors.horizontalCenter: parent.horizontalCenter + + source: __style.mmSymbolImage + + width: 32 * __dp + height: 32 * __dp + + fillMode: Image.PreserveAspectFit + } + MMComponents.MMText { width: parent.width - text: qsTr( "Looking for devices" ) + "..." + text: qsTr( "Looking for more devices" ) + "..." font: __style.t3 + color: __style.forestColor + + horizontalAlignment: Text.AlignHCenter } } } diff --git a/app/qml/gps/MMBluetoothConnectionDrawer.qml b/app/qml/gps/MMExternalProviderConnectionDrawer.qml similarity index 57% rename from app/qml/gps/MMBluetoothConnectionDrawer.qml rename to app/qml/gps/MMExternalProviderConnectionDrawer.qml index 64d5c9af4..f3f0b9041 100644 --- a/app/qml/gps/MMBluetoothConnectionDrawer.qml +++ b/app/qml/gps/MMExternalProviderConnectionDrawer.qml @@ -19,24 +19,49 @@ import "../components" as MMComponents MMComponents.MMDrawer { id: root + property string providerType: "" property var positionProvider: PositionKit.positionProvider property string howToConnectGPSLink: __inputHelp.howToConnectGPSLink property string titleText: { - if ( rootstate.state === "working" ) - { - if ( !root.positionProvider ) return "" - if ( root.positionProvider.name() ) return qsTr( "Connecting to" ) + " " + root.positionProvider.name() + connectingSuffixAnimation - return qsTr( "Connecting" ) + connectingSuffixAnimation - } - else if ( rootstate.state === "success" ) + if ( root.providerType === "network" ) { - return qsTr( "Connected" ) + if ( rootstate.state === "working" ) + { + if ( !root.positionProvider ) return "" + if ( root.positionProvider.getIpAddress() ) return qsTr( "Connecting to" ) + " " + root.positionProvider.getIpAddress() + connectingSuffixAnimation + return qsTr( "Connecting" ) + connectingSuffixAnimation + } + else if ( rootstate.state === "success" ) + { + return qsTr( "Connected" ) + } + else if ( rootstate.state === "fail" ) + { + return qsTr( "Failed to connect to" ) + " " + ( root.positionProvider ? root.positionProvider.getIpAddress() : "" ) + } + else return qsTr( "We were not able to connect to the specified IP address. Please try again later." ) } - else + else if ( root.providerType === "bluetooth" ) { - // either NoConnection or WaitingToReconnect - return qsTr( "Failed to connect to" ) + " " + ( root.positionProvider ? root.positionProvider.name() : "" ) + if ( rootstate.state === "working" ) + { + if ( !root.positionProvider ) return "" + if ( root.positionProvider.name() ) return qsTr( "Connecting to" ) + " " + root.positionProvider.name() + connectingSuffixAnimation + return qsTr( "Connecting" ) + connectingSuffixAnimation + } + else if ( rootstate.state === "success" ) + { + return qsTr( "Connected" ) + } + else if ( rootstate.state === "fail" ) + { + return qsTr( "Failed to connect to" ) + " " + ( root.positionProvider ? root.positionProvider.name() : "" ) + } + else + { + if ( root.providerType === "bluetooth" ) return qsTr( "We were not able to connect to the specified device. Please make sure your device is powered on and can be connected to." ) + } } } @@ -45,7 +70,10 @@ MMComponents.MMDrawer { property string descriptionText: { if ( rootstate.state === "working" ) { - return qsTr( "You might be asked to pair your device during this process." ) + if ( root.providerType === "bluetooth" ) + return qsTr( "You might be asked to pair your device during this process." ) + else if ( root.providerType === "network" ) + return qsTr( "This might take a while..." ) } else if ( rootstate.state === "success" ) { @@ -59,7 +87,10 @@ MMComponents.MMDrawer { else { - return qsTr( "We were not able to connect to the specified device. Please make sure your device is powered on and can be connected to." ) + if ( root.providerType === "bluetooth" ) + return qsTr( "We were not able to connect to the specified device. Please make sure your device is powered on and can be connected to." ) + else if ( root.providerType === "network" ) + return qsTr( "We were not able to connect to the specified IP address. Please try again later." ) } } @@ -68,8 +99,12 @@ MMComponents.MMDrawer { { return __style.externalGpsRedImage } - else { - return __style.externalGpsGreenImage + else + { + if ( root.providerType === "bluetooth" ) + return __style.externalBluetoothGreenImage + else if ( root.providerType === "network" ) + return __style.externalNetworkGreenImage } } diff --git a/app/qml/gps/MMGpsDataDrawer.qml b/app/qml/gps/MMGpsDataDrawer.qml index 1b018bbe3..ad4456e8c 100644 --- a/app/qml/gps/MMGpsDataDrawer.qml +++ b/app/qml/gps/MMGpsDataDrawer.qml @@ -73,7 +73,7 @@ MMComponents.MMDrawer { width: parent.width / 2 - visible: PositionKit.positionProvider && PositionKit.positionProvider.type() === "external" + visible: __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") title: qsTr( "Status" ) value: PositionKit.positionProvider ? PositionKit.positionProvider.stateMessage : "" @@ -214,7 +214,7 @@ MMComponents.MMDrawer { PositionKit.fix } - visible: PositionKit.positionProvider && PositionKit.positionProvider.type() === "external" + visible: __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") alignmentRight: Positioner.index % 2 === 1 } @@ -247,7 +247,7 @@ MMComponents.MMDrawer { __inputUtils.formatNumber( PositionKit.hdop, 2 ) } - visible: PositionKit.positionProvider && PositionKit.positionProvider.type() === "external" + visible: __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") alignmentRight: Positioner.index % 2 === 1 } @@ -264,7 +264,7 @@ MMComponents.MMDrawer { __inputUtils.formatNumber( PositionKit.vdop, 2 ) } - visible: PositionKit.positionProvider && PositionKit.positionProvider.type() === "external" + visible: __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") alignmentRight: Positioner.index % 2 === 1 } @@ -281,7 +281,7 @@ MMComponents.MMDrawer { __inputUtils.formatNumber( PositionKit.pdop, 2 ) } - visible: PositionKit.positionProvider && PositionKit.positionProvider.type() === "external" + visible: __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") alignmentRight: Positioner.index % 2 === 1 } @@ -329,6 +329,7 @@ MMComponents.MMDrawer { } __inputUtils.formatNumber( PositionKit.geoidSeparation, 2 ) + " m" } + visible: __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") alignmentRight: Positioner.index % 2 === 1 } diff --git a/app/qml/gps/MMNetworkProviderDrawer.qml b/app/qml/gps/MMNetworkProviderDrawer.qml new file mode 100644 index 000000000..d5632fdec --- /dev/null +++ b/app/qml/gps/MMNetworkProviderDrawer.qml @@ -0,0 +1,119 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../components" as MMComponents +import "../inputs" as MMInputs + +MMComponents.MMDrawer { + id: root + + signal confirmed( string alias, string deviceAddress ) + + drawerHeader.title: qsTr( "Network connection" ) + drawerHeader.titleFont: __style.t2 + + drawerContent: Column { + width: parent.width + spacing: __style.spacing20 + + MMComponents.MMText { + width: parent.width + + text: qsTr( "External receivers can be connected via network in iOS devices. Some of the known devices that support network connection (TCP or UDP) are EOS and Emlid." ) + + font: __style.p5 + color: __style.nightColor + + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignJustify + } + + MMInputs.MMTextInput { + id: ipAddressInput + + width: parent.width + textFieldBackground.color: __style.lightGreenColor + + title: qsTr( "IP address" ) + placeholderText: qsTr( "localhost" ) + + onTextEdited: errorMsg = "" + } + + MMInputs.MMTextInput { + id: portInput + + width: parent.width + textFieldBackground.color: __style.lightGreenColor + + title: qsTr( "Port" ) + placeholderText: qsTr( "1234" ) + + textField.inputMethodHints: Qt.ImhDigitsOnly + + onTextEdited: errorMsg = "" + } + + MMInputs.MMTextInput { + id: aliasInput + + width: parent.width + textFieldBackground.color: __style.lightGreenColor + + title: qsTr( "Receiver nickname (optional)" ) + placeholderText: qsTr( "Green device" ) + } + + MMComponents.MMButton { + width: parent.width + + text: qsTr( "Confirm" ) + + onClicked: { + const ip = ipAddressInput.text.trim() + const port = portInput.text.trim() + + const ipv4Regex = /^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/ + const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/ + + if ( ip === "" ) { + ipAddressInput.errorMsg = qsTr( "IP address is required" ) + } + else if ( !ipv4Regex.test( ip ) && !hostnameRegex.test( ip ) ) { + ipAddressInput.errorMsg = qsTr( "Enter a valid IP address or hostname" ) + } + else { + ipAddressInput.errorMsg = "" + } + + const portNum = parseInt( port ) + if ( port === "" ) { + portInput.errorMsg = qsTr( "Port is required" ) + } + else if ( !/^\d+$/.test( port ) || portNum < 1 || portNum > 65535 ) { + portInput.errorMsg = qsTr( "Enter a valid port (1–65535)" ) + } + else { + portInput.errorMsg = "" + } + + if ( ipAddressInput.errorMsg !== "" || portInput.errorMsg !== "" ) { + return + } + + const deviceAddress = ip + ":" + port + + root.confirmed( aliasInput.text, deviceAddress ) + root.close() + } + } + } +} diff --git a/app/qml/gps/MMPositionProviderPage.qml b/app/qml/gps/MMPositionProviderPage.qml index c98d9a0c8..471e48ba6 100644 --- a/app/qml/gps/MMPositionProviderPage.qml +++ b/app/qml/gps/MMPositionProviderPage.qml @@ -8,8 +8,6 @@ ***************************************************************************/ import QtQuick -import QtQuick.Controls -import QtQuick.Dialogs import mm 1.0 as MM import MMInput @@ -33,8 +31,6 @@ MMComponents.MMPage { property bool showTopTitle: visibleArea.yPosition * height > ( headerItem.contentHeight / 2 ) - visible: __haveBluetooth - width: parent.width height: parent.height @@ -94,7 +90,7 @@ MMComponents.MMPage { if ( ListView.section === "internal" ) { let ix = providersModel.index( index + 1, 0 ) let type = providersModel.data( ix, MM.PositionProvidersModel.ProviderType ) - if ( type === "external" ) return false + if ( type.includes( "external" ) ) return false } return true @@ -135,10 +131,36 @@ MMComponents.MMPage { text: qsTr( "Connect new receiver" ) - onClicked: bluetoothDiscoveryLoader.active = true + onClicked: { + if ( __haveBluetooth ) { + providerTypeDrawer.open() + } + else { + networkProviderDrawer.open() + } + } + } + + MMProviderTypeDrawer { + id: providerTypeDrawer + + onBluetoothSelected: bluetoothDiscoveryLoader.active = true + onNetworkSelected: networkProviderDrawer.open() + } + + MMNetworkProviderDrawer { + id: networkProviderDrawer + + onConfirmed: function( alias, deviceAddress ) { + PositionKit.positionProvider = PositionKit.constructProvider( "external_ip", deviceAddress, alias ) + providersModel.addProvider( alias, deviceAddress, "external_ip" ) + connectingDialogLoaderNetwork.open() + } } MMComponents.MMMessage { + id: infoMessage + visible: !listview.visible width: parent.width anchors.centerIn: parent @@ -146,10 +168,10 @@ MMComponents.MMPage { image: __style.externalGpsRedImage title: qsTr( "Connecting to external receivers via bluetooth is not supported" ) description: qsTr( "This function is not available on iOS. " + - "Your hardware vendor may provide a custom " + - "app that connects to the receiver and sets position. " + - "The app will still think it is the internal GPS of " + - "your phone/tablet." ) + "Your hardware vendor may provide a custom " + + "app that connects to the receiver and sets position. " + + "The app will still think it is the internal GPS of " + + "your phone/tablet." ) link: __inputHelp.howToConnectGPSLink } @@ -162,7 +184,7 @@ MMComponents.MMPage { } onRemoveProvider: { - if (removeDialog.providerId === "") { + if ( removeDialog.providerId === "" ) { close() return } @@ -183,7 +205,23 @@ MMComponents.MMPage { id: bluetoothDiscoveryLoader active: false - sourceComponent: bluetoothDiscoveryDrawerComponent + source: Qt.resolvedUrl( "MMAddPositionProviderDrawer.qml" ) + + onLoaded: item.open() + } + + Connections { + target: bluetoothDiscoveryLoader.item + + function onInitiatedConnectionTo( deviceAddress, deviceName ) { + PositionKit.positionProvider = PositionKit.constructProvider( "external_bt", deviceAddress, deviceName ) + providersModel.addProvider( deviceName, deviceAddress, "external_bt" ) + bluetoothDiscoveryLoader.item.list.model.discovering = false + bluetoothDiscoveryLoader.item.close() + connectingDialogLoader.open() + } + + function onClosed() { bluetoothDiscoveryLoader.active = false } } Loader { @@ -191,44 +229,49 @@ MMComponents.MMPage { active: false asynchronous: true - sourceComponent: connectionToSavedProviderDialogComponent + source: Qt.resolvedUrl( "MMExternalProviderConnectionDrawer.qml" ) + + onLoaded: { + item.providerType = "bluetooth" + item.open() + } function open() { active = true focus = true } } - } - Component { - id: bluetoothDiscoveryDrawerComponent + Connections { + target: connectingDialogLoader.item - MMAddPositionProviderDrawer { - onInitiatedConnectionTo: function ( deviceAddress, deviceName ) { - PositionKit.positionProvider = PositionKit.constructProvider( "external", deviceAddress, deviceName ) + function onClosed() { connectingDialogLoader.active = false } + function onFailure() { PositionKit.positionProvider = PositionKit.constructProvider( "internal", "devicegps", "" ) } + } - providersModel.addProvider( deviceName, deviceAddress ) - list.model.discovering = false - close() + Loader { + id: connectingDialogLoaderNetwork - connectingDialogLoader.open() + active: false + asynchronous: true + source: Qt.resolvedUrl( "MMExternalProviderConnectionDrawer.qml" ) + + onLoaded: { + item.providerType = "network" + item.open() } - onClosed: bluetoothDiscoveryLoader.active = false - Component.onCompleted: open() + function open() { + active = true + focus = true + } } - } - - Component { - id: connectionToSavedProviderDialogComponent - MMBluetoothConnectionDrawer { - onClosed: connectingDialogLoader.active = false + Connections { + target: connectingDialogLoaderNetwork.item - // revert position provider back to internal provider - onFailure: PositionKit.positionProvider = PositionKit.constructProvider( "internal", "devicegps", "" ) - - Component.onCompleted: open() + function onClosed() { connectingDialogLoaderNetwork.active = false } + function onFailure() { PositionKit.positionProvider = PositionKit.constructProvider( "internal", "devicegps", "" ) } } } @@ -239,7 +282,7 @@ MMComponents.MMPage { } function constructProvider( type, id, name ) { - if ( type === "external" ) { + if ( type === "external_bt" ) { // Is bluetooth turned on? if ( !__inputUtils.isBluetoothTurnedOn() ) { __inputUtils.turnBluetoothOn() @@ -253,8 +296,11 @@ MMComponents.MMPage { PositionKit.positionProvider = PositionKit.constructProvider( type, id, name ) - if ( type === "external" ) { + if ( type === "external_bt" ) { connectingDialogLoader.open() } + else if ( type === "external_ip" ) { + connectingDialogLoaderNetwork.open() + } } } diff --git a/app/qml/gps/MMProviderTypeDrawer.qml b/app/qml/gps/MMProviderTypeDrawer.qml new file mode 100644 index 000000000..af281397e --- /dev/null +++ b/app/qml/gps/MMProviderTypeDrawer.qml @@ -0,0 +1,102 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../components" as MMComponents +import "../account/components" as MMAccountComponents + +MMComponents.MMListDrawer { + id: root + + property string selectedProviderType: "" + + signal bluetoothSelected() + signal networkSelected() + + drawerHeader.title: qsTr( "Connect new receiver" ) + drawerHeader.titleFont: __style.t2 + + onOpened: root.selectedProviderType = "" + + list.model: ListModel { + id: providerTypeModel + + Component.onCompleted: { + providerTypeModel.append( [ + { name: qsTr( "Bluetooth" ), description: qsTr( "Connect via Bluetooth" ), type: "bluetooth", icon: __style.bluetoothIcon }, + { name: qsTr( "Network" ), description: qsTr( "Connect via IP address" ), type: "network", icon: __style.networkIcon } + ] ) + } + } + + list.header: MMComponents.MMText { + width: ListView.view.width + + text: qsTr( "This function is not available on iOS. Your hardware vendor may provide a custom app that connects to the receiver and sets position. Mergin Maps will still think it is the internal GPS of your phone/tablet." ) + + font: __style.p5 + color: __style.nightColor + + wrapMode: Text.Wrap + bottomPadding: __style.margin16 + } + + list.delegate: Item { + width: ListView.view.width + height: checkbox.height + __style.margin12 + + MMAccountComponents.MMIconCheckBoxHorizontal { + id: checkbox + + width: parent.width + showBorder: true + sourceIcon: model.icon + text: model.name + description: model.description + checked: root.selectedProviderType === model.type + + onClicked: { + if ( root.selectedProviderType === model.type ) { + root.selectedProviderType = "" + } + else { + root.selectedProviderType = model.type + } + } + } + } + + list.footer: Item { + width: ListView.view.width + height: continueButton.height + __style.margin20 + + MMComponents.MMButton { + id: continueButton + + width: parent.width + anchors.top: parent.top + anchors.topMargin: __style.margin8 + + text: qsTr( "Continue" ) + enabled: root.selectedProviderType !== "" + + onClicked: { + root.close() + + if ( root.selectedProviderType === "bluetooth" ) { + root.bluetoothSelected() + } + else if ( root.selectedProviderType === "network" ) { + root.networkSelected() + } + } + } + } +} diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index ae892281d..6766d3f32 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -679,7 +679,7 @@ Item { visible: { if ( root.mapExtentOffset > 0 && root.state !== "stakeout" ) return false - if ( PositionKit.positionProvider && PositionKit.positionProvider.type() === "external" ) { + if ( __positionKit.positionProvider && __positionKit.positionProvider.type().includes("external") ) { // for external receivers we want to show gps panel and accuracy button // even when the GPS receiver is not sending position data return true @@ -699,7 +699,7 @@ Item { { return "" } - else if ( PositionKit.positionProvider.type() === "external" ) + else if ( __positionKit.positionProvider.type().includes("external") ) { if ( PositionKit.positionProvider.state === MM.PositionProvider.Connecting ) { diff --git a/app/test/testposition.cpp b/app/test/testposition.cpp index 6c0bdba26..846c4c227 100644 --- a/app/test/testposition.cpp +++ b/app/test/testposition.cpp @@ -106,8 +106,8 @@ void TestPosition::testBluetoothProviderConnection() QCOMPARE( "testBluetoothProvider", pkProvider->name() ); QCOMPARE( "AA:AA:AA:AA:00:00", btProvider->id() ); QCOMPARE( "AA:AA:AA:AA:00:00", pkProvider->id() ); - QCOMPARE( "external", btProvider->type() ); - QCOMPARE( "external", pkProvider->type() ); + QCOMPARE( "external_bt", btProvider->type() ); + QCOMPARE( "external_bt", pkProvider->type() ); // // let's continue with BT instance, @@ -257,11 +257,11 @@ void TestPosition::testPositionProviderKeysInSettings() rawSettings.remove( AppSettings::POSITION_PROVIDERS_GROUP ); // make sure nothing is there from previous tests #ifdef HAVE_BLUETOOTH - positionKit->setPositionProvider( positionKit->constructProvider( "external", "AA:BB:CC:DD:EE:FF", "testProviderA" ) ); + positionKit->setPositionProvider( positionKit->constructProvider( "external_bt", "AA:BB:CC:DD:EE:FF", "testProviderA" ) ); QCOMPARE( positionKit->positionProvider()->id(), "AA:BB:CC:DD:EE:FF" ); QCOMPARE( positionKit->positionProvider()->name(), "testProviderA" ); - QCOMPARE( positionKit->positionProvider()->type(), "external" ); + QCOMPARE( positionKit->positionProvider()->type(), "external_bt" ); QCOMPARE( rawSettings.value( CoreUtils::QSETTINGS_APP_GROUP_NAME + "/activePositionProviderId" ).toString(), "AA:BB:CC:DD:EE:FF" ); #endif @@ -279,20 +279,28 @@ void TestPosition::testPositionProviderKeysInSettings() QCOMPARE( providersModel.data( providersModel.index( 1 ), PositionProvidersModel::ProviderId ), "simulated" ); providersModel.setAppSettings( &appSettings ); - providersModel.addProvider( "testProviderB", "AA:00:11:22:23:44" ); + providersModel.addProvider( "testProviderB", "AA:00:11:22:23:44", "external_bt" ); + providersModel.addProvider( "testProviderC", "localhost:9000", "external_ip" ); - // app settings should have one saved provider - testProviderB + // app settings should have two saved providers - testProviderB & testProviderC QVariantList providers = appSettings.savedPositionProviders(); - QCOMPARE( providers.count(), 1 ); // we have one (external) provider - QCOMPARE( providers.at( 0 ).toList().count(), 2 ); // the provider has two properties + QCOMPARE( providers.count(), 2 ); // we have two (external) providers + QCOMPARE( providers.at( 0 ).toList().count(), 3 ); // the provider has two properties QVariantList providerData = providers.at( 0 ).toList(); QCOMPARE( providerData.at( 0 ).toString(), "testProviderB" ); QCOMPARE( providerData.at( 1 ).toString(), "AA:00:11:22:23:44" ); + QCOMPARE( providerData.at( 2 ).toString(), "external_bt" ); + + providerData = providers.at( 1 ).toList(); + QCOMPARE( providerData.at( 0 ).toString(), "testProviderC" ); + QCOMPARE( providerData.at( 1 ).toString(), "localhost:9000" ); + QCOMPARE( providerData.at( 2 ).toString(), "external_ip" ); // remove that provider providersModel.removeProvider( "AA:00:11:22:23:44" ); + providersModel.removeProvider( "localhost:9000" ); providers = appSettings.savedPositionProviders(); @@ -762,6 +770,73 @@ void TestPosition::testPositionTransformerInternalDesktopPosition() QVERIFY( qgsDoubleNear( newPosition.elevation, 171.3 ) ); } +void TestPosition::testPositionTransformerNetworkPosition() +{ +// prepare position transformers + // WGS84 + ellipsoid + QgsCoordinateReferenceSystem ellipsoidHeightCrs = QgsCoordinateReferenceSystem::fromEpsgId( 4979 ); + // WGS84 + EGM96 + QgsCoordinateReferenceSystem geoidHeightCrs = QgsCoordinateReferenceSystem::fromEpsgId( 9707 ); + PositionTransformer passThroughTransformer( ellipsoidHeightCrs, geoidHeightCrs, true, QgsCoordinateTransformContext() ); + PositionTransformer positionTransformer( ellipsoidHeightCrs, geoidHeightCrs, false, QgsCoordinateTransformContext() ); + +#ifdef HAVE_BLUETOOTH + // mini file contains only minimal info like position and date + QString miniNmeaPositionFilePath = TestUtils::testDataDir() + "/position/nmea_petrzalka_mini.txt"; + QFile miniNmeaFile( miniNmeaPositionFilePath ); + miniNmeaFile.open( QFile::ReadOnly ); + + QVERIFY( miniNmeaFile.isOpen() ); + + NmeaParser parser; + QgsGpsInformation position = parser.parseNmeaString( miniNmeaFile.readAll() ); + GeoPosition geoPosition = GeoPosition::fromQgsGpsInformation( position ); + + QVERIFY( qgsDoubleNear( geoPosition.latitude, 48.10305 ) ); + QVERIFY( qgsDoubleNear( geoPosition.longitude, 17.1064 ) ); + QCOMPARE( geoPosition.elevation, 171.3 ); + QCOMPARE( geoPosition.elevation_diff, std::numeric_limits::quiet_NaN() ); +#else + GeoPosition geoPosition; + geoPosition.latitude = 48.10305; + geoPosition.longitude = 17.1064; + geoPosition.elevation = 171.3; +#endif + + // transform with pass through disabled and missing elevation separation + GeoPosition newPosition = positionTransformer.processNetworkPosition( geoPosition ); + + QVERIFY( qgsDoubleNear( newPosition.latitude, 48.10305 ) ); + QVERIFY( qgsDoubleNear( newPosition.longitude, 17.1064 ) ); + QCOMPARE( newPosition.elevation, 171.3 ); + QCOMPARE( newPosition.elevation_diff, std::numeric_limits::quiet_NaN() ); + + // transform with pass through enabled and missing elevation separation + newPosition = passThroughTransformer.processNetworkPosition( geoPosition ); + + QVERIFY( qgsDoubleNear( newPosition.latitude, 48.10305 ) ); + QVERIFY( qgsDoubleNear( newPosition.longitude, 17.1064 ) ); + QCOMPARE( newPosition.elevation, 171.3 ); + QCOMPARE( newPosition.elevation_diff, std::numeric_limits::quiet_NaN() ); + + // transform with pass through enabled and elevation separation + geoPosition.elevation_diff = 40; + newPosition = passThroughTransformer.processNetworkPosition( geoPosition ); + + QVERIFY( qgsDoubleNear( newPosition.latitude, 48.10305 ) ); + QVERIFY( qgsDoubleNear( newPosition.longitude, 17.1064 ) ); + QCOMPARE( newPosition.elevation, 171.3 ); + QCOMPARE( newPosition.elevation_diff, 40 ); + + // transform with pass through disabled and elevation separation + newPosition = positionTransformer.processNetworkPosition( geoPosition ); + + QVERIFY( qgsDoubleNear( newPosition.latitude, 48.10305 ) ); + QVERIFY( qgsDoubleNear( newPosition.longitude, 17.1064 ) ); + QVERIFY( qgsDoubleNear( newPosition.elevation, 167.53574931171875 ) ); + QVERIFY( qgsDoubleNear( newPosition.elevation_diff, 43.764250688281265 ) ); +} + void TestPosition::testPositionTransformerSimulatedPosition() { // prepare position transformers diff --git a/app/test/testposition.h b/app/test/testposition.h index 3636b89c3..40ffd45c6 100644 --- a/app/test/testposition.h +++ b/app/test/testposition.h @@ -44,6 +44,7 @@ class TestPosition: public QObject void testPositionTransformerInternalAndroidPosition(); void testPositionTransformerInternalIosPosition(); void testPositionTransformerInternalDesktopPosition(); + void testPositionTransformerNetworkPosition(); void testPositionTransformerSimulatedPosition(); private: diff --git a/app/test/testvariablesmanager.cpp b/app/test/testvariablesmanager.cpp index e7bda0498..cd191c431 100644 --- a/app/test/testvariablesmanager.cpp +++ b/app/test/testvariablesmanager.cpp @@ -84,7 +84,7 @@ void TestVariablesManager::testPositionVariables() evaluateExpression( QStringLiteral( "@position_gps_antenna_height" ), QStringLiteral( "0.000" ), &context ); evaluateExpression( QStringLiteral( "@position_provider_address" ), QStringLiteral( "AA:AA:FF:AA:00:10" ), &context ); evaluateExpression( QStringLiteral( "@position_provider_name" ), QStringLiteral( "testBluetoothProvider" ), &context ); - evaluateExpression( QStringLiteral( "@position_provider_type" ), QStringLiteral( "external" ), &context ); + evaluateExpression( QStringLiteral( "@position_provider_type" ), QStringLiteral( "external_bt" ), &context ); mAppSettings->setGpsAntennaHeight( 1.6784 ); pos.verticalSpeed = 1.345; diff --git a/gallery/positionkit.h b/gallery/positionkit.h index 059a3e737..bab5f6dfc 100644 --- a/gallery/positionkit.h +++ b/gallery/positionkit.h @@ -61,7 +61,7 @@ class PositionKit : public QObject private: QString pProviderName = "Gps Source is ok!"; - QString pProviderType = "external"; + QString pProviderType = "external_bt"; QString pProviderMessage = "Connected"; QString pStateMessage = "Message"; QString pLastRead = "17:19:08 CEST"; diff --git a/gallery/qml.qrc b/gallery/qml.qrc index 9d82ad2d8..a8a055cbe 100644 --- a/gallery/qml.qrc +++ b/gallery/qml.qrc @@ -140,7 +140,7 @@ ../app/qml/settings/components/MMSettingsInput.qml ../app/qml/settings/components/MMSettingsDropdown.qml ../app/qml/settings/components/MMSettingsSwitch.qml - ../app/qml/gps/MMBluetoothConnectionDrawer.qml + ../app/qml/gps/MMExternalProviderConnectionDrawer.qml ../app/qml/gps/MMGpsDataDrawer.qml ../app/qml/gps/components/MMGpsDataText.qml ../app/qml/dialogs/MMSyncFailedDialog.qml diff --git a/gallery/qml/pages/ChecksPage.qml b/gallery/qml/pages/ChecksPage.qml index 9e097a4b1..11afacbb4 100644 --- a/gallery/qml/pages/ChecksPage.qml +++ b/gallery/qml/pages/ChecksPage.qml @@ -90,9 +90,11 @@ Column { MMAccountComponents.MMIconCheckBoxHorizontal { checked: true + width: 300 * __dp sourceIcon: __style.redditIcon text: "Reddit" - small: true + description: "This is a small description to check the functionality of this component" + small: false } } } diff --git a/gallery/qml/pages/DrawerPage.qml b/gallery/qml/pages/DrawerPage.qml index e3400a423..47a0aab1f 100644 --- a/gallery/qml/pages/DrawerPage.qml +++ b/gallery/qml/pages/DrawerPage.qml @@ -59,7 +59,7 @@ Page { } Button { - text: "MMBluetoothConnectionDrawer" + text: "MMExternalProviderConnectionDrawer" onClicked: { bluetoothConnectionDrawer.positionProvider.state = PositionProvider.Connecting bluetoothConnectionTimer.start() @@ -197,8 +197,9 @@ Page { } } - MMBluetoothConnectionDrawer { + MMExternalProviderConnectionDrawer { id: bluetoothConnectionDrawer + providerType: "bluetooth" howToConnectGPSLink: "www.merginmaps.com" positionProvider: QtObject { diff --git a/gallery/qml/pages/ImagesPage.qml b/gallery/qml/pages/ImagesPage.qml index ed931d11b..59bd68133 100644 --- a/gallery/qml/pages/ImagesPage.qml +++ b/gallery/qml/pages/ImagesPage.qml @@ -38,7 +38,8 @@ ScrollView { Column { Image { source: __style.positionTrackingRunningImage } Text { text: "positionTrackingRunningImage" } } Column { Image { source: __style.noMapThemesImage } Text { text: "noMapThemesImage" } } Column { Image { source: __style.syncImage } Text { text: "syncImage" } } - Column { Image { source: __style.externalGpsGreenImage } Text { text: "externalGpsGreenImage" } } + Column { Image { source: __style.externalBluetoothGreenImage } Text { text: "externalBluetoothGreenImage" } } + Column { Image { source: __style.externalNetworkGreenImage } Text { text: "externalNetworkGreenImage" } } Column { Image { source: __style.externalGpsRedImage } Text { text: "externalGpsRedImage" } } Column { Image { source: __style.reachedDataLimitImage } Text { text: "reachedDataLimitImage" } } Column { Image { source: __style.positiveMMSymbolImage } Text { text: "positiveMMSymbolImage" } }