From 6d2385b6b3f7e4b5cd52ee92df8c48edf650fb8d Mon Sep 17 00:00:00 2001 From: FineFindus <63370021+FineFindus@users.noreply.github.com> Date: Sat, 5 Aug 2023 19:42:10 +0200 Subject: [PATCH] feat: support UnifiedPush notifications (#749) * build: add unified push dependency * feat(notification): allow arbitrary push notification endpoint * feat(notification/unified-push): show notification * refactor(unifiedPush): use more consise null check * feat(settings/notification): add UnifiedPush toggle * feat(settings/notification): show no distributor message * feat(settings/notification): disable unifiedpush when no distributor is available * change icon name --------- Co-authored-by: sk --- build.gradle | 6 ++ mastodon/build.gradle | 1 + mastodon/src/main/AndroidManifest.xml | 12 ++- .../android/PushNotificationReceiver.java | 9 ++- .../UnifiedPushNotificationReceiver.java | 81 +++++++++++++++++++ .../android/api/PushSubscriptionManager.java | 12 ++- .../RegisterForPushNotifications.java | 4 +- .../SettingsNotificationsFragment.java | 54 ++++++++++++- .../android/model/PushNotification.java | 37 +++++++++ .../ic_fluent_alert_arrow_up_24_regular.xml | 12 +++ mastodon/src/main/res/values/strings_sk.xml | 7 +- 11 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java create mode 100644 mastodon/src/main/res/drawable/ic_fluent_alert_arrow_up_24_regular.xml diff --git a/build.gradle b/build.gradle index 8357ab502..5f07f7905 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,12 @@ buildscript { repositories { google() mavenCentral() + maven { + url "https://www.jitpack.io" + content { + includeModule 'com.github.UnifiedPush', 'android-connector' + } + } } dependencies { classpath 'com.android.tools.build:gradle:8.0.0' diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 1c216d6ec..b03de8998 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -78,6 +78,7 @@ dependencies { implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0' annotationProcessor 'org.parceler:parceler:1.1.12' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' + implementation 'com.github.UnifiedPush:android-connector:2.1.1' androidTestImplementation 'androidx.test:core:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index f5c5deb9f..3444c7818 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -87,6 +88,15 @@ + + + + + + + + diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index 915b4d91e..7a3a380be 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -14,6 +14,7 @@ import android.content.Intent; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.util.Log; @@ -32,6 +33,7 @@ import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushNotification; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; +import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; @@ -58,7 +60,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ private static final int SUMMARY_ID = 791; private static int notificationId = 0; - private static Map notificationIdsForAccounts = new HashMap<>(); + private static final Map notificationIdsForAccounts = new HashMap<>(); @Override public void onReceive(Context context, Intent intent){ @@ -148,6 +150,11 @@ public class PushNotificationReceiver extends BroadcastReceiver{ } } + public void notifyUnifiedPush(Context context, String accountID, org.joinmastodon.android.model.Notification notification) { + // push notifications are only created from the official push notification, so we create a fake from by transforming the notification + PushNotificationReceiver.this.notify(context, PushNotification.fromNotification(context, notification), accountID, notification); + } + private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ NotificationManager nm=context.getSystemService(NotificationManager.class); AccountSession session=AccountSessionManager.get(accountID); diff --git a/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java new file mode 100644 index 000000000..3d7b0d3a9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/UnifiedPushNotificationReceiver.java @@ -0,0 +1,81 @@ +package org.joinmastodon.android; + +import android.content.Context; +import android.util.Log; + +import org.jetbrains.annotations.NotNull; +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; +import org.unifiedpush.android.connector.MessagingReceiver; + +import java.util.List; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class UnifiedPushNotificationReceiver extends MessagingReceiver{ + private static final String TAG="UnifiedPushNotificationReceiver"; + + public UnifiedPushNotificationReceiver() { + super(); + } + + @Override + public void onNewEndpoint(@NotNull Context context, @NotNull String endpoint, @NotNull String instance) { + // Called when a new endpoint be used for sending push messages + Log.d(TAG, "onNewEndpoint: New Endpoint " + endpoint + " for "+ instance); + AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount(); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onRegistrationFailed(@NotNull Context context, @NotNull String instance) { + // called when the registration is not possible, eg. no network + Log.d(TAG, "onRegistrationFailed: " + instance); + //re-register for gcm + AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount(); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onUnregistered(@NotNull Context context, @NotNull String instance) { + // called when this application is unregistered from receiving push messages + Log.d(TAG, "onUnregistered: " + instance); + //re-register for gcm + AccountSession account = AccountSessionManager.getInstance().getLastActiveAccount(); + if (account != null) + account.getPushSubscriptionManager().registerAccountForPush(null); + } + + @Override + public void onMessage(@NotNull Context context, @NotNull byte[] message, @NotNull String instance) { + // Called when a new message is received. The message contains the full POST body of the push message + AccountSession account = AccountSessionManager.getInstance().getAccount(instance); + + //this is stupid + // Mastodon stores the info to decrypt the message in the HTTP headers, which are not accessible in UnifiedPush, + // thus it is not possible to decrypt them. SO we need to re-request them from the server and transform them later on + // The official uses fcm and moves the headers to extra data, see + // https://github.com/mastodon/webpush-fcm-relay/blob/cac95b28d5364b0204f629283141ac3fb749e0c5/webpush-fcm-relay.go#L116 + // https://github.com/tuskyapp/Tusky/pull/2303#issue-1112080540 + account.getCacheController().getNotifications(null, 1, false, false, true, new Callback<>(){ + @Override + public void onSuccess(PaginatedResponse> result){ + result.items + .stream() + .findFirst() + .ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value))); + } + + @Override + public void onError(ErrorResponse error){ + //professional error handling + } + }); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java index 281307a00..28c17e0bc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java @@ -120,9 +120,16 @@ public class PushSubscriptionManager{ return !TextUtils.isEmpty(deviceToken); } + public void registerAccountForPush(PushSubscription subscription){ if(TextUtils.isEmpty(deviceToken)) throw new IllegalStateException("No device push token available"); + String endpoint = "https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; + registerAccountForPush(subscription, endpoint); + } + + public void registerAccountForPush(PushSubscription subscription, String endpoint){ + MastodonAPIController.runInBackground(()->{ Log.d(TAG, "registerAccountForPush: started for "+accountID); String encodedPublicKey, encodedAuthKey, pushAccountID; @@ -151,12 +158,11 @@ public class PushSubscriptionManager{ Log.e(TAG, "registerAccountForPush: error generating encryption key", e); return; } - new RegisterForPushNotifications(deviceToken, + new RegisterForPushNotifications(endpoint, encodedPublicKey, encodedAuthKey, subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts, - subscription==null ? PushSubscription.Policy.ALL : subscription.policy, - pushAccountID) + subscription==null ? PushSubscription.Policy.ALL : subscription.policy) .setCallback(new Callback<>(){ @Override public void onSuccess(PushSubscription result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java index b1ef8ace9..fb6cabcd9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java @@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.PushSubscription; public class RegisterForPushNotifications extends MastodonAPIRequest{ - public RegisterForPushNotifications(String deviceToken, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy, String accountID){ + public RegisterForPushNotifications(String endpoint, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy){ super(HttpMethod.POST, "/push/subscription", PushSubscription.class); Request r=new Request(); - r.subscription.endpoint="https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; + r.subscription.endpoint=endpoint; r.data.alerts=alerts; r.policy=policy; r.subscription.keys.p256dh=encryptionKey; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java index 3ebca1fc2..e64e1054f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.fragments.settings; +import static org.unifiedpush.android.connector.UnifiedPush.getDistributor; + import android.app.AlertDialog; import android.app.NotificationManager; import android.content.Intent; @@ -25,8 +27,11 @@ import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; import org.joinmastodon.android.ui.utils.UiUtils; +import org.unifiedpush.android.connector.RegistrationDialogContent; +import org.unifiedpush.android.connector.UnifiedPush; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; @@ -52,7 +57,8 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ private boolean notificationsAllowed=true; // MEGALODON - private CheckableListItem uniformIconItem, deleteItem, onlyLatestItem; + private boolean useUnifiedPush = false; + private CheckableListItem uniformIconItem, deleteItem, onlyLatestItem, unifiedPushItem; private CheckableListItem postsItem, updateItem; private AccountLocalPreferences lp; @@ -64,6 +70,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ lp=AccountSessionManager.get(accountID).getLocalPreferences(); getPushSubscription(); + useUnifiedPush=!getDistributor(getContext()).isEmpty(); onDataLoaded(List.of( pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_fluent_alert_snooze_24_regular, ()->onPauseNotificationsClick(false)), @@ -79,9 +86,16 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ uniformIconItem=new CheckableListItem<>(R.string.sk_settings_uniform_icon_for_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.uniformNotificationIcon, R.drawable.ic_ntf_logo, ()->toggleCheckableItem(uniformIconItem)), deleteItem=new CheckableListItem<>(R.string.sk_settings_enable_delete_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enableDeleteNotifications, R.drawable.ic_fluent_mail_inbox_dismiss_24_regular, ()->toggleCheckableItem(deleteItem)), - onlyLatestItem=new CheckableListItem<>(R.string.sk_settings_single_notification, 0, CheckableListItem.Style.SWITCH, lp.keepOnlyLatestNotification, R.drawable.ic_fluent_convert_range_24_regular, ()->toggleCheckableItem(onlyLatestItem), true) + onlyLatestItem=new CheckableListItem<>(R.string.sk_settings_single_notification, 0, CheckableListItem.Style.SWITCH, lp.keepOnlyLatestNotification, R.drawable.ic_fluent_convert_range_24_regular, ()->toggleCheckableItem(onlyLatestItem), true), + unifiedPushItem=new CheckableListItem<>(R.string.sk_settings_unifiedpush, 0, CheckableListItem.Style.SWITCH, useUnifiedPush, R.drawable.ic_fluent_alert_arrow_up_24_regular, this::onUnifiedPush, true) )); + //only enable when distributors, who can receive notifications, are available + unifiedPushItem.isEnabled=!UnifiedPush.getDistributors(getContext(), new ArrayList<>()).isEmpty(); + if (!unifiedPushItem.isEnabled) { + unifiedPushItem.subtitleRes=R.string.sk_settings_unifiedpush_no_distributor_body; + } + typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem, updateItem, postsItem); pauseItem.checkedChangeListener=checked->onPauseNotificationsClick(true); updatePolicyItem(null); @@ -312,4 +326,38 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ bannerAdapter.setVisible(false); } } -} + + private void onUnifiedPush(){ + if(getDistributor(getContext()).isEmpty()){ + List distributors = UnifiedPush.getDistributors(getContext(), new ArrayList<>()); + showUnifiedPushRegisterDialog(distributors); + return; + } + + UnifiedPush.unregisterApp( + getContext(), + accountID + ); + + //re-register to fcm + AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().registerAccountForPush(getPushSubscription()); + unifiedPushItem.toggle(); + rebindItem(unifiedPushItem); + } + + private void showUnifiedPushRegisterDialog(List distributors){ + new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_unifiedpush_choose).setItems(distributors.toArray(String[]::new), + (dialog, which) ->{ + String userDistrib = distributors.get(which); + UnifiedPush.saveDistributor(getContext(), userDistrib); + UnifiedPush.registerApp( + getContext(), + accountID, + new ArrayList<>(), + getContext().getPackageName() + ); + unifiedPushItem.toggle(); + rebindItem(unifiedPushItem); + }).show(); + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java index 7f81a8e62..1e7ce594e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java @@ -1,9 +1,12 @@ package org.joinmastodon.android.model; +import android.content.Context; + import com.google.gson.annotations.SerializedName; import org.joinmastodon.android.R; import org.joinmastodon.android.api.RequiredField; +import org.joinmastodon.android.ui.utils.UiUtils; import androidx.annotation.StringRes; @@ -20,6 +23,40 @@ public class PushNotification extends BaseModel{ @RequiredField public String body; + public static PushNotification fromNotification(Context context, Notification notification){ + PushNotification pushNotification = new PushNotification(); + pushNotification.notificationType = switch(notification.type) { + case FOLLOW -> PushNotification.Type.FOLLOW; + case MENTION -> PushNotification.Type.MENTION; + case REBLOG -> PushNotification.Type.REBLOG; + case FAVORITE -> PushNotification.Type.FAVORITE; + case POLL -> PushNotification.Type.POLL; + case STATUS -> PushNotification.Type.STATUS; + case UPDATE -> PushNotification.Type.UPDATE; + case SIGN_UP -> PushNotification.Type.SIGN_UP; + case REPORT -> PushNotification.Type.REPORT; + //Follow request, and reactions are not supported by the API + default -> throw new IllegalStateException("Unexpected value: "+notification.type); + }; + + String notificationTitle = context.getString(switch(notification.type){ + case FOLLOW -> R.string.user_followed_you; + case MENTION -> R.string.sk_notification_mention; + case REBLOG -> R.string.notification_boosted; + case FAVORITE -> R.string.user_favorited; + case POLL -> R.string.poll_ended; + case UPDATE -> R.string.sk_post_edited; + case SIGN_UP -> R.string.sk_signed_up; + case REPORT -> R.string.sk_reported; + default -> throw new IllegalStateException("Unexpected value: "+notification.type); + }); + + pushNotification.title = UiUtils.generateFormattedString(notificationTitle, notification.account.displayName).toString(); + pushNotification.icon = notification.status.account.avatarStatic; + pushNotification.body = notification.status.getStrippedText(); + return pushNotification; + } + @Override public String toString(){ return "PushNotification{"+ diff --git a/mastodon/src/main/res/drawable/ic_fluent_alert_arrow_up_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_alert_arrow_up_24_regular.xml new file mode 100644 index 000000000..b6cca5760 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_alert_arrow_up_24_regular.xml @@ -0,0 +1,12 @@ + + + + diff --git a/mastodon/src/main/res/values/strings_sk.xml b/mastodon/src/main/res/values/strings_sk.xml index 5c1b7eb47..519cb8c10 100644 --- a/mastodon/src/main/res/values/strings_sk.xml +++ b/mastodon/src/main/res/values/strings_sk.xml @@ -135,7 +135,11 @@ Mark as read About instance Only show one notification - Create + Use UnifiedPush + Choose a distributor + No distributor found + You need to install a distributor for UnifiedPush notifications to work. For more information, visit https://unifiedpush.org/ + Create Create list List name Show replies to @@ -344,4 +348,5 @@ Notifications Profile Show tab labels in the navigation bar + You were mentioned by %s \ No newline at end of file