diff --git a/app/android/res/xml/file_paths.xml b/app/android/res/xml/file_paths.xml index 7829eff2e..b4950a8d7 100644 --- a/app/android/res/xml/file_paths.xml +++ b/app/android/res/xml/file_paths.xml @@ -1,4 +1,7 @@ + diff --git a/app/android/src/uk/co/lutraconsulting/InputActivity.java b/app/android/src/uk/co/lutraconsulting/InputActivity.java index fa5781037..ac6523204 100644 --- a/app/android/src/uk/co/lutraconsulting/InputActivity.java +++ b/app/android/src/uk/co/lutraconsulting/InputActivity.java @@ -28,6 +28,14 @@ import android.graphics.Insets; import android.graphics.Color; +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.content.ActivityNotFoundException; +import java.io.File; +import androidx.core.content.FileProvider; +import android.widget.Toast; + import androidx.core.view.WindowCompat; import androidx.core.splashscreen.SplashScreen; @@ -123,6 +131,43 @@ public void hideSplashScreen() keepSplashScreenVisible = false; } + public boolean openFile( String filePath ) { + File file = new File( filePath ); + + if ( !file.exists() ) + { + return false; + } + + Intent showFileIntent = new Intent( Intent.ACTION_VIEW ); + + try + { + Uri fileUri = FileProvider.getUriForFile( this, "uk.co.lutraconsulting.fileprovider", file ); + + showFileIntent.setData( fileUri ); + + // FLAG_GRANT_READ_URI_PERMISSION grants temporary read permission to the content URI. + // FLAG_ACTIVITY_NEW_TASK is used when starting an Activity from a non-Activity context. + showFileIntent.setFlags( Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION ); + } + catch ( IllegalArgumentException e ) + { + return false; + } + + if ( showFileIntent.resolveActivity( getPackageManager() ) != null ) + { + startActivity( showFileIntent ); + } + else + { + return false; + } + + return true; + } + public void quitGracefully() { String man = android.os.Build.MANUFACTURER.toUpperCase(); diff --git a/app/androidutils.cpp b/app/androidutils.cpp index 1869a38cd..bf352074c 100644 --- a/app/androidutils.cpp +++ b/app/androidutils.cpp @@ -227,6 +227,17 @@ void AndroidUtils::hideSplashScreen() #endif } +bool AndroidUtils::openFile( const QString &filePath ) +{ + bool result = false; +#ifdef ANDROID + auto activity = QJniObject( QNativeInterface::QAndroidApplication::context() ); + QJniObject jFilePath = QJniObject::fromString( filePath ); + result = activity.callMethod( "openFile", "(Ljava/lang/String;)Z", jFilePath.object() ); +#endif + return result; +} + bool AndroidUtils::requestStoragePermission() { #ifdef ANDROID diff --git a/app/androidutils.h b/app/androidutils.h index 8d7f6ec21..a0fe50126 100644 --- a/app/androidutils.h +++ b/app/androidutils.h @@ -69,6 +69,7 @@ class AndroidUtils: public QObject */ Q_INVOKABLE void callImagePicker( const QString &code = "" ); Q_INVOKABLE void callCamera( const QString &targetPath, const QString &code = "" ); + Q_INVOKABLE bool openFile( const QString &filePath ); #ifdef ANDROID const static int MEDIA_CODE = 101; diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 5a30f88d6..aad2eacbe 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -57,8 +57,10 @@ #include #include #include +#include #include -#include +#include +#include #include #include #include @@ -2175,3 +2177,40 @@ QString InputUtils::getDeviceModel() #endif return QStringLiteral( "N/A" ); } + +bool InputUtils::openLink( const QString &homePath, const QString &link ) +{ + if ( link.startsWith( LOCAL_FILE_PREFIX ) ) + { + QString relativePath = link.mid( QString( LOCAL_FILE_PREFIX ).length() ); + QString absoluteLinkPath = homePath + QDir::separator() + relativePath; + if ( !fileExists( absoluteLinkPath ) ) + { + return false; + } +#ifdef Q_OS_ANDROID + if ( !mAndroidUtils->openFile( absoluteLinkPath ) ) + { + return false; + } +#elif defined(Q_OS_IOS) + if ( ! IosUtils::openFile( absoluteLinkPath ) ) + { + return false; + } +#else + // Desktop environments + QUrl fileUrl = QUrl::fromLocalFile( absoluteLinkPath ); + if ( !QDesktopServices::openUrl( fileUrl ) ) + { + return false; + } +#endif + } + else + { + QDesktopServices::openUrl( QUrl( link ) ); + } + + return true; +} diff --git a/app/inpututils.h b/app/inpututils.h index f2606ab92..bc2c8deb0 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -174,6 +174,13 @@ class InputUtils: public QObject */ Q_INVOKABLE static QString bytesToHumanSize( double bytes ); + /** + * Opens the specified link in an appropriate application. For "project://" links, it converts them to + * absolute paths and opens with default file handlers. Other links are opened in the default web browser. + * @param link The link to open, either a "project://" link or a standard URL. + */ + Q_INVOKABLE bool openLink( const QString &homePath, const QString &link ); + Q_INVOKABLE bool acquireCameraPermission(); Q_INVOKABLE bool isBluetoothTurnedOn(); @@ -603,6 +610,8 @@ class InputUtils: public QObject static QUrl iconFromGeometry( const Qgis::GeometryType &geometry ); AndroidUtils *mAndroidUtils = nullptr; // not owned + + const QString LOCAL_FILE_PREFIX = QStringLiteral( "project://" ); }; #endif // INPUTUTILS_H diff --git a/app/ios/iosutils.cpp b/app/ios/iosutils.cpp index 37c3e107f..e261ac7c6 100644 --- a/app/ios/iosutils.cpp +++ b/app/ios/iosutils.cpp @@ -82,3 +82,12 @@ QString IosUtils::getDeviceModel() #endif return ""; } + +bool IosUtils::openFile( const QString &filePath ) +{ +#ifdef Q_OS_IOS + return openFileImpl( filePath ); +#else + return false; +#endif +} diff --git a/app/ios/iosutils.h b/app/ios/iosutils.h index b0386b943..64c911273 100644 --- a/app/ios/iosutils.h +++ b/app/ios/iosutils.h @@ -43,6 +43,8 @@ class IosUtils: public QObject Q_INVOKABLE QVector getSafeArea(); + Q_INVOKABLE static bool openFile( const QString &filePath ); + static Q_INVOKABLE QString getManufacturer(); static Q_INVOKABLE QString getDeviceModel(); @@ -64,9 +66,10 @@ class IosUtils: public QObject void setIdleTimerDisabled(); QVector getSafeAreaImpl(); + static QString getManufacturerImpl(); static QString getDeviceModelImpl(); - + static bool openFileImpl( const QString &filePath ); }; #endif // IOSUTILS_H diff --git a/app/ios/iosutils.mm b/app/ios/iosutils.mm index ea91001ae..28c0b7fb8 100644 --- a/app/ios/iosutils.mm +++ b/app/ios/iosutils.mm @@ -15,6 +15,9 @@ #include #include +#import +#import +#include #include "iosutils.h" void IosUtils::setIdleTimerDisabled() @@ -54,3 +57,38 @@ QString deviceModel = QString::fromUtf8( systemInfo.machine ); return deviceModel.toUpper(); } + +@interface FileOpener : UIViewController +@end + +@implementation FileOpener + +- ( UIViewController * )documentInteractionControllerViewControllerForPreview:( UIDocumentInteractionController * )ctrl +{ + return self; +} + +@end + +bool IosUtils::openFileImpl( const QString &filePath ) +{ + static FileOpener *viewer = nil; + NSURL *resourceURL = [NSURL fileURLWithPath:filePath.toNSString()]; + + UIDocumentInteractionController *interactionCtrl = [UIDocumentInteractionController interactionControllerWithURL:resourceURL]; + UIViewController *rootViewController = [[[[UIApplication sharedApplication] windows] firstObject] rootViewController]; + + viewer = [[FileOpener alloc] init]; + [rootViewController addChildViewController: viewer]; + interactionCtrl.delegate = ( id )viewer; + + if ( ![interactionCtrl presentPreviewAnimated:NO] ) + { + if ( ![interactionCtrl presentOptionsMenuFromRect:CGRectZero inView:viewer.view animated:NO] ) + { + return false; + } + } + + return true; +} diff --git a/app/qml/form/editors/MMFormRichTextViewer.qml b/app/qml/form/editors/MMFormRichTextViewer.qml index ec226d191..2b2c5ea3a 100644 --- a/app/qml/form/editors/MMFormRichTextViewer.qml +++ b/app/qml/form/editors/MMFormRichTextViewer.qml @@ -20,6 +20,7 @@ MMPrivateComponents.MMBaseInput { property bool _fieldShouldShowTitle: parent.fieldShouldShowTitle property string _fieldTitle: parent.fieldTitle + property string _fieldHomePath: parent.fieldHomePath title: _fieldShouldShowTitle ? _fieldTitle : "" @@ -47,8 +48,11 @@ MMPrivateComponents.MMBaseInput { leftPadding: __style.margin20 rightPadding: __style.margin20 - onLinkActivated: function( link ) { - Qt.openUrlExternally( link ) + onLinkActivated: function ( link ) { + if ( !__inputUtils.openLink( root._fieldHomePath, link.toString( ) ) ) + { + __notificationModel.addError( "Could not open the file. It may not exist, could be invalid, or there might be no application available to open it." ) + } } } } diff --git a/app/qml/form/editors/MMFormTextMultilineEditor.qml b/app/qml/form/editors/MMFormTextMultilineEditor.qml index 985fdf9ec..04942d380 100644 --- a/app/qml/form/editors/MMFormTextMultilineEditor.qml +++ b/app/qml/form/editors/MMFormTextMultilineEditor.qml @@ -36,6 +36,7 @@ MMPrivateComponents.MMBaseInput { property string _fieldTitle: parent.fieldTitle property string _fieldErrorMessage: parent.fieldErrorMessage property string _fieldWarningMessage: parent.fieldWarningMessage + property string _fieldHomePath: parent.fieldHomePath property bool _fieldRememberValueSupported: parent.fieldRememberValueSupported property bool _fieldRememberValueState: parent.fieldRememberValueState @@ -117,7 +118,13 @@ MMPrivateComponents.MMBaseInput { radius: __style.radius12 } - onLinkActivated: ( link ) => Qt.openUrlExternally( link ) + onLinkActivated: function ( link ) { + if ( !__inputUtils.openLink( root._fieldHomePath, link.toString( ) ) ) + { + __notificationModel.addError( "Could not open the file. It may not exist, could be invalid, or there might be no application available to open it." ) + } + } + onTextChanged: root.editorValueChanged( textArea.text, textArea.text === "" ) } diff --git a/cmake_templates/AndroidManifest.xml.in b/cmake_templates/AndroidManifest.xml.in index 68ccd01cd..0a5c6ded4 100644 --- a/cmake_templates/AndroidManifest.xml.in +++ b/cmake_templates/AndroidManifest.xml.in @@ -100,10 +100,15 @@ - + - + - + + + + + +