diff --git a/mastodon/build.gradle b/mastodon/build.gradle
index 1722777a1..b31af568a 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 cb26b722e..15bfddb5d 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 46312ac9c..b1502e7d7 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 52c139022..948eb4e13 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 000000000..edf4e1194
--- /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 000000000..13fcd2a7a
--- /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 000000000..472bb4a89
--- /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 000000000..6ac126201
--- /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 427bf4116..0d7542a8d 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 014c87417..779ea61c7 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 67b290f3d..7b5762f0e 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 18bfa8b7f..ef68ef53b 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 96f4d1979..41a1346ae 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 000000000..da87ec46e
--- /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 000000000..2d1486e37
--- /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 f4a79a0dc..c2164ff14 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 3e5d727fb..60c5eb3cd 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