From a0cbf0fa31cbed1993daf6fda065739341f51643 Mon Sep 17 00:00:00 2001 From: Grishka Date: Sun, 3 Apr 2022 23:54:31 +0300 Subject: [PATCH] Push notifications --- mastodon/build.gradle | 2 +- mastodon/src/main/AndroidManifest.xml | 18 +- .../joinmastodon/android/MainActivity.java | 74 +++- .../org/joinmastodon/android/MastodonApp.java | 4 + .../android/PushNotificationReceiver.java | 140 +++++++ .../android/api/PushSubscriptionManager.java | 349 ++++++++++++++++++ .../notifications/GetNotificationByID.java | 10 + .../RegisterForPushNotifications.java | 35 ++ .../android/api/session/AccountSession.java | 12 + .../api/session/AccountSessionManager.java | 14 +- .../android/fragments/HomeFragment.java | 19 + .../fragments/discover/SearchFragment.java | 3 +- .../android/model/Notification.java | 2 + .../android/model/PushNotification.java | 55 +++ .../android/model/PushSubscription.java | 46 +++ .../android/ui/utils/UiUtils.java | 12 + mastodon/src/main/res/values/strings.xml | 5 + 17 files changed, 794 insertions(+), 6 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationByID.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 1722777a..b31af568 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -10,7 +10,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 31 - versionCode 18 + versionCode 20 versionName "0.1" } diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index cb26b722..15bfddb5 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ + + + + - + @@ -32,6 +36,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 46312ac9..b1502e7d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -2,13 +2,20 @@ package org.joinmastodon.android; import android.app.Application; import android.app.Fragment; +import android.content.Intent; import android.os.Bundle; +import android.util.Log; +import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HomeFragment; +import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.SplashFragment; +import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; +import org.joinmastodon.android.model.Notification; +import org.parceler.Parcels; import java.lang.reflect.InvocationTargetException; @@ -25,12 +32,29 @@ public class MainActivity extends FragmentStackActivity{ showFragmentClearingBackStack(new SplashFragment()); }else{ AccountSessionManager.getInstance().maybeUpdateLocalInfo(); - AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount(); + AccountSession session; Bundle args=new Bundle(); + Intent intent=getIntent(); + if(intent.getBooleanExtra("fromNotification", false)){ + String accountID=intent.getStringExtra("accountID"); + try{ + session=AccountSessionManager.getInstance().getAccount(accountID); + if(!intent.hasExtra("notification")) + args.putString("tab", "notifications"); + }catch(IllegalStateException x){ + session=AccountSessionManager.getInstance().getLastActiveAccount(); + } + }else{ + session=AccountSessionManager.getInstance().getLastActiveAccount(); + } args.putString("account", session.getID()); Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment(); fragment.setArguments(args); showFragmentClearingBackStack(fragment); + if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){ + Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); + showFragmentForNotification(notification, session.getID()); + } } } @@ -41,4 +65,52 @@ public class MainActivity extends FragmentStackActivity{ }catch(ClassNotFoundException|NoSuchMethodException|IllegalAccessException|InvocationTargetException ignore){} } } + + @Override + protected void onNewIntent(Intent intent){ + super.onNewIntent(intent); + if(intent.getBooleanExtra("fromNotification", false)){ + String accountID=intent.getStringExtra("accountID"); + AccountSession accountSession; + try{ + accountSession=AccountSessionManager.getInstance().getAccount(accountID); + }catch(IllegalStateException x){ + return; + } + if(intent.hasExtra("notification")){ + Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); + showFragmentForNotification(notification, accountID); + }else{ + AccountSessionManager.getInstance().setLastActiveAccountID(accountID); + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("tab", "notifications"); + Fragment fragment=new HomeFragment(); + fragment.setArguments(args); + showFragmentClearingBackStack(fragment); + } + } + } + + private void showFragmentForNotification(Notification notification, String accountID){ + Fragment fragment; + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putBoolean("_can_go_back", true); + try{ + notification.postprocess(); + }catch(ObjectValidationException x){ + Log.w("MainActivity", x); + return; + } + if(notification.status!=null){ + fragment=new ThreadFragment(); + args.putParcelable("status", Parcels.wrap(notification.status)); + }else{ + fragment=new ProfileFragment(); + args.putParcelable("profileAccount", Parcels.wrap(notification.account)); + } + fragment.setArguments(args); + showFragment(fragment); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java index 52c13902..948eb4e1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -4,6 +4,8 @@ import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; +import org.joinmastodon.android.api.PushSubscriptionManager; + import java.lang.reflect.InvocationTargetException; import me.grishka.appkit.imageloader.ImageCache; @@ -23,5 +25,7 @@ public class MastodonApp extends Application{ ImageCache.setParams(params); NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME); context=getApplicationContext(); + + PushSubscriptionManager.tryRegisterFCM(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java new file mode 100644 index 00000000..edf4e119 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -0,0 +1,140 @@ +package org.joinmastodon.android; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; + +import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.requests.notifications.GetNotificationByID; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.PushNotification; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.imageloader.ImageCache; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class PushNotificationReceiver extends BroadcastReceiver{ + private static final String TAG="PushNotificationReceive"; + + public static final int NOTIFICATION_ID=178; + + @Override + public void onReceive(Context context, Intent intent){ + if(BuildConfig.DEBUG){ + Log.e(TAG, "received: "+intent); + Bundle extras=intent.getExtras(); + for(String key : extras.keySet()){ + Log.i(TAG, key+" -> "+extras.get(key)); + } + } + if("com.google.android.c2dm.intent.RECEIVE".equals(intent.getAction())){ + String k=intent.getStringExtra("k"); + String p=intent.getStringExtra("p"); + String s=intent.getStringExtra("s"); + String accountID=intent.getStringExtra("x"); + if(!TextUtils.isEmpty(accountID) && !TextUtils.isEmpty(k) && !TextUtils.isEmpty(p) && !TextUtils.isEmpty(s)){ + MastodonAPIController.runInBackground(()->{ + try{ + PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s); + new GetNotificationByID(pn.notificationId+"") + .setCallback(new Callback<>(){ + @Override + public void onSuccess(org.joinmastodon.android.model.Notification result){ + MastodonAPIController.runInBackground(()->PushNotificationReceiver.this.notify(context, pn, accountID, result)); + } + + @Override + public void onError(ErrorResponse error){ + MastodonAPIController.runInBackground(()->PushNotificationReceiver.this.notify(context, pn, accountID, null)); + } + }) + .exec(accountID); + }catch(Exception x){ + Log.w(TAG, x); + } + }); + }else{ + Log.w(TAG, "onReceive: invalid push notification format"); + } + } + } + + private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ + NotificationManager nm=context.getSystemService(NotificationManager.class); + Account self=AccountSessionManager.getInstance().getAccount(accountID).self; + String accountName="@"+self.username+"@"+AccountSessionManager.getInstance().getAccount(accountID).domain; + Notification.Builder builder; + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){ + boolean hasGroup=false; + List channelGroups=nm.getNotificationChannelGroups(); + for(NotificationChannelGroup group:channelGroups){ + if(group.getId().equals(accountID)){ + hasGroup=true; + break; + } + } + if(!hasGroup){ + NotificationChannelGroup group=new NotificationChannelGroup(accountID, accountName); + nm.createNotificationChannelGroup(group); + List channels=Arrays.stream(PushNotification.Type.values()) + .map(type->{ + NotificationChannel channel=new NotificationChannel(accountID+"_"+type, context.getString(type.localizedName), NotificationManager.IMPORTANCE_DEFAULT); + channel.setGroup(accountID); + return channel; + }) + .collect(Collectors.toList()); + nm.createNotificationChannels(channels); + } + builder=new Notification.Builder(context, accountID+"_"+pn.notificationType); + }else{ + builder=new Notification.Builder(context) + .setPriority(Notification.PRIORITY_DEFAULT) + .setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE); + } + Drawable avatar=ImageCache.getInstance(context).get(new UrlImageLoaderRequest(pn.icon, V.dp(50), V.dp(50))); + Intent contentIntent=new Intent(context, MainActivity.class); + contentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + contentIntent.putExtra("fromNotification", true); + contentIntent.putExtra("accountID", accountID); + if(notification!=null){ + contentIntent.putExtra("notification", Parcels.wrap(notification)); + } + builder.setContentTitle(pn.title) + .setContentText(pn.body) + .setStyle(new Notification.BigTextStyle().bigText(pn.body)) + .setSmallIcon(R.drawable.ic_ntf_logo) + .setContentIntent(PendingIntent.getActivity(context, accountID.hashCode() & 0xFFFF, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_CANCEL_CURRENT)) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setAutoCancel(true) + .setColor(context.getColor(R.color.primary_700)); + if(avatar!=null){ + builder.setLargeIcon(UiUtils.getBitmapFromDrawable(avatar)); + } + if(AccountSessionManager.getInstance().getLoggedInAccounts().size()>1){ + builder.setSubText(accountName); + } + nm.notify(accountID, NOTIFICATION_ID, builder.build()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java new file mode 100644 index 00000000..13fcd2a7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java @@ -0,0 +1,349 @@ +package org.joinmastodon.android.api; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.api.requests.notifications.RegisterForPushNotifications; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.PushNotification; +import org.joinmastodon.android.model.PushSubscription; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyAgreement; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + +public class PushSubscriptionManager{ + private static final String FCM_SENDER_ID="449535203550"; + private static final String EC_CURVE_NAME="prime256v1"; + private static final byte[] P256_HEAD=new byte[]{(byte)0x30,(byte)0x59,(byte)0x30,(byte)0x13,(byte)0x06,(byte)0x07,(byte)0x2a, + (byte)0x86,(byte)0x48,(byte)0xce,(byte)0x3d,(byte)0x02,(byte)0x01,(byte)0x06,(byte)0x08,(byte)0x2a,(byte)0x86, + (byte)0x48,(byte)0xce,(byte)0x3d,(byte)0x03,(byte)0x01,(byte)0x07,(byte)0x03,(byte)0x42,(byte)0x00}; + private static final int[] BASE85_DECODE_TABLE={ + 0xff, 0x44, 0xff, 0x54, 0x53, 0x52, 0x48, 0xff, + 0x4b, 0x4c, 0x46, 0x41, 0xff, 0x3f, 0x3e, 0x45, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x40, 0xff, 0x49, 0x42, 0x4a, 0x47, + 0x51, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, + 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, + 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x4d, 0xff, 0x4e, 0x43, 0xff, + 0xff, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + 0x21, 0x22, 0x23, 0x4f, 0xff, 0x50, 0xff, 0xff + }; + + private static final String TAG="PushSubscriptionManager"; + public static final String EXTRA_APPLICATION_PENDING_INTENT = "app"; + public static final String GSF_PACKAGE = "com.google.android.gms"; + /** Internal parameter used to indicate a 'subtype'. Will not be stored in DB for Nacho. */ + private static final String EXTRA_SUBTYPE = "subtype"; + /** Extra used to indicate which senders (Google API project IDs) can send messages to the app */ + private static final String EXTRA_SENDER = "sender"; + private static final String EXTRA_SCOPE = "scope"; + private static final String KID_VALUE="|ID|1|"; // request ID? + + private static String deviceToken; + private String accountID; + private PrivateKey privateKey; + private PublicKey publicKey; + private PublicKey serverKey; + private byte[] authKey; + + public PushSubscriptionManager(String accountID){ + this.accountID=accountID; + } + + public static void tryRegisterFCM(){ + deviceToken=getPrefs().getString("deviceToken", null); + if(!TextUtils.isEmpty(deviceToken)){ + registerAllAccountsForPush(); + return; + } + Intent intent = new Intent("com.google.iid.TOKEN_REQUEST"); + intent.setPackage(GSF_PACKAGE); + intent.putExtra(EXTRA_APPLICATION_PENDING_INTENT, + PendingIntent.getBroadcast(MastodonApp.context, 0, new Intent(), PendingIntent.FLAG_IMMUTABLE)); + intent.putExtra(EXTRA_SENDER, FCM_SENDER_ID); + intent.putExtra(EXTRA_SUBTYPE, FCM_SENDER_ID); + intent.putExtra(EXTRA_SCOPE, "*"); + intent.putExtra("kid", KID_VALUE); + MastodonApp.context.sendBroadcast(intent); + } + + private static SharedPreferences getPrefs(){ + return MastodonApp.context.getSharedPreferences("push", Context.MODE_PRIVATE); + } + + public static boolean arePushNotificationsAvailable(){ + return !TextUtils.isEmpty(deviceToken); + } + + public void registerAccountForPush(){ + if(TextUtils.isEmpty(deviceToken)) + throw new IllegalStateException("No device push token available"); + MastodonAPIController.runInBackground(()->{ + Log.d(TAG, "registerAccountForPush: started for "+accountID); + String encodedPublicKey, encodedAuthKey; + try{ + KeyPairGenerator generator=KeyPairGenerator.getInstance("EC"); + ECGenParameterSpec spec=new ECGenParameterSpec(EC_CURVE_NAME); + generator.initialize(spec); + KeyPair keyPair=generator.generateKeyPair(); + publicKey=keyPair.getPublic(); + privateKey=keyPair.getPrivate(); + encodedPublicKey=Base64.encodeToString(serializeRawPublicKey(publicKey), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + authKey=new byte[16]; + new SecureRandom().nextBytes(authKey); + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + session.pushPrivateKey=Base64.encodeToString(privateKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + session.pushPublicKey=Base64.encodeToString(publicKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + session.pushAuthKey=encodedAuthKey=Base64.encodeToString(authKey, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + AccountSessionManager.getInstance().writeAccountsFile(); + }catch(NoSuchAlgorithmException|InvalidAlgorithmParameterException e){ + Log.e(TAG, "registerAccountForPush: error generating encryption key", e); + return; + } + new RegisterForPushNotifications(deviceToken, encodedPublicKey, encodedAuthKey, PushSubscription.Alerts.ofAll(), accountID) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(PushSubscription result){ + MastodonAPIController.runInBackground(()->{ + serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE)); + + if(serverKey!=null){ + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + session.pushServerKey=Base64.encodeToString(serverKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + AccountSessionManager.getInstance().writeAccountsFile(); + Log.d(TAG, "Successfully registered "+accountID+" for push notifications"); + } + }); + } + + @Override + public void onError(ErrorResponse error){ + + } + }) + .exec(accountID); + }); + } + + private PublicKey deserializeRawPublicKey(byte[] rawBytes){ + if(rawBytes.length!=65 && rawBytes.length!=64) + return null; + try{ + KeyFactory kf=KeyFactory.getInstance("EC"); + ByteArrayOutputStream os=new ByteArrayOutputStream(); + os.write(P256_HEAD); + if(rawBytes.length==64) + os.write(4); + os.write(rawBytes); + return kf.generatePublic(new X509EncodedKeySpec(os.toByteArray())); + }catch(NoSuchAlgorithmException|InvalidKeySpecException|IOException x){ + Log.e(TAG, "deserializeRawPublicKey", x); + } + return null; + } + + private byte[] serializeRawPublicKey(PublicKey key){ + ECPoint point=((ECPublicKey)key).getW(); + byte[] x=point.getAffineX().toByteArray(); + byte[] y=point.getAffineY().toByteArray(); + if(x.length>32) + x=Arrays.copyOfRange(x, x.length-32, x.length); + if(y.length>32) + y=Arrays.copyOfRange(y, y.length-32, y.length); + byte[] result=new byte[65]; + result[0]=4; + System.arraycopy(x, 0, result, 1+(32-x.length), x.length); + System.arraycopy(y, 0, result, result.length-y.length, y.length); + return result; + } + + private static byte[] decode85(String in){ + ByteArrayOutputStream data=new ByteArrayOutputStream(); + int block=0; + int n=0; + for(char c:in.toCharArray()){ + if(c>=32 && c<128 && BASE85_DECODE_TABLE[c-32]!=0xff){ + int value=BASE85_DECODE_TABLE[c-32]; + block=block*85+value; + n++; + if(n==5){ + data.write(block >> 24); + data.write(block >> 16); + data.write(block >> 8); + data.write(block); + block=0; + n=0; + } + } + } + if(n>=4) + data.write(block >> 16); + if(n>=3) + data.write(block >> 8); + if(n>=2) + data.write(block); + return data.toByteArray(); + } + + public PushNotification decryptNotification(String k, String p, String s){ + byte[] serverKeyBytes=decode85(k); + byte[] payload=decode85(p); + byte[] salt=decode85(s); + PublicKey serverKey=deserializeRawPublicKey(serverKeyBytes); + if(privateKey==null){ + try{ + KeyFactory kf=KeyFactory.getInstance("EC"); + privateKey=kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.decode(AccountSessionManager.getInstance().getAccount(accountID).pushPrivateKey, Base64.URL_SAFE))); + publicKey=kf.generatePublic(new X509EncodedKeySpec(Base64.decode(AccountSessionManager.getInstance().getAccount(accountID).pushPublicKey, Base64.URL_SAFE))); + authKey=Base64.decode(AccountSessionManager.getInstance().getAccount(accountID).pushAuthKey, Base64.URL_SAFE); + }catch(NoSuchAlgorithmException|InvalidKeySpecException x){ + Log.e(TAG, "decryptNotification: error loading private key", x); + return null; + } + } + byte[] sharedSecret; + try{ + KeyAgreement keyAgreement=KeyAgreement.getInstance("ECDH"); + keyAgreement.init(privateKey); + keyAgreement.doPhase(serverKey, true); + sharedSecret=keyAgreement.generateSecret(); + }catch(NoSuchAlgorithmException|InvalidKeyException x){ + Log.e(TAG, "decryptNotification: error doing key exchange", x); + return null; + } + byte[] secondSaltInfo="Content-Encoding: auth\0".getBytes(StandardCharsets.UTF_8); + byte[] key, nonce; + try{ + byte[] secondSalt=deriveKey(authKey, sharedSecret, secondSaltInfo, 32); + byte[] keyInfo=info("aesgcm", publicKey, serverKey); + key=deriveKey(salt, secondSalt, keyInfo, 16); + byte[] nonceInfo=info("nonce", publicKey, serverKey); + nonce=deriveKey(salt, secondSalt, nonceInfo, 12); + }catch(NoSuchAlgorithmException|InvalidKeyException x){ + Log.e(TAG, "decryptNotification: error deriving key", x); + return null; + } + String decryptedStr; + try{ + Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec aesKey=new SecretKeySpec(key, "AES"); + GCMParameterSpec iv=new GCMParameterSpec(128, nonce); + cipher.init(Cipher.DECRYPT_MODE, aesKey, iv); + byte[] decrypted=cipher.doFinal(payload); + decryptedStr=new String(decrypted, 2, decrypted.length-2, StandardCharsets.UTF_8); + if(BuildConfig.DEBUG) + Log.i(TAG, "decryptNotification: notification json "+decryptedStr); + }catch(NoSuchAlgorithmException|NoSuchPaddingException|InvalidAlgorithmParameterException|InvalidKeyException|BadPaddingException|IllegalBlockSizeException x){ + Log.e(TAG, "decryptNotification: error decrypting payload", x); + return null; + } + PushNotification notification=MastodonAPIController.gson.fromJson(decryptedStr, PushNotification.class); + try{ + notification.postprocess(); + }catch(IOException x){ + Log.e(TAG, "decryptNotification: error verifying notification object", x); + return null; + } + return notification; + } + + private byte[] deriveKey(byte[] firstSalt, byte[] secondSalt, byte[] info, int length) throws NoSuchAlgorithmException, InvalidKeyException{ + Mac hmacContext=Mac.getInstance("HmacSHA256"); + hmacContext.init(new SecretKeySpec(firstSalt, "HmacSHA256")); + byte[] hmac=hmacContext.doFinal(secondSalt); + hmacContext.init(new SecretKeySpec(hmac, "HmacSHA256")); + hmacContext.update(info); + byte[] result=hmacContext.doFinal(new byte[]{1}); + return result.length<=length ? result : Arrays.copyOfRange(result, 0, length); + } + + private byte[] info(String type, PublicKey clientPublicKey, PublicKey serverPublicKey){ + ByteArrayOutputStream info=new ByteArrayOutputStream(); + try{ + info.write("Content-Encoding: ".getBytes(StandardCharsets.UTF_8)); + info.write(type.getBytes(StandardCharsets.UTF_8)); + info.write(0); + info.write("P-256".getBytes(StandardCharsets.UTF_8)); + info.write(0); + info.write(0); + info.write(65); + info.write(serializeRawPublicKey(clientPublicKey)); + info.write(0); + info.write(65); + info.write(serializeRawPublicKey(serverPublicKey)); + }catch(IOException ignore){} + return info.toByteArray(); + } + + private static void registerAllAccountsForPush(){ + for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ + if(TextUtils.isEmpty(session.pushServerKey)) + session.getPushSubscriptionManager().registerAccountForPush(); + } + } + + public static class RegistrationReceiver extends BroadcastReceiver{ + @Override + public void onReceive(Context context, Intent intent){ + if("com.google.android.c2dm.intent.REGISTRATION".equals(intent.getAction())){ + if(intent.hasExtra("registration_id")){ + deviceToken=intent.getStringExtra("registration_id"); + if(deviceToken.startsWith(KID_VALUE)) + deviceToken=deviceToken.substring(KID_VALUE.length()+1); + getPrefs().edit().putString("deviceToken", deviceToken).apply(); + Log.i(TAG, "Successfully registered for FCM"); + registerAllAccountsForPush(); + }else{ + Log.e(TAG, "FCM registration intent did not contain registration_id: "+intent); + Bundle extras=intent.getExtras(); + for(String key:extras.keySet()){ + Log.i(TAG, key+" -> "+extras.get(key)); + } + } + } + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationByID.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationByID.java new file mode 100644 index 00000000..472bb4a8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationByID.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.notifications; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Notification; + +public class GetNotificationByID extends MastodonAPIRequest{ + public GetNotificationByID(String id){ + super(HttpMethod.GET, "/notifications/"+id, Notification.class); + } +} 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 new file mode 100644 index 00000000..6ac12620 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java @@ -0,0 +1,35 @@ +package org.joinmastodon.android.api.requests.notifications; + +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, String accountID){ + super(HttpMethod.POST, "/push/subscription", PushSubscription.class); + Request r=new Request(); + r.subscription.endpoint="https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; + r.data.alerts=alerts; + r.subscription.keys.p256dh=encryptionKey; + r.subscription.keys.auth=authKey; + setRequestBody(r); + } + + private static class Request{ + public Subscription subscription=new Subscription(); + public Data data=new Data(); + + private static class Keys{ + public String p256dh; + public String auth; + } + + private static class Subscription{ + public String endpoint; + public Keys keys=new Keys(); + } + + private static class Data{ + public PushSubscription.Alerts alerts; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 427bf411..0d7542a8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.api.session; import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.StatusInteractionController; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; @@ -15,9 +16,14 @@ public class AccountSession{ public Application app; public long infoLastUpdated; public boolean activated=true; + public String pushPrivateKey; + public String pushPublicKey; + public String pushAuthKey; + public String pushServerKey; private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController; private transient CacheController cacheController; + private transient PushSubscriptionManager pushSubscriptionManager; AccountSession(Token token, Account self, Application app, String domain, boolean activated){ this.token=token; @@ -51,4 +57,10 @@ public class AccountSession{ cacheController=new CacheController(getID()); return cacheController; } + + public PushSubscriptionManager getPushSubscriptionManager(){ + if(pushSubscriptionManager==null) + pushSubscriptionManager=new PushSubscriptionManager(getID()); + return pushSubscriptionManager; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 014c8741..779ea61c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -1,9 +1,11 @@ package org.joinmastodon.android.api.session; import android.app.Activity; +import android.app.NotificationManager; import android.content.Context; import android.content.SharedPreferences; import android.net.Uri; +import android.os.Build; import android.util.Log; import com.google.gson.JsonParseException; @@ -11,6 +13,7 @@ import com.google.gson.JsonParseException; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.requests.instance.GetCustomEmojis; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.instance.GetInstance; @@ -92,9 +95,12 @@ public class AccountSessionManager{ lastActiveAccountID=session.getID(); writeAccountsFile(); maybeUpdateLocalInfo(); + if(PushSubscriptionManager.arePushNotificationsAvailable()){ + session.getPushSubscriptionManager().registerAccountForPush(); + } } - private void writeAccountsFile(){ + public synchronized void writeAccountsFile(){ File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); try{ try(FileOutputStream out=new FileOutputStream(file)){ @@ -157,6 +163,10 @@ public class AccountSessionManager{ if(sessions.isEmpty() || !sessions.values().stream().map(s->s.domain.toLowerCase()).collect(Collectors.toSet()).contains(domain)){ getInstanceInfoFile(domain).delete(); } + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){ + NotificationManager nm=MastodonApp.context.getSystemService(NotificationManager.class); + nm.deleteNotificationChannelGroup(id); + } } @NonNull @@ -178,7 +188,7 @@ public class AccountSessionManager{ .appendQueryParameter("response_type", "code") .appendQueryParameter("client_id", result.clientId) .appendQueryParameter("redirect_uri", "mastodon-android-auth://callback") - .appendQueryParameter("scope", "read write follow push") + .appendQueryParameter("scope", SCOPE) .build(); new CustomTabsIntent.Builder() diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 67b290f3..7b5762f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Fragment; +import android.app.NotificationManager; import android.content.res.Configuration; import android.graphics.Outline; import android.os.Build; @@ -9,12 +10,14 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.PushNotificationReceiver; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.discover.DiscoverFragment; @@ -70,6 +73,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene args.putBoolean("noAutoLoad", true); profileFragment=new ProfileFragment(); profileFragment.setArguments(args); + } @Nullable @@ -105,6 +109,19 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene .add(R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment) .add(R.id.fragment_wrap, profileFragment).hide(profileFragment) .commit(); + + String defaultTab=getArguments().getString("tab"); + if("notifications".equals(defaultTab)){ + tabBar.selectTab(R.id.tab_notifications); + fragmentContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + fragmentContainer.getViewTreeObserver().removeOnPreDrawListener(this); + onTabSelected(R.id.tab_notifications); + return true; + } + }); + } }else{ tabBar.selectTab(currentTab); } @@ -174,6 +191,8 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene }else if(newFragment instanceof NotificationsFragment){ ((NotificationsFragment) newFragment).loadData(); // TODO make an interface? + NotificationManager nm=getActivity().getSystemService(NotificationManager.class); + nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID); } currentTab=tab; ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java index 18bfa8b7..ef68ef53 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java @@ -169,7 +169,8 @@ public class SearchFragment extends BaseStatusListFragment{ @Override protected void onDataLoaded(List d, boolean more){ super.onDataLoaded(d, more); - progressVisibilityListener.onProgressVisibilityChanged(false); + if(progressVisibilityListener!=null) + progressVisibilityListener.onProgressVisibilityChanged(false); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java index 96f4d197..41a1346a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java @@ -4,9 +4,11 @@ import com.google.gson.annotations.SerializedName; import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; +import org.parceler.Parcel; import java.time.Instant; +@Parcel public class Notification extends BaseModel implements DisplayItemsParent{ @RequiredField public String id; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java new file mode 100644 index 00000000..da87ec46 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushNotification.java @@ -0,0 +1,55 @@ +package org.joinmastodon.android.model; + +import com.google.gson.annotations.SerializedName; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.RequiredField; + +import androidx.annotation.StringRes; + +public class PushNotification extends BaseModel{ + public String accessToken; + public String preferredLocale; + public long notificationId; + @RequiredField + public Type notificationType; + @RequiredField + public String icon; + @RequiredField + public String title; + @RequiredField + public String body; + + @Override + public String toString(){ + return "PushNotification{"+ + "accessToken='"+accessToken+'\''+ + ", preferredLocale='"+preferredLocale+'\''+ + ", notificationId="+notificationId+ + ", notificationType="+notificationType+ + ", icon='"+icon+'\''+ + ", title='"+title+'\''+ + ", body='"+body+'\''+ + '}'; + } + + public enum Type{ + @SerializedName("favourite") + FAVORITE(R.string.notification_type_favorite), + @SerializedName("mention") + MENTION(R.string.notification_type_mention), + @SerializedName("reblog") + REBLOG(R.string.notification_type_reblog), + @SerializedName("follow") + FOLLOW(R.string.notification_type_follow), + @SerializedName("poll") + POLL(R.string.notification_type_poll); + + @StringRes + public final int localizedName; + + Type(int localizedName){ + this.localizedName=localizedName; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java new file mode 100644 index 00000000..2d1486e3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java @@ -0,0 +1,46 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.AllFieldsAreRequired; + +@AllFieldsAreRequired +public class PushSubscription extends BaseModel{ + public int id; + public String endpoint; + public Alerts alerts; + public String serverKey; + + @Override + public String toString(){ + return "PushSubscription{"+ + "id="+id+ + ", endpoint='"+endpoint+'\''+ + ", alerts="+alerts+ + ", serverKey='"+serverKey+'\''+ + '}'; + } + + public static class Alerts{ + public boolean follow; + public boolean favourite; + public boolean reblog; + public boolean mention; + public boolean poll; + + public static Alerts ofAll(){ + Alerts alerts=new Alerts(); + alerts.follow=alerts.favourite=alerts.reblog=alerts.mention=alerts.poll=true; + return alerts; + } + + @Override + public String toString(){ + return "Alerts{"+ + "follow="+follow+ + ", favourite="+favourite+ + ", reblog="+reblog+ + ", mention="+mention+ + ", poll="+poll+ + '}'; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index f4a79a0d..c2164ff1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -5,6 +5,9 @@ import android.app.Activity; import android.content.Context; import android.content.res.TypedArray; import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; @@ -362,4 +365,13 @@ public class UiUtils{ list.scrollToPosition(topItem); list.scrollBy(0, topItemOffset); } + + public static Bitmap getBitmapFromDrawable(Drawable d){ + if(d instanceof BitmapDrawable) + return ((BitmapDrawable) d).getBitmap(); + Bitmap bitmap=Bitmap.createBitmap(d.getIntrinsicWidth(), d.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + d.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight()); + d.draw(new Canvas(bitmap)); + return bitmap; + } } diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 3e5d727f..60c5eb3c 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -223,4 +223,9 @@ Recent searches Step %1$d of %2$d Skip + New followers + Favorites + Boosts + Mentions + Polls \ No newline at end of file