diff --git a/src/android/org/kde/knotifications/KNotification.java b/src/android/org/kde/knotifications/KNotification.java index 8ab8f1f..0ce1d84 100644 --- a/src/android/org/kde/knotifications/KNotification.java +++ b/src/android/org/kde/knotifications/KNotification.java @@ -1,59 +1,60 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package org.kde.knotifications; import android.graphics.drawable.Icon; import android.os.Build; import java.lang.Object; import java.util.ArrayList; /** Java side of KNotification. * Used to convey the relevant notification data to Java. */ public class KNotification { public int id; public String text; public String richText; public String title; public Object icon; public ArrayList actions = new ArrayList(); public String channelId; public String channelName; public String channelDescription; + public String group; public int urgency; // see knotification.h public static final int LowUrgency = 10; public static final int NormalUrgency = 50; public static final int HighUrgency = 70; public static final int CriticalUrgency = 90; public void setIconFromData(byte[] data, int length) { if (Build.VERSION.SDK_INT >= 23) { icon = Icon.createWithData(data, 0, length); } } public void addAction(String action) { actions.add(action); } } diff --git a/src/android/org/kde/knotifications/NotifyByAndroid.java b/src/android/org/kde/knotifications/NotifyByAndroid.java index d8b55ed..34b0de4 100644 --- a/src/android/org/kde/knotifications/NotifyByAndroid.java +++ b/src/android/org/kde/knotifications/NotifyByAndroid.java @@ -1,192 +1,290 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package org.kde.knotifications; import android.app.Activity; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.graphics.drawable.Icon; import android.os.Build; import android.text.Html; import android.util.Log; +import java.util.HashMap; import java.util.HashSet; /** Java side of the Android notfication backend. */ public class NotifyByAndroid extends BroadcastReceiver { private static final String TAG = "org.kde.knotifications"; private static final String NOTIFICATION_ACTION = ".org.kde.knotifications.NOTIFICATION_ACTION"; private static final String NOTIFICATION_DELETED = ".org.kde.knotifications.NOTIFICATION_DELETED"; private static final String NOTIFICATION_OPENED = ".org.kde.knotifications.NOTIFICATION_OPENED"; + // the id of the notification triggering an intent private static final String NOTIFICATION_ID_EXTRA = "org.kde.knotifications.NOTIFICATION_ID"; + // the id of the action that was triggered for a notification private static final String NOTIFICATION_ACTION_ID_EXTRA = "org.kde.knotifications.NOTIFICATION_ACTION_ID"; + // the group a notification belongs too + private static final String NOTIFICATION_GROUP_EXTRA = "org.kde.knotifications.NOTIFICATION_GROUP"; + + // notification id offset for group summary notifications + // we need this to stay out of the regular notification's id space (which comes from the C++ side) + // and so we can distinguish if we received actions on regular notifications or group summaries + private static final int NOTIFICATION_GROUP_ID_FLAG = (1 << 24); private android.content.Context m_ctx; private NotificationManager m_notificationManager; private int m_uniquePendingIntentId = 0; private HashSet m_channels = new HashSet(); + private class GroupData { + public HashSet childIds = new HashSet(); + public int groupId; + }; + private HashMap m_groupSummaries = new HashMap(); + public NotifyByAndroid(android.content.Context context) { Log.i(TAG, context.getPackageName()); m_ctx = context; m_notificationManager = (NotificationManager)m_ctx.getSystemService(Context.NOTIFICATION_SERVICE); IntentFilter filter = new IntentFilter(); filter.addAction(m_ctx.getPackageName() + NOTIFICATION_ACTION); filter.addAction(m_ctx.getPackageName() + NOTIFICATION_DELETED); filter.addAction(m_ctx.getPackageName() + NOTIFICATION_OPENED); m_ctx.registerReceiver(this, filter); } public void notify(KNotification notification) { Log.i(TAG, notification.text); // notification channel if (!m_channels.contains(notification.channelId)) { m_channels.add(notification.channelId); if (Build.VERSION.SDK_INT >= 26) { NotificationChannel channel = new NotificationChannel(notification.channelId, notification.channelName, NotificationManager.IMPORTANCE_DEFAULT); channel.setDescription(notification.channelDescription); switch (notification.urgency) { case KNotification.CriticalUrgency: channel.setImportance(NotificationManager.IMPORTANCE_HIGH); break; case KNotification.NormalUrgency: channel.setImportance(NotificationManager.IMPORTANCE_LOW); break; case KNotification.LowUrgency: channel.setImportance(NotificationManager.IMPORTANCE_MIN); break; case KNotification.HighUrgency: default: channel.setImportance(NotificationManager.IMPORTANCE_DEFAULT); break; } m_notificationManager.createNotificationChannel(channel); } } Notification.Builder builder; if (Build.VERSION.SDK_INT >= 26) { builder = new Notification.Builder(m_ctx, notification.channelId); } else { builder = new Notification.Builder(m_ctx); } if (Build.VERSION.SDK_INT >= 23) { builder.setSmallIcon((Icon)notification.icon); } else { builder.setSmallIcon(m_ctx.getApplicationInfo().icon); } builder.setContentTitle(notification.title); builder.setContentText(notification.text); // regular notifications show only a single line of content, if we have more // we need the "BigTextStyle" expandable notifications to make everything readable // in the single line case this behaves like the regular one, so no special-casing needed if (Build.VERSION.SDK_INT >= 24) { builder.setStyle(new Notification.BigTextStyle().bigText(Html.fromHtml(notification.richText, Html.FROM_HTML_MODE_COMPACT))); } else { builder.setStyle(new Notification.BigTextStyle().bigText(Html.fromHtml(notification.richText))); } + if (notification.group != null) { + createGroupNotification(notification); + builder.setGroup(notification.group); + } + // legacy priority handling for versions without NotificationChannel support if (Build.VERSION.SDK_INT < 26) { switch (notification.urgency) { case KNotification.CriticalUrgency: builder.setPriority(Notification.PRIORITY_HIGH); break; case KNotification.NormalUrgency: builder.setPriority(Notification.PRIORITY_LOW); break; case KNotification.LowUrgency: builder.setPriority(Notification.PRIORITY_MIN); break; case KNotification.HighUrgency: default: builder.setPriority(Notification.PRIORITY_DEFAULT); break; } } // taping the notification shows the app Intent intent = new Intent(m_ctx.getPackageName() + NOTIFICATION_OPENED); intent.putExtra(NOTIFICATION_ID_EXTRA, notification.id); PendingIntent contentIntent = PendingIntent.getBroadcast(m_ctx, m_uniquePendingIntentId++, intent, PendingIntent.FLAG_UPDATE_CURRENT); builder.setContentIntent(contentIntent); // actions int actionId = 1; for (String actionName : notification.actions) { Intent actionIntent = new Intent(m_ctx.getPackageName() + NOTIFICATION_ACTION); actionIntent.putExtra(NOTIFICATION_ID_EXTRA, notification.id); actionIntent.putExtra(NOTIFICATION_ACTION_ID_EXTRA, actionId); PendingIntent pendingIntent = PendingIntent.getBroadcast(m_ctx, m_uniquePendingIntentId++, actionIntent, PendingIntent.FLAG_UPDATE_CURRENT); Notification.Action action = new Notification.Action.Builder(0, actionName, pendingIntent).build(); builder.addAction(action); ++actionId; } // notfication about user closing the notification Intent deleteIntent = new Intent(m_ctx.getPackageName() + NOTIFICATION_DELETED); deleteIntent.putExtra(NOTIFICATION_ID_EXTRA, notification.id); + if (notification.group != null) { + deleteIntent.putExtra(NOTIFICATION_GROUP_EXTRA, notification.group); + } Log.i(TAG, deleteIntent.getExtras() + " " + notification.id); builder.setDeleteIntent(PendingIntent.getBroadcast(m_ctx, m_uniquePendingIntentId++, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT)); m_notificationManager.notify(notification.id, builder.build()); } - public void close(int id) + public void close(int id, String group) { m_notificationManager.cancel(id); + + if (group != null && m_groupSummaries.containsKey(group)) { + GroupData g = m_groupSummaries.get(group); + g.childIds.remove(id); + if (g.childIds.isEmpty()) { + m_groupSummaries.remove(group); + m_notificationManager.cancel(g.groupId); + } else { + m_groupSummaries.put(group, g); + } + } } @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); + int id = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1); Log.i(TAG, action + ": " + id + " " + intent.getExtras()); if (action.equals(m_ctx.getPackageName() + NOTIFICATION_ACTION)) { + // user activated one of the custom actions int actionId = intent.getIntExtra(NOTIFICATION_ACTION_ID_EXTRA, -1); notificationActionInvoked(id, actionId); } else if (action.equals(m_ctx.getPackageName() + NOTIFICATION_DELETED)) { - notificationFinished(id); + // user (or system) dismissed the notification - this can happen both for groups and regular notifications + String group = null; + if (intent.hasExtra(NOTIFICATION_GROUP_EXTRA)) { + group = intent.getStringExtra(NOTIFICATION_GROUP_EXTRA); + } + + if ((id & NOTIFICATION_GROUP_ID_FLAG) != 0) { + // entire group has been deleted + m_groupSummaries.remove(group); + } else { + // a single regular notification, so reduce the refcount of the group if there is one + notificationFinished(id); + if (group != null && m_groupSummaries.containsKey(group)) { + // we do not need to handle the case of childIds being empty here, the system will send us a deletion intent for the group too + // this only matters for the logic in close(). + GroupData g = m_groupSummaries.get(group); + g.childIds.remove(id); + m_groupSummaries.put(group, g); + } + } } else if (action.equals(m_ctx.getPackageName() + NOTIFICATION_OPENED)) { + // user tapped the notification Intent newintent = new Intent(m_ctx, m_ctx.getClass()); newintent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); m_ctx.startActivity(newintent); notificationActionInvoked(id, 0); } } public native void notificationFinished(int notificationId); public native void notificationActionInvoked(int notificationId, int action); + + private void createGroupNotification(KNotification notification) + { + if (m_groupSummaries.containsKey(notification.group)) { + GroupData group = m_groupSummaries.get(notification.group); + group.childIds.add(notification.id); + m_groupSummaries.put(notification.group, group); + return; + } + + GroupData group = new GroupData(); + group.childIds.add(notification.id); + group.groupId = m_uniquePendingIntentId++ + NOTIFICATION_GROUP_ID_FLAG; + m_groupSummaries.put(notification.channelId, group); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= 26) { + builder = new Notification.Builder(m_ctx, notification.channelId); + } else { + builder = new Notification.Builder(m_ctx); + } + + if (Build.VERSION.SDK_INT >= 23) { + builder.setSmallIcon((Icon)notification.icon); + } else { + builder.setSmallIcon(m_ctx.getApplicationInfo().icon); + } + builder.setContentTitle(notification.channelName); + builder.setContentText(notification.channelDescription); + builder.setGroup(notification.group); + builder.setGroupSummary(true); + + // monitor for deletion (which happens when the last child notification is closed) + Intent deleteIntent = new Intent(m_ctx.getPackageName() + NOTIFICATION_DELETED); + deleteIntent.putExtra(NOTIFICATION_GROUP_EXTRA, notification.group); + deleteIntent.putExtra(NOTIFICATION_ID_EXTRA, group.groupId); + builder.setDeleteIntent(PendingIntent.getBroadcast(m_ctx, m_uniquePendingIntentId++, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT)); + + // try to stay out of the normal id space for regular notifications + m_notificationManager.notify(group.groupId, builder.build()); + } } diff --git a/src/notifybyandroid.cpp b/src/notifybyandroid.cpp index 7c0ee40..4cf59b2 100644 --- a/src/notifybyandroid.cpp +++ b/src/notifybyandroid.cpp @@ -1,179 +1,183 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ #include "notifybyandroid.h" #include "knotification.h" #include "knotifyconfig.h" #include "debug_p.h" #include #include #include #include static NotifyByAndroid *s_instance = nullptr; static void notificationFinished(JNIEnv *env, jobject that, jint notificationId) { Q_UNUSED(env); Q_UNUSED(that); if (s_instance) { s_instance->notificationFinished(notificationId); } } static void notificationActionInvoked(JNIEnv *env, jobject that, jint id, jint action) { Q_UNUSED(env); Q_UNUSED(that); if (s_instance) { s_instance->notificationActionInvoked(id, action); } } static const JNINativeMethod methods[] = { {"notificationFinished", "(I)V", (void*)notificationFinished}, {"notificationActionInvoked", "(II)V", (void*)notificationActionInvoked} }; KNOTIFICATIONS_EXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void*) { static bool initialized = false; if (initialized) { return JNI_VERSION_1_4; } initialized = true; JNIEnv *env = nullptr; if (vm->GetEnv((void**)&env, JNI_VERSION_1_4) != JNI_OK) { qCWarning(LOG_KNOTIFICATIONS) << "Failed to get JNI environment."; return -1; } jclass cls = env->FindClass("org/kde/knotifications/NotifyByAndroid"); if (env->RegisterNatives(cls, methods, sizeof(methods) / sizeof(JNINativeMethod)) < 0) { qCWarning(LOG_KNOTIFICATIONS) << "Failed to register native functions."; return -1; } return JNI_VERSION_1_4; } NotifyByAndroid::NotifyByAndroid(QObject* parent) : KNotificationPlugin(parent) { s_instance = this; m_backend = QAndroidJniObject("org/kde/knotifications/NotifyByAndroid", "(Landroid/content/Context;)V", QtAndroid::androidContext().object()); } NotifyByAndroid::~NotifyByAndroid() { s_instance = nullptr; } QString NotifyByAndroid::optionName() { return QStringLiteral("Popup"); } void NotifyByAndroid::notify(KNotification *notification, KNotifyConfig *config) { Q_UNUSED(config); // HACK work around that notification->id() is only populated after returning from here // note that config will be invalid at that point, so we can't pass that along QMetaObject::invokeMethod(this, [this, notification](){ notifyDeferred(notification); }, Qt::QueuedConnection); } QAndroidJniObject NotifyByAndroid::createAndroidNotification(KNotification *notification, KNotifyConfig *config) const { QAndroidJniEnvironment env; QAndroidJniObject n("org/kde/knotifications/KNotification", "()V"); n.setField("id", notification->id()); n.setField("text", QAndroidJniObject::fromString(stripRichText(notification->text())).object()); n.setField("richText", QAndroidJniObject::fromString(notification->text()).object()); n.setField("title", QAndroidJniObject::fromString(stripRichText(notification->title())).object()); n.setField("urgency", (jint)(notification->urgency() == KNotification::DefaultUrgency ? KNotification::HighUrgency : notification->urgency())); n.setField("channelId", QAndroidJniObject::fromString(notification->eventId()).object()); n.setField("channelName", QAndroidJniObject::fromString(config->readEntry(QLatin1String("Name"))).object()); n.setField("channelDescription", QAndroidJniObject::fromString(config->readEntry(QLatin1String("Comment"))).object()); + if ((notification->flags() & KNotification::SkipGrouping) == 0) { + n.setField("group", QAndroidJniObject::fromString(notification->eventId()).object()); + } + // icon QPixmap pixmap; if (!notification->iconName().isEmpty()) { const auto icon = QIcon::fromTheme(notification->iconName()); pixmap = icon.pixmap(32, 32); } else { pixmap = notification->pixmap(); } QByteArray iconData; QBuffer buffer(&iconData); buffer.open(QIODevice::WriteOnly); pixmap.save(&buffer, "PNG"); auto jIconData = env->NewByteArray(iconData.length()); env->SetByteArrayRegion(jIconData, 0, iconData.length(), reinterpret_cast(iconData.constData())); n.callMethod("setIconFromData", "([BI)V", jIconData, iconData.length()); env->DeleteLocalRef(jIconData); // actions const auto actions = notification->actions(); for (const auto &action : actions) { n.callMethod("addAction", "(Ljava/lang/String;)V", QAndroidJniObject::fromString(action).object()); } return n; } void NotifyByAndroid::notifyDeferred(KNotification* notification) { KNotifyConfig config(notification->appName(), notification->contexts(), notification->eventId()); const auto n = createAndroidNotification(notification, &config); m_notifications.insert(notification->id(), notification); m_backend.callMethod("notify", "(Lorg/kde/knotifications/KNotification;)V", n.object()); } void NotifyByAndroid::update(KNotification *notification, KNotifyConfig *config) { const auto n = createAndroidNotification(notification, config); m_backend.callMethod("notify", "(Lorg/kde/knotifications/KNotification;)V", n.object()); } void NotifyByAndroid::close(KNotification* notification) { - m_backend.callMethod("close", "(I)V", notification->id()); + m_backend.callMethod("close", "(ILjava/lang/String;)V", notification->id(), QAndroidJniObject::fromString(notification->eventId()).object()); KNotificationPlugin::close(notification); } void NotifyByAndroid::notificationFinished(int id) { qCDebug(LOG_KNOTIFICATIONS) << id; const auto it = m_notifications.find(id); if (it == m_notifications.end()) { return; } m_notifications.erase(it); if (it.value()) { finish(it.value()); } } void NotifyByAndroid::notificationActionInvoked(int id, int action) { qCDebug(LOG_KNOTIFICATIONS) << id << action; emit actionInvoked(id, action); }