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 @@
-
+
-
+
-
+
+
+
+
+
+