Add support for deep linking through push notification data

This commit is contained in:
Michael Zanetti 2021-09-16 16:17:07 +02:00
parent 10c0e531b7
commit aec0d7c5df
23 changed files with 320 additions and 21 deletions

View File

@ -222,8 +222,11 @@ int Thing::executeAction(const QString &actionName, const QVariantList &params)
{
ActionType *actionType = m_thingClass->actionTypes()->findByName(actionName);
if (!actionType) {
qCWarning(dcThingManager) << "No such action name" << actionName << "in thing class" << m_thingClass->name();
return -1;
actionType = m_thingClass->actionTypes()->getActionType(QUuid(actionName));
if (!actionType) {
qCWarning(dcThingManager) << "No such action" << actionName << "in thing class" << m_thingClass->name();
return -1;
}
}
QVariantList finalParams;

View File

@ -105,7 +105,8 @@ ubuntu_files.files += \
packaging/ubuntu/click/appicon.svg \
packaging/ubuntu/click/push.json \
packaging/ubuntu/click/push-apparmor.json \
packaging/ubuntu/click/pushexec
packaging/ubuntu/click/pushexec \
packaging/ubuntu/click/urls.json
INSTALLS += ubuntu_files
}

View File

@ -98,7 +98,13 @@ int main(int argc, char *argv[])
// Initialize app log controller as early as possible, but after setting app name and printing initial startup info
AppLogController::instance();
qCInfo(dcApplication()) << "*** nymea:app starting ***" << QDateTime::currentDateTime().toString();
qCInfo(dcApplication()) << "*** nymea:app starting ***" << QDateTime::currentDateTime().toString() << application.arguments();
foreach (const QString &argument, application.arguments()) {
if (argument.startsWith("nymea://notification")) {
PlatformHelper::instance()->notificationActionReceived(QUrlQuery(QUrl(argument).query()).queryItemValue("nymeaData"));
}
}
QTranslator qtTranslator;
qtTranslator.load("qt_" + QLocale::system().name(), QLibraryInfo::location(QLibraryInfo::TranslationsPath));

View File

@ -183,7 +183,7 @@ ubports: {
DEFINES += UBPORTS
CONFIG += link_pkgconfig
PKGCONFIG += connectivity-qt1
PKGCONFIG += connectivity-qt1 dbus-1 libnih-dbus libnih
HEADERS += platformintegration/ubports/pushclient.h \
platformintegration/ubports/platformhelperubports.h \

View File

@ -34,6 +34,8 @@
#include <QClipboard>
#include <QDesktopServices>
#include <QUrl>
#include <QUrlQuery>
#include <QJsonDocument>
#if defined Q_OS_ANDROID
#include <QtAndroidExtras/QtAndroid>
@ -56,9 +58,34 @@ PlatformHelper::PlatformHelper(QObject *parent) : QObject(parent)
}
PlatformHelper *PlatformHelper::instance()
void PlatformHelper::notificationActionReceived(const QString &nymeaData)
{
if (!s_instance) {
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(nymeaData.toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
qCWarning(dcPlatformIntegration()) << "Received a notification action but cannot parse it:" << error.errorString() << nymeaData;
return;
}
QVariantMap map = jsonDoc.toVariant().toMap();
QUuid id = QUuid::createUuid();
map.insert("id", id);
// transforming data from a url query to a map for easier processing in QML
QUrlQuery query(map.value("data").toString());
QVariantMap dataMap;
for (int i = 0; i < query.queryItems().count(); i++) {
const QPair<QString, QString> &item = query.queryItems().at(i);
dataMap.insert(item.first, item.second);
}
map.insert("dataMap", dataMap);
m_pendingNotificationActions.insert(id, map);
emit pendingNotificationActionsChanged();
}
PlatformHelper *PlatformHelper::instance(bool create)
{
if (!s_instance && create) {
#ifdef Q_OS_ANDROID
s_instance = new PlatformHelperAndroid();
#elif defined(Q_OS_IOS)
@ -178,6 +205,17 @@ bool PlatformHelper::darkModeEnabled() const
return false;
}
QVariantList PlatformHelper::pendingNotificationActions() const
{
return m_pendingNotificationActions.values();
}
void PlatformHelper::notificationActionHandled(const QUuid &id)
{
m_pendingNotificationActions.remove(id);
emit pendingNotificationActionsChanged();
}
bool PlatformHelper::splashVisible() const
{
return m_splashVisible;

View File

@ -33,6 +33,9 @@
#include <QObject>
#include <QColor>
#include <QHash>
#include <QUuid>
#include <QVariant>
class QQmlEngine;
class QJSEngine;
@ -54,6 +57,7 @@ class PlatformHelper : public QObject
Q_PROPERTY(QColor topPanelColor READ topPanelColor WRITE setTopPanelColor NOTIFY topPanelColorChanged)
Q_PROPERTY(QColor bottomPanelColor READ bottomPanelColor WRITE setBottomPanelColor NOTIFY bottomPanelColorChanged)
Q_PROPERTY(bool darkModeEnabled READ darkModeEnabled NOTIFY darkModeEnabledChanged)
Q_PROPERTY(QVariantList pendingNotificationActions READ pendingNotificationActions NOTIFY pendingNotificationActionsChanged)
public:
enum HapticsFeedback {
@ -63,7 +67,7 @@ public:
};
Q_ENUM(HapticsFeedback)
static PlatformHelper* instance();
static PlatformHelper* instance(bool create = true);
virtual ~PlatformHelper() = default;
virtual bool hasPermissions() const;
@ -89,6 +93,9 @@ public:
virtual bool darkModeEnabled() const;
QVariantList pendingNotificationActions() const;
Q_INVOKABLE void notificationActionHandled(const QUuid &id);
virtual bool splashVisible() const;
virtual void setSplashVisible(bool splashVisible);
Q_INVOKABLE virtual void hideSplashScreen();
@ -102,6 +109,9 @@ public:
Q_INVOKABLE virtual void shareFile(const QString &fileName);
static QObject *platformHelperProvider(QQmlEngine *engine, QJSEngine *scriptEngine);
void notificationActionReceived(const QString &nymeaData);
signals:
void permissionsRequestFinished();
void screenTimeoutChanged();
@ -110,6 +120,7 @@ signals:
void bottomPanelColorChanged();
void darkModeEnabledChanged();
void splashVisibleChanged();
void pendingNotificationActionsChanged();
protected:
explicit PlatformHelper(QObject *parent = nullptr);
@ -121,6 +132,8 @@ private:
QColor m_bottomPanelColor = QColor("black");
bool m_splashVisible = true;
QHash<QUuid, QVariant> m_pendingNotificationActions;
};
#endif // PLATFORMHELPER_H

View File

@ -44,7 +44,13 @@ public class NymeaAppNotificationService extends FirebaseMessagingService {
Intent intent = new Intent(this, NymeaAppActivity.class);
// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
// PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent, PendingIntent.FLAG_ONE_SHOT);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent, 0);
Log.d(TAG, "adding extra data to intent: " + remoteMessage.getData().get("nymeaData"));
intent.setAction(Intent.ACTION_SEND);
intent.putExtra("notificationData", remoteMessage.getData().get("nymeaData"));
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent, PendingIntent.FLAG_CANCEL_CURRENT);
// We can't directly access R.drawable.ic_stat_notification from here:
// When the package is branded, the package name is not "io.guh.nymeaapp" and resources in
@ -63,6 +69,17 @@ public class NymeaAppNotificationService extends FirebaseMessagingService {
.setSound(android.provider.Settings.System.DEFAULT_RINGTONE_URI)
.setContentIntent(pendingIntent);
// Action tests
// Intent actionIntent = new Intent(this, NymeaAppActivity.class);
// actionIntent.setAction(Intent.ACTION_SEND);
// actionIntent.putExtra("foobar", "baz");
// PendingIntent actionPendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, actionIntent, PendingIntent.FLAG_CANCEL_CURRENT);
// notificationBuilder.addAction(resId, "30%", actionPendingIntent);
// notificationBuilder.addAction(resId, "50%", actionPendingIntent);
// notificationBuilder.addAction(resId, "70%", actionPendingIntent);
// Action tests end
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -20,6 +20,7 @@ public class NymeaAppActivity extends org.qtproject.qt5.android.bindings.QtActiv
private static Context context = null;
private static native void darkModeEnabledChangedJNI();
private static native void notificationActionReceivedJNI(String data);
@Override
public void onCreate(Bundle savedInstanceState) {
@ -27,12 +28,24 @@ public class NymeaAppActivity extends org.qtproject.qt5.android.bindings.QtActiv
this.context = getApplicationContext();
}
public void onNewIntent (Intent intent) {
Log.d(TAG, "New intent: " + intent);
String notificationData = intent.getStringExtra("notificationData");
if (notificationData != null) {
Log.d(TAG, "Intent data: " + notificationData);
notificationActionReceivedJNI(notificationData);
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
NymeaAppActivity.darkModeEnabledChangedJNI();
}
public String notificationData() {
return getIntent().getStringExtra("notificationData");
}
public static Context getAppContext() {
return NymeaAppActivity.context;

View File

@ -48,6 +48,7 @@ static PlatformHelperAndroid *m_instance = nullptr;
static JNINativeMethod methods[] = {
{ "darkModeEnabledChangedJNI", "()V", (void *)PlatformHelperAndroid::darkModeEnabledChangedJNI },
{ "notificationActionReceivedJNI", "(Ljava/lang/String;)V", (void *)PlatformHelperAndroid::notificationActionReceivedJNI },
};
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* /*reserved*/)
@ -77,6 +78,10 @@ PlatformHelperAndroid::PlatformHelperAndroid(QObject *parent) : PlatformHelper(p
{
m_instance = this;
QString notificationData = QtAndroid::androidActivity().callObjectMethod("notificationData", "()Ljava/lang/String;").toString();
if (!notificationData.isNull()) {
notificationActionReceived(notificationData);
}
}
void PlatformHelperAndroid::requestPermissions()
@ -274,3 +279,13 @@ void PlatformHelperAndroid::darkModeEnabledChangedJNI()
emit m_instance->darkModeEnabledChanged();
}
}
void PlatformHelperAndroid::notificationActionReceivedJNI(JNIEnv *env, jobject, jstring data)
{
// Only call the platformhelper if it exists yet. We may get this callback before the Qt part is created
// and we don't want to create the PlatformHelper on the android thread.
PlatformHelper* platformHelper = PlatformHelperAndroid::instance(false);
if (platformHelper) {
platformHelper->notificationActionReceived(env->GetStringUTFChars(data, nullptr));
}
}

View File

@ -68,6 +68,7 @@ public:
void shareFile(const QString &fileName) override;
static void darkModeEnabledChangedJNI();
static void notificationActionReceivedJNI(JNIEnv *env, jobject /*thiz*/, jstring data);
private:
static void permissionRequestFinished(const QtAndroid::PermissionResultMap &);

View File

@ -1,8 +1,8 @@
#import "UIKit/UIKit.h"
#import <UserNotifications/UserNotifications.h>
// Include our C++ class
#include "pushnotifications.h"
#include "platformhelper.h"
#include <QDebug>
@ -76,6 +76,11 @@
-(void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)())completionHandler{
NSLog(@"User Info : %@",response.notification.request.content.userInfo);
qDebug() << "received notification response!";
NSString *nymeaData = response.notification.request.content.userInfo[@"gcm.notification.nymeaData"];
PlatformHelper::instance()->notificationActionReceived(QString::fromNSString(nymeaData));
completionHandler();
}

View File

@ -1,11 +1,23 @@
#include <libnih.h>
#include <libnih-dbus.h>
#include "platformhelperubports.h"
#include <QSettings>
#include <QUuid>
#include <QUrl>
#include <QUrlQuery>
#include <QDBusConnection>
#include <QDebug>
#include <QCoreApplication>
PlatformHelperUBPorts::PlatformHelperUBPorts(QObject *parent) : PlatformHelper(parent)
PlatformHelperUBPorts::PlatformHelperUBPorts(QObject *parent):
PlatformHelper(parent),
m_uriHandlerObject(this)
{
setupUriHandler();
}
QString PlatformHelperUBPorts::platform() const
@ -21,3 +33,49 @@ QString PlatformHelperUBPorts::deviceSerial() const
}
return s.value("deviceSerial").toString();
}
void PlatformHelperUBPorts::setupUriHandler()
{
QString objectPath;
if (!QDBusConnection::sessionBus().isConnected()) {
qWarning() << "UCUriHandler: D-Bus session bus is not connected, ignoring.";
return;
}
// Get the object path based on the "APP_ID" environment variable.
QByteArray applicationId = qgetenv("APP_ID");
if (applicationId.isEmpty()) {
qWarning() << "UCUriHandler: Empty \"APP_ID\" environment variable, ignoring.";
return;
}
char* path = nih_dbus_path(NULL, "", applicationId.constData(), nullptr);
objectPath = QString::fromLocal8Bit(path);
nih_free(path);
// Ensure handler is running on the main thread.
QCoreApplication* instance = QCoreApplication::instance();
if (instance) {
moveToThread(instance->thread());
} else {
qWarning() << "UCUriHandler: Created before QCoreApplication, application may misbehave.";
}
QDBusConnection::sessionBus().registerObject(
objectPath, &m_uriHandlerObject, QDBusConnection::ExportAllSlots);
}
UriHandlerObject::UriHandlerObject(PlatformHelper *platformHelper):
m_platformHelper(platformHelper)
{
}
void UriHandlerObject::Open(const QStringList& uris, const QHash<QString, QVariant>& platformData)
{
Q_UNUSED(platformData);
foreach (const QString &uri, uris) {
if (uri.startsWith("nymea://notification")) {
m_platformHelper->notificationActionReceived(QUrlQuery(QUrl(uri)).queryItemValue("nymeaData"));
}
}
}

View File

@ -5,6 +5,22 @@
#include "platformhelper.h"
class UriHandlerObject: public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.freedesktop.Application")
public:
UriHandlerObject(PlatformHelper* platformHelper);
public Q_SLOTS:
void Open(const QStringList& uris, const QHash<QString, QVariant>& platformData);
private:
PlatformHelper* m_platformHelper = nullptr;
};
class PlatformHelperUBPorts : public PlatformHelper
{
Q_OBJECT
@ -16,6 +32,11 @@ public:
signals:
private:
void setupUriHandler();
UriHandlerObject m_uriHandlerObject;
};
#endif // PLATFORMHELPERUBPORTS_H

View File

@ -26,6 +26,10 @@
{
"name": "body",
"value": "%0 runs out of battery"
},
{
"name": "data",
"value": "open=$0"
}
]
}
@ -58,6 +62,10 @@
{
"name": "body",
"value": "%0 runs dry"
},
{
"name": "data",
"value": "open=$0"
}
]
}
@ -90,6 +98,10 @@
{
"name": "body",
"value": "%0 has disconnected"
},
{
"name": "data",
"value": "open=$0"
}
]
}
@ -122,6 +134,10 @@
{
"name": "body",
"value": "%0 has connected"
},
{
"name": "data",
"value": "open=$0"
}
]
}

View File

@ -44,6 +44,11 @@ import "mainviews"
Page {
id: root
// Removing the background from this page only because the MainViewBase adds it again in
// a deepter layer as we need to include it in the blurring of the header and footer.
// We don't want to paint the background on the entire screen twice (overdraw is costly)
background: null
function configureViews() {
if (Configuration.hasOwnProperty("mainViewsFilter")) {
console.warn("Main views configuration is disabled by app configuration")
@ -54,10 +59,21 @@ Page {
d.configOverlay = configComponent.createObject(contentContainer)
}
// Removing the background from this page only because the MainViewBase adds it again in
// a deepter layer as we need to include it in the blurring of the header and footer.
// We don't want to paint the background on the entire screen twice (overdraw is costly)
background: null
function goToView(viewName, data) {
// We allow separating the target by : and pass more stuff to
console.log("Going to main view", viewName, filteredContentModel.count, data)
for (var i = 0; i < filteredContentModel.count; i++) {
console.log("got", i, filteredContentModel.modelData(i, "name"))
if (filteredContentModel.modelData(i, "name") === viewName) {
console.log("activating", i)
// mainViewSettings.currentIndex = i;
// tabBar.currentIndex = i;
swipeView.setCurrentIndex(i)
swipeView.currentItem.item.handleEvent(data)
break;
}
}
}
header: Item {
id: mainHeader
@ -245,6 +261,7 @@ Page {
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } }
Repeater {
id: mainViewsRepeater
model: d.configOverlay != null ? null : filteredContentModel
delegate: Loader {

View File

@ -426,12 +426,57 @@ Item {
target: engine.thingManager
onFetchingDataChanged: {
if (!engine.thingManager.fetchingData) {
processPendingPushNotificationActions();
updatePushNotificationThings()
}
PlatformHelper.hideSplashScreen();
}
}
Connections {
target: PlatformHelper
onPendingNotificationActionsChanged: {
processPendingPushNotificationActions()
}
}
function processPendingPushNotificationActions() {
print("pending notification actions changed:", PlatformHelper.pendingNotificationActions)
if (PlatformHelper.pendingNotificationActions.length > 0) {
var notificationAction = PlatformHelper.pendingNotificationActions[0]
if (notificationAction.serverUuid.replace(/[{]]/g, "") !== engine.jsonRpcClient.serverUuid.toString().replace(/[{}]/g, "")) {
print("notification action for different server")
return;
}
print("handling action", JSON.stringify(notificationAction))
if (notificationAction.dataMap.hasOwnProperty("open")) {
// It could be just a thing ID
var target = notificationAction.dataMap["open"]
var thing = engine.thingManager.things.getThing(target)
if (thing) {
print("opening thing:", thing.name)
pageStack.push("/ui/devicepages/" + NymeaUtils.interfaceListToDevicePage(thing.thingClass.interfaces), {thing: thing})
} else {
// or a view name
console.log("going to main view:", target)
pageStack.currentItem.goToView(target, notificationAction.dataMap)
}
}
if (notificationAction.dataMap.hasOwnProperty("execute")) {
var action = notificationAction.dataMap["execute"]
var thingId = notificationAction.dataMap["thingId"]
var actionParams = JSON.parse(notificationAction.dataMap["actionParams"])
print("executing:", thingId, action, actionParams)
engine.thingManager.things.getThing(thingId).executeAction(action, actionParams);
}
PlatformHelper.notificationActionHandled(notificationAction.id)
}
}
Component {
id: invalidVersionComponent
Popup {

View File

@ -49,6 +49,11 @@ Item {
property var headerButtons: []
// Override this to receive events (e.g. from push notification bubbles)
function handleEvent(data) {
print("handleEvent not implemented in", title)
}
Background {
anchors.fill: parent
}

View File

@ -747,6 +747,8 @@ WizardPageBase {
content: ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.margins
Layout.rightMargin: Style.margins
Layout.maximumWidth: 500
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: visibleContentHeight
@ -755,6 +757,7 @@ WizardPageBase {
wrapMode: Text.WordWrap
text: qsTr("You can now go ahead and configure your nymea system.")
visible: wirelessConnectionCompletedPage.host != null
horizontalAlignment: Text.AlignHCenter
}
BusyIndicator {
Layout.alignment: Qt.AlignHCenter

View File

@ -278,6 +278,7 @@ Page {
print("replacing args", typeof actionParam.value)
if (typeof actionParam.value === "string") {
actionParam.value = actionParam.value.replace("%" + selectionId, thingName);
actionParam.value = actionParam.value.replace("$" + selectionId, selectedThings[selectionId]);
}
}
}
@ -287,6 +288,7 @@ Page {
var actionParam = action.ruleActionParams.get(k);
if (typeof actionParam.value === "string") {
actionParam.value = actionParam.value.replace("%" + selectionId, thingName);
actionParam.value = actionParam.value.replace("$" + selectionId, selectedThings[selectionId]);
}
}
}
@ -445,6 +447,11 @@ Page {
for (var j = 0; j < ruleActionTemplate.ruleActionParamTemplates.count; j++) {
var ruleActionParamTemplate = ruleActionTemplate.ruleActionParamTemplates.get(j)
var paramType = actionType.paramTypes.findByName(ruleActionParamTemplate.paramName);
if (!paramType) {
print("Skipping template action param", ruleActionParamTemplate, "as action type does not have this param")
continue;
}
if (ruleActionParamTemplate.value !== undefined) {
ruleAction.ruleActionParams.setRuleActionParam(paramType.id, ruleActionParamTemplate.value)
} else if (ruleActionParamTemplate.eventInterface && ruleActionParamTemplate.eventName && ruleActionParamTemplate.eventParamName) {

View File

@ -11,7 +11,9 @@
"qml-module-qtcharts",
"qml-module-qt-labs-calendar",
"libconnectivity-qt1-dev",
"libunity-api-dev"
"libunity-api-dev",
"libnih-dbus-dev",
"libdbus-1-dev"
],
"install_qml": [
"/usr/lib/${ARCH_TRIPLET}/qt5/qml/Qt/labs/calendar/"

View File

@ -6,7 +6,8 @@
"hooks": {
"nymea-app": {
"apparmor": "nymea-app.apparmor",
"desktop": "nymea-app.desktop"
"desktop": "nymea-app.desktop",
"urls": "urls.json"
},
"push": {
"apparmor": "push-apparmor.json",

View File

@ -1,6 +1,6 @@
[Desktop Entry]
Name=nymea:app
Exec=usr/bin/nymea-app
Exec=usr/bin/nymea-app %U
Icon=appicon.svg
Terminal=false
Type=Application

View File

@ -7,13 +7,25 @@ import json
f1, f2 = sys.argv[1:3]
payloadJson = json.load(open(f1))
print("<<<< Input: %s" % payloadJson)
# Set an icon
dir_path = os.path.dirname(os.path.realpath(__file__))
payloadJson["notification"]["card"]["icon"] = dir_path + "/appicon.svg"
payloadJson["notification"]["card"]["actions"] = ["appid://io.guh.nymeaapp/nymea-app/current-user-version"]
# Define the on-click action
action = "appid://io.guh.nymeaapp/nymea-app/current-user-version" # The default action (just opening the app)
if "nymeaData" in payloadJson["notification"]:
action = "nymea://notification?nymeaData=%s" % json.dumps(payloadJson["notification"]["nymeaData"])
payloadJson["notification"]["card"]["actions"] = [action]
#payloadJson["notification"]["emblem-counter"] = {"count": 1, "visible": True }
print(payloadJson)
#print("nymeaData: %s" % nymeaData)
print("action: %s" % action)
print(">>>> Output: %s" % payloadJson)
open(f2, "w").write(json.dumps(payloadJson) + "\n")