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 <sk22@mailbox.org>
This commit is contained in:
parent
44eaa36cef
commit
6d2385b6b3
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
@ -87,6 +88,15 @@
|
|||
<category android:name="me.grishka.fcmtest"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:exported="true" android:enabled="true" android:name=".UnifiedPushNotificationReceiver"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
|
|
|
@ -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<String, Integer> notificationIdsForAccounts = new HashMap<>();
|
||||
private static final Map<String, Integer> 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);
|
||||
|
|
|
@ -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<List<Notification>> result){
|
||||
result.items
|
||||
.stream()
|
||||
.findFirst()
|
||||
.ifPresent(value->MastodonAPIController.runInBackground(()->new PushNotificationReceiver().notifyUnifiedPush(context, instance, value)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
//professional error handling
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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){
|
||||
|
|
|
@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
|
|||
import org.joinmastodon.android.model.PushSubscription;
|
||||
|
||||
public class RegisterForPushNotifications extends MastodonAPIRequest<PushSubscription>{
|
||||
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;
|
||||
|
|
|
@ -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<Void>{
|
|||
private boolean notificationsAllowed=true;
|
||||
|
||||
// MEGALODON
|
||||
private CheckableListItem<Void> uniformIconItem, deleteItem, onlyLatestItem;
|
||||
private boolean useUnifiedPush = false;
|
||||
private CheckableListItem<Void> uniformIconItem, deleteItem, onlyLatestItem, unifiedPushItem;
|
||||
private CheckableListItem<Void> postsItem, updateItem;
|
||||
|
||||
private AccountLocalPreferences lp;
|
||||
|
@ -64,6 +70,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
|||
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<Void>{
|
|||
|
||||
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<Void>{
|
|||
bannerAdapter.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onUnifiedPush(){
|
||||
if(getDistributor(getContext()).isEmpty()){
|
||||
List<String> 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<String> 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();
|
||||
}
|
||||
}
|
|
@ -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{"+
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,1.996C16.05,1.996 19.357,5.191 19.496,9.245L19.5,9.496V13.593L20.88,16.749C20.949,16.907 20.985,17.077 20.985,17.25C20.985,17.94 20.425,18.5 19.735,18.5L15,18.501C15,20.158 13.657,21.501 12,21.501C10.402,21.501 9.096,20.252 9.005,18.678L9,18.499L4.275,18.5C4.104,18.5 3.934,18.465 3.777,18.396C3.144,18.121 2.853,17.385 3.128,16.752L4.5,13.594V9.496C4.501,5.341 7.852,1.996 12,1.996ZM13.5,18.499L10.5,18.501C10.5,19.33 11.172,20.001 12,20.001C12.78,20.001 13.421,19.406 13.493,18.646L13.5,18.499ZM12,3.496C8.68,3.496 6.001,6.17 6,9.496V13.906L4.656,17H19.353L18,13.907L18,9.509L17.997,9.284C17.885,6.05 15.242,3.496 12,3.496Z"
|
||||
android:fillColor="#212121"/>
|
||||
<path
|
||||
android:pathData="M8.083,9.969A0.508,0.508 0,0 0,8.807 10.682L11.494,7.955L11.494,14.897a0.508,0.508 0,1 0,1.016 0L12.509,7.958L15.193,10.682A0.508,0.508 45,0 0,15.917 9.969L12.452,6.453a0.635,0.635 0,0 0,-0.904 0z"
|
||||
android:fillColor="#212121"/>
|
||||
</vector>
|
|
@ -135,7 +135,11 @@
|
|||
<string name="sk_mark_as_read">Mark as read</string>
|
||||
<string name="sk_settings_about_instance">About instance</string>
|
||||
<string name="sk_settings_single_notification">Only show one notification</string>
|
||||
<string name="sk_create">Create</string>
|
||||
<string name="sk_settings_unifiedpush">Use UnifiedPush</string>
|
||||
<string name="sk_settings_unifiedpush_choose">Choose a distributor</string>
|
||||
<string name="sk_settings_unifiedpush_no_distributor">No distributor found</string>
|
||||
<string name="sk_settings_unifiedpush_no_distributor_body">You need to install a distributor for UnifiedPush notifications to work. For more information, visit https://unifiedpush.org/</string>
|
||||
<string name="sk_create">Create</string>
|
||||
<string name="sk_create_list_title">Create list</string>
|
||||
<string name="sk_list_name_hint">List name</string>
|
||||
<string name="sk_list_replies_policy">Show replies to</string>
|
||||
|
@ -344,4 +348,5 @@
|
|||
<string name="sk_tab_notifications">Notifications</string>
|
||||
<string name="sk_tab_profile">Profile</string>
|
||||
<string name="sk_settings_show_labels_in_navigation_bar">Show tab labels in the navigation bar</string>
|
||||
<string name="sk_notification_mention">You were mentioned by %s</string>
|
||||
</resources>
|
Loading…
Reference in New Issue