Push notifications
This commit is contained in:
parent
10655e4c98
commit
a0cbf0fa31
|
@ -10,7 +10,7 @@ android {
|
||||||
applicationId "org.joinmastodon.android"
|
applicationId "org.joinmastodon.android"
|
||||||
minSdk 23
|
minSdk 23
|
||||||
targetSdk 31
|
targetSdk 31
|
||||||
versionCode 18
|
versionCode 20
|
||||||
versionName "0.1"
|
versionName "0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,10 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
|
||||||
|
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
|
||||||
|
|
||||||
|
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MastodonApp"
|
android:name=".MastodonApp"
|
||||||
|
@ -15,7 +19,7 @@
|
||||||
android:theme="@style/Theme.Mastodon.AutoLightDark"
|
android:theme="@style/Theme.Mastodon.AutoLightDark"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
|
|
||||||
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize">
|
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize" android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
@ -32,6 +36,18 @@
|
||||||
|
|
||||||
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
|
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
|
||||||
|
|
||||||
|
<receiver android:name=".PushNotificationReceiver" android:exported="true" android:permission="com.google.android.c2dm.permission.SEND">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.android.c2dm.intent.RECEIVE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".api.PushSubscriptionManager$RegistrationReceiver" android:exported="true" android:permission="com.google.android.c2dm.permission.SEND">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.android.c2dm.intent.REGISTRATION"/>
|
||||||
|
<category android:name="me.grishka.fcmtest"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
|
@ -2,13 +2,20 @@ package org.joinmastodon.android;
|
||||||
|
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.app.Fragment;
|
import android.app.Fragment;
|
||||||
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
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.AccountSession;
|
||||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||||
import org.joinmastodon.android.fragments.HomeFragment;
|
import org.joinmastodon.android.fragments.HomeFragment;
|
||||||
|
import org.joinmastodon.android.fragments.ProfileFragment;
|
||||||
import org.joinmastodon.android.fragments.SplashFragment;
|
import org.joinmastodon.android.fragments.SplashFragment;
|
||||||
|
import org.joinmastodon.android.fragments.ThreadFragment;
|
||||||
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
|
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
|
||||||
|
import org.joinmastodon.android.model.Notification;
|
||||||
|
import org.parceler.Parcels;
|
||||||
|
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
|
||||||
|
@ -25,12 +32,29 @@ public class MainActivity extends FragmentStackActivity{
|
||||||
showFragmentClearingBackStack(new SplashFragment());
|
showFragmentClearingBackStack(new SplashFragment());
|
||||||
}else{
|
}else{
|
||||||
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
|
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
|
||||||
AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount();
|
AccountSession session;
|
||||||
Bundle args=new Bundle();
|
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());
|
args.putString("account", session.getID());
|
||||||
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
|
Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment();
|
||||||
fragment.setArguments(args);
|
fragment.setArguments(args);
|
||||||
showFragmentClearingBackStack(fragment);
|
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){}
|
}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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import android.annotation.SuppressLint;
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.api.PushSubscriptionManager;
|
||||||
|
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
|
||||||
import me.grishka.appkit.imageloader.ImageCache;
|
import me.grishka.appkit.imageloader.ImageCache;
|
||||||
|
@ -23,5 +25,7 @@ public class MastodonApp extends Application{
|
||||||
ImageCache.setParams(params);
|
ImageCache.setParams(params);
|
||||||
NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME);
|
NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME);
|
||||||
context=getApplicationContext();
|
context=getApplicationContext();
|
||||||
|
|
||||||
|
PushSubscriptionManager.tryRegisterFCM();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<NotificationChannelGroup> 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<NotificationChannel> 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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Notification>{
|
||||||
|
public GetNotificationByID(String id){
|
||||||
|
super(HttpMethod.GET, "/notifications/"+id, Notification.class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<PushSubscription>{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package org.joinmastodon.android.api.session;
|
||||||
|
|
||||||
import org.joinmastodon.android.api.CacheController;
|
import org.joinmastodon.android.api.CacheController;
|
||||||
import org.joinmastodon.android.api.MastodonAPIController;
|
import org.joinmastodon.android.api.MastodonAPIController;
|
||||||
|
import org.joinmastodon.android.api.PushSubscriptionManager;
|
||||||
import org.joinmastodon.android.api.StatusInteractionController;
|
import org.joinmastodon.android.api.StatusInteractionController;
|
||||||
import org.joinmastodon.android.model.Account;
|
import org.joinmastodon.android.model.Account;
|
||||||
import org.joinmastodon.android.model.Application;
|
import org.joinmastodon.android.model.Application;
|
||||||
|
@ -15,9 +16,14 @@ public class AccountSession{
|
||||||
public Application app;
|
public Application app;
|
||||||
public long infoLastUpdated;
|
public long infoLastUpdated;
|
||||||
public boolean activated=true;
|
public boolean activated=true;
|
||||||
|
public String pushPrivateKey;
|
||||||
|
public String pushPublicKey;
|
||||||
|
public String pushAuthKey;
|
||||||
|
public String pushServerKey;
|
||||||
private transient MastodonAPIController apiController;
|
private transient MastodonAPIController apiController;
|
||||||
private transient StatusInteractionController statusInteractionController;
|
private transient StatusInteractionController statusInteractionController;
|
||||||
private transient CacheController cacheController;
|
private transient CacheController cacheController;
|
||||||
|
private transient PushSubscriptionManager pushSubscriptionManager;
|
||||||
|
|
||||||
AccountSession(Token token, Account self, Application app, String domain, boolean activated){
|
AccountSession(Token token, Account self, Application app, String domain, boolean activated){
|
||||||
this.token=token;
|
this.token=token;
|
||||||
|
@ -51,4 +57,10 @@ public class AccountSession{
|
||||||
cacheController=new CacheController(getID());
|
cacheController=new CacheController(getID());
|
||||||
return cacheController;
|
return cacheController;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PushSubscriptionManager getPushSubscriptionManager(){
|
||||||
|
if(pushSubscriptionManager==null)
|
||||||
|
pushSubscriptionManager=new PushSubscriptionManager(getID());
|
||||||
|
return pushSubscriptionManager;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
package org.joinmastodon.android.api.session;
|
package org.joinmastodon.android.api.session;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.app.NotificationManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
|
@ -11,6 +13,7 @@ import com.google.gson.JsonParseException;
|
||||||
import org.joinmastodon.android.MastodonApp;
|
import org.joinmastodon.android.MastodonApp;
|
||||||
import org.joinmastodon.android.R;
|
import org.joinmastodon.android.R;
|
||||||
import org.joinmastodon.android.api.MastodonAPIController;
|
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.instance.GetCustomEmojis;
|
||||||
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
|
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
|
||||||
import org.joinmastodon.android.api.requests.instance.GetInstance;
|
import org.joinmastodon.android.api.requests.instance.GetInstance;
|
||||||
|
@ -92,9 +95,12 @@ public class AccountSessionManager{
|
||||||
lastActiveAccountID=session.getID();
|
lastActiveAccountID=session.getID();
|
||||||
writeAccountsFile();
|
writeAccountsFile();
|
||||||
maybeUpdateLocalInfo();
|
maybeUpdateLocalInfo();
|
||||||
|
if(PushSubscriptionManager.arePushNotificationsAvailable()){
|
||||||
|
session.getPushSubscriptionManager().registerAccountForPush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeAccountsFile(){
|
public synchronized void writeAccountsFile(){
|
||||||
File file=new File(MastodonApp.context.getFilesDir(), "accounts.json");
|
File file=new File(MastodonApp.context.getFilesDir(), "accounts.json");
|
||||||
try{
|
try{
|
||||||
try(FileOutputStream out=new FileOutputStream(file)){
|
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)){
|
if(sessions.isEmpty() || !sessions.values().stream().map(s->s.domain.toLowerCase()).collect(Collectors.toSet()).contains(domain)){
|
||||||
getInstanceInfoFile(domain).delete();
|
getInstanceInfoFile(domain).delete();
|
||||||
}
|
}
|
||||||
|
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
|
||||||
|
NotificationManager nm=MastodonApp.context.getSystemService(NotificationManager.class);
|
||||||
|
nm.deleteNotificationChannelGroup(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
@ -178,7 +188,7 @@ public class AccountSessionManager{
|
||||||
.appendQueryParameter("response_type", "code")
|
.appendQueryParameter("response_type", "code")
|
||||||
.appendQueryParameter("client_id", result.clientId)
|
.appendQueryParameter("client_id", result.clientId)
|
||||||
.appendQueryParameter("redirect_uri", "mastodon-android-auth://callback")
|
.appendQueryParameter("redirect_uri", "mastodon-android-auth://callback")
|
||||||
.appendQueryParameter("scope", "read write follow push")
|
.appendQueryParameter("scope", SCOPE)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
new CustomTabsIntent.Builder()
|
new CustomTabsIntent.Builder()
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.joinmastodon.android.fragments;
|
package org.joinmastodon.android.fragments;
|
||||||
|
|
||||||
import android.app.Fragment;
|
import android.app.Fragment;
|
||||||
|
import android.app.NotificationManager;
|
||||||
import android.content.res.Configuration;
|
import android.content.res.Configuration;
|
||||||
import android.graphics.Outline;
|
import android.graphics.Outline;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
@ -9,12 +10,14 @@ import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.ViewOutlineProvider;
|
import android.view.ViewOutlineProvider;
|
||||||
|
import android.view.ViewTreeObserver;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import org.joinmastodon.android.MastodonApp;
|
import org.joinmastodon.android.MastodonApp;
|
||||||
|
import org.joinmastodon.android.PushNotificationReceiver;
|
||||||
import org.joinmastodon.android.R;
|
import org.joinmastodon.android.R;
|
||||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||||
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
|
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
|
||||||
|
@ -70,6 +73,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||||
args.putBoolean("noAutoLoad", true);
|
args.putBoolean("noAutoLoad", true);
|
||||||
profileFragment=new ProfileFragment();
|
profileFragment=new ProfileFragment();
|
||||||
profileFragment.setArguments(args);
|
profileFragment.setArguments(args);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@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, notificationsFragment).hide(notificationsFragment)
|
||||||
.add(R.id.fragment_wrap, profileFragment).hide(profileFragment)
|
.add(R.id.fragment_wrap, profileFragment).hide(profileFragment)
|
||||||
.commit();
|
.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{
|
}else{
|
||||||
tabBar.selectTab(currentTab);
|
tabBar.selectTab(currentTab);
|
||||||
}
|
}
|
||||||
|
@ -174,6 +191,8 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||||
}else if(newFragment instanceof NotificationsFragment){
|
}else if(newFragment instanceof NotificationsFragment){
|
||||||
((NotificationsFragment) newFragment).loadData();
|
((NotificationsFragment) newFragment).loadData();
|
||||||
// TODO make an interface?
|
// TODO make an interface?
|
||||||
|
NotificationManager nm=getActivity().getSystemService(NotificationManager.class);
|
||||||
|
nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID);
|
||||||
}
|
}
|
||||||
currentTab=tab;
|
currentTab=tab;
|
||||||
((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this);
|
((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this);
|
||||||
|
|
|
@ -169,7 +169,8 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
|
||||||
@Override
|
@Override
|
||||||
protected void onDataLoaded(List<SearchResult> d, boolean more){
|
protected void onDataLoaded(List<SearchResult> d, boolean more){
|
||||||
super.onDataLoaded(d, more);
|
super.onDataLoaded(d, more);
|
||||||
progressVisibilityListener.onProgressVisibilityChanged(false);
|
if(progressVisibilityListener!=null)
|
||||||
|
progressVisibilityListener.onProgressVisibilityChanged(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -4,9 +4,11 @@ import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
import org.joinmastodon.android.api.ObjectValidationException;
|
import org.joinmastodon.android.api.ObjectValidationException;
|
||||||
import org.joinmastodon.android.api.RequiredField;
|
import org.joinmastodon.android.api.RequiredField;
|
||||||
|
import org.parceler.Parcel;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Parcel
|
||||||
public class Notification extends BaseModel implements DisplayItemsParent{
|
public class Notification extends BaseModel implements DisplayItemsParent{
|
||||||
@RequiredField
|
@RequiredField
|
||||||
public String id;
|
public String id;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,9 @@ import android.app.Activity;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.res.TypedArray;
|
import android.content.res.TypedArray;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.drawable.BitmapDrawable;
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
@ -362,4 +365,13 @@ public class UiUtils{
|
||||||
list.scrollToPosition(topItem);
|
list.scrollToPosition(topItem);
|
||||||
list.scrollBy(0, topItemOffset);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -223,4 +223,9 @@
|
||||||
<string name="recent_searches">Recent searches</string>
|
<string name="recent_searches">Recent searches</string>
|
||||||
<string name="step_x_of_n">Step %1$d of %2$d</string>
|
<string name="step_x_of_n">Step %1$d of %2$d</string>
|
||||||
<string name="skip">Skip</string>
|
<string name="skip">Skip</string>
|
||||||
|
<string name="notification_type_follow">New followers</string>
|
||||||
|
<string name="notification_type_favorite">Favorites</string>
|
||||||
|
<string name="notification_type_reblog">Boosts</string>
|
||||||
|
<string name="notification_type_mention">Mentions</string>
|
||||||
|
<string name="notification_type_poll">Polls</string>
|
||||||
</resources>
|
</resources>
|
Loading…
Reference in New Issue