Push notifications

This commit is contained in:
Thomas 2025-02-19 16:44:29 +01:00
parent 318a566e76
commit 38a630a4e5
17 changed files with 272 additions and 688 deletions

View file

@ -52,7 +52,6 @@ android {
}
sourceSets {
playstore {
manifest.srcFile "src/playstore/AndroidManifest.xml"
java.srcDirs = ['src/main/java', 'src/playstore/java']
res.srcDirs = ['src/main/res', 'src/playstore/res']
}
@ -157,9 +156,9 @@ dependencies {
implementation "ch.acra:acra-limiter:5.11.3"
implementation "ch.acra:acra-dialog:5.11.3"
implementation "com.madgag.spongycastle:bctls-jdk15on:1.58.0.0"
implementation 'com.github.UnifiedPush:android-connector:2.2.0'
// implementation 'com.github.UnifiedPush:android-foss_embedded_fcm_distributor:1.0.0-beta1'
playstoreImplementation('com.github.UnifiedPush:android-embedded_fcm_distributor:2.2.0') {
implementation 'org.unifiedpush.android:connector:3.0.4'
playstoreImplementation('org.unifiedpush.android:embedded-fcm-distributor:3.0.0') {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'

View file

@ -467,18 +467,12 @@
</intent-filter>
</receiver>
<receiver
android:name=".mastodon.services.CustomReceiver"
android:enabled="true"
android:exported="true">
<service android:name=".mastodon.services.PushServiceImpl"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED" />
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</receiver>
</service>
<activity

View file

@ -21,6 +21,7 @@ import app.fedilab.android.mastodon.client.entities.api.Account;
import app.fedilab.android.mastodon.client.entities.api.Activity;
import app.fedilab.android.mastodon.client.entities.api.Emoji;
import app.fedilab.android.mastodon.client.entities.api.Instance;
import app.fedilab.android.mastodon.client.entities.api.InstanceV2;
import app.fedilab.android.mastodon.client.entities.api.Tag;
import retrofit2.Call;
import retrofit2.http.GET;
@ -32,6 +33,9 @@ public interface MastodonInstanceService {
@GET("instance")
Call<Instance> instance();
@GET("instance")
Call<InstanceV2> instanceV2();
@GET("instance/peers")
Call<List<String>> connectedInstance();

View file

@ -67,6 +67,7 @@ public interface MastodonNotificationsService {
@Field("subscription[endpoint]") String endpoint,
@Field("subscription[keys][p256dh]") String keys_p256dh,
@Field("subscription[keys][auth]") String keys_auth,
@Field("subscription[standard]") boolean standard,
@Field("data[alerts][follow]") boolean follow,
@Field("data[alerts][favourite]") boolean favourite,
@Field("data[alerts][reblog]") boolean reblog,

View file

@ -0,0 +1,70 @@
package app.fedilab.android.mastodon.client.entities.api;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/* Copyright 2025 Thomas Schneider
*
* This file is a part of Fedilab
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Fedilab is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Fedilab; if not,
* see <http://www.gnu.org/licenses>. */
public class InstanceV2 implements Serializable {
@SerializedName("domain")
public String domain;
@SerializedName("title")
public String title;
@SerializedName("version")
public String version;
@SerializedName("source_url")
public String sourceUrl;
@SerializedName("description")
public String description;
@SerializedName("configuration")
public Configuration configuration;
public static String serialize(InstanceV2 instance) {
Gson gson = new Gson();
try {
return gson.toJson(instance);
} catch (Exception e) {
return null;
}
}
public static InstanceV2 restore(String serialized) {
Gson gson = new Gson();
try {
return gson.fromJson(serialized, InstanceV2.class);
} catch (Exception e) {
return null;
}
}
public static class Configuration implements Serializable {
@SerializedName("vapid")
public VapId vapId;
}
public static class VapId implements Serializable {
@SerializedName("public_key")
public String publicKey;
}
}

View file

@ -22,6 +22,8 @@ public class PushSubscription {
public String id;
@SerializedName("endpoint")
public String endpoint;
@SerializedName("standard")
public String standard;
@SerializedName("policy")
public String policy;
@SerializedName("alerts")

View file

@ -1,289 +0,0 @@
package app.fedilab.android.mastodon.helper;
/* Copyright 2022 Thomas Schneider
*
* This file is a part of Fedilab
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Fedilab is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Fedilab; if not,
* see <http://www.gnu.org/licenses>. */
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Base64;
import androidx.preference.PreferenceManager;
import org.spongycastle.asn1.ASN1ObjectIdentifier;
import org.spongycastle.asn1.x9.ECNamedCurveTable;
import org.spongycastle.asn1.x9.X9ECParameters;
import org.spongycastle.crypto.params.ECNamedDomainParameters;
import org.spongycastle.crypto.params.ECPrivateKeyParameters;
import org.spongycastle.crypto.params.ECPublicKeyParameters;
import org.spongycastle.jce.spec.ECNamedCurveSpec;
import org.spongycastle.jce.spec.ECParameterSpec;
import org.spongycastle.jce.spec.ECPrivateKeySpec;
import org.spongycastle.jce.spec.ECPublicKeySpec;
import org.spongycastle.math.ec.ECCurve;
import org.spongycastle.math.ec.ECPoint;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Security;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
public class ECDH {
public static final String kp_public = "kp_public";
public static final String peer_public = "peer_public";
public static final String PROVIDER = org.spongycastle.jce.provider.BouncyCastleProvider.PROVIDER_NAME;
public static final String kp_private = "kp_private";
public static final String KEGEN_ALG = "ECDH";
public static final String name = "prime256v1";
private static final String kp_public_affine_x = "kp_public_affine_x";
private static final String kp_public_affine_y = "kp_public_affine_y";
private static ECDH instance;
static {
Security.addProvider(new org.spongycastle.jce.provider.BouncyCastleProvider());
}
public final KeyFactory kf;
private final KeyPairGenerator kpg;
private final String slug;
public ECDH(String slug) throws Exception {
if (slug == null) {
throw new Exception("slug cannot be null");
}
try {
kf = KeyFactory.getInstance(KEGEN_ALG, PROVIDER);
kpg = KeyPairGenerator.getInstance(KEGEN_ALG, PROVIDER);
this.slug = slug;
} catch (NoSuchAlgorithmException | NoSuchProviderException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
public static synchronized ECDH getInstance(String slug) throws Exception {
if (instance == null) {
instance = new ECDH(slug);
}
return instance;
}
public static String base64Encode(byte[] b) {
return Base64.encodeToString(
b, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP);
}
static byte[] base64Decode(String str) {
return Base64.decode(str, Base64.URL_SAFE);
}
synchronized KeyPair generateKeyPair()
throws Exception {
ECGenParameterSpec ecParamSpec = new ECGenParameterSpec(name);
kpg.initialize(ecParamSpec);
return kpg.generateKeyPair();
}
private byte[] generateSecret(PrivateKey myPrivKey, PublicKey otherPubKey) throws Exception {
KeyAgreement keyAgreement = KeyAgreement.getInstance(KEGEN_ALG);
keyAgreement.init(myPrivKey);
keyAgreement.doPhase(otherPubKey, true);
return keyAgreement.generateSecret();
}
synchronized KeyPair readKeyPair(Context context)
throws Exception {
return new KeyPair(readMyPublicKey(context), readMyPrivateKey(context));
}
@SuppressLint("ApplySharedPref")
public KeyPair newPair(Context context) {
SharedPreferences.Editor prefsEditor = PreferenceManager
.getDefaultSharedPreferences(context).edit();
KeyPair kp;
try {
kp = generateKeyPair();
} catch (Exception e) {
e.printStackTrace();
return null;
}
ECPublicKey key = (ECPublicKey) kp.getPublic();
byte[] x = key.getW().getAffineX().toByteArray();
byte[] y = key.getW().getAffineY().toByteArray();
BigInteger xbi = new BigInteger(1, x);
BigInteger ybi = new BigInteger(1, y);
X9ECParameters x9 = ECNamedCurveTable.getByName(name);
ASN1ObjectIdentifier oid = ECNamedCurveTable.getOID(name);
ECCurve curve = x9.getCurve();
ECPoint point = curve.createPoint(xbi, ybi);
ECNamedDomainParameters dParams = new ECNamedDomainParameters(oid,
x9.getCurve(), x9.getG(), x9.getN(), x9.getH(), x9.getSeed());
ECPublicKeyParameters pubKey = new ECPublicKeyParameters(point, dParams);
ECPrivateKeyParameters privateKey = new ECPrivateKeyParameters(new BigInteger(kp.getPrivate().getEncoded()), pubKey.getParameters());
byte[] privateKeyBytes = privateKey.getD().toByteArray();
String keyString = base64Encode(pubKey.getQ().getEncoded(false));
String keypString = base64Encode(privateKeyBytes);
prefsEditor.putString(kp_public + slug, keyString);
prefsEditor.putString(kp_public_affine_x + slug, key.getW().getAffineX().toString());
prefsEditor.putString(kp_public_affine_y + slug, key.getW().getAffineY().toString());
prefsEditor.putString(kp_private + slug, keypString);
prefsEditor.commit();
return kp;
}
synchronized PublicKey readMyPublicKey(Context context) throws Exception {
X9ECParameters x9 = ECNamedCurveTable.getByName(name);
ASN1ObjectIdentifier oid = ECNamedCurveTable.getOID(name);
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
BigInteger xbi = new BigInteger(prefs.getString(kp_public_affine_x + slug, "0"));
BigInteger ybi = new BigInteger(prefs.getString(kp_public_affine_y + slug, "0"));
ECNamedDomainParameters dParams = new ECNamedDomainParameters(oid,
x9.getCurve(), x9.getG(), x9.getN(), x9.getH(), x9.getSeed());
ECNamedCurveSpec ecNamedCurveSpec = new ECNamedCurveSpec(name, dParams.getCurve(), dParams.getG(), dParams.getN());
java.security.spec.ECPoint w = new java.security.spec.ECPoint(xbi, ybi);
return kf.generatePublic(new java.security.spec.ECPublicKeySpec(w, ecNamedCurveSpec));
}
public String uncryptMessage(Context context, String cyphered) {
byte[] privateKey = getSharedSecret(context);
try {
Cipher outCipher = Cipher.getInstance("ECIES", PROVIDER);
PrivateKey ddd = readPrivateKey(privateKey);
outCipher.init(Cipher.DECRYPT_MODE, readPrivateKey(privateKey));
byte[] plaintext = outCipher.doFinal(base64Decode(cyphered));
return new String(plaintext);
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
public PublicKey readPublicKey(String keyStr) throws Exception {
ECParameterSpec parameterSpec = org.spongycastle.jce.ECNamedCurveTable.getParameterSpec(name);
ECCurve curve = parameterSpec.getCurve();
ECPoint point = curve.decodePoint(base64Decode(keyStr));
ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, parameterSpec);
return kf.generatePublic(pubSpec);
}
public PrivateKey readPrivateKey(byte[] key) throws Exception {
ECParameterSpec parameterSpec = org.spongycastle.jce.ECNamedCurveTable.getParameterSpec(name);
ECPrivateKeySpec pubSpec = new ECPrivateKeySpec(new BigInteger(1, key), parameterSpec);
return kf.generatePrivate(pubSpec);
}
synchronized PrivateKey readMyPrivateKey(Context context) throws Exception {
X9ECParameters x9 = ECNamedCurveTable.getByName(name);
ASN1ObjectIdentifier oid = ECNamedCurveTable.getOID(name);
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
BigInteger ybi = new BigInteger(prefs.getString(kp_public_affine_y + slug, "0"));
ECNamedDomainParameters dParams = new ECNamedDomainParameters(oid,
x9.getCurve(), x9.getG(), x9.getN(), x9.getH(), x9.getSeed());
ECNamedCurveSpec ecNamedCurveSpec = new ECNamedCurveSpec(name, dParams.getCurve(), dParams.getG(), dParams.getN());
return kf.generatePrivate(new java.security.spec.ECPrivateKeySpec(ybi, ecNamedCurveSpec));
}
private synchronized KeyPair getPair(Context context) {
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
String strPub = prefs.getString(kp_public + slug, "");
String strPriv = prefs.getString(kp_private + slug, "");
if (strPub.trim().isEmpty() || strPriv.trim().isEmpty()) {
return newPair(context);
}
try {
return readKeyPair(context);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
PublicKey getServerKey(Context context) throws Exception {
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
String serverKey = prefs.getString(peer_public + slug, "");
return readPublicKey(serverKey);
}
@SuppressWarnings({"unused", "RedundantSuppression"})
public byte[] getSharedSecret(Context context) {
try {
KeyPair keyPair = getPair(context);
if (keyPair != null) {
return generateSecret(keyPair.getPrivate(), getServerKey(context));
}
} catch (Exception e) {
e.printStackTrace();
return null;
}
return null;
}
public String getPublicKey(Context context) {
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
return prefs.getString(kp_public + slug, "");
}
@SuppressLint("ApplySharedPref")
public void saveServerKey(Context context, String strPeerPublic) {
SharedPreferences.Editor prefsEditor = PreferenceManager
.getDefaultSharedPreferences(context).edit();
prefsEditor.putString(peer_public + slug, strPeerPublic);
prefsEditor.commit();
}
}

View file

@ -1,239 +0,0 @@
package app.fedilab.android.mastodon.helper;
/* Copyright 2022 Thomas Schneider
*
* This file is a part of Fedilab
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Fedilab is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Fedilab; if not,
* see <http://www.gnu.org/licenses>. */
import static app.fedilab.android.mastodon.client.entities.app.StatusCache.restoreNotificationFromString;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Base64;
import androidx.preference.PreferenceManager;
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.Security;
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.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import app.fedilab.android.mastodon.client.entities.api.Notification;
public class ECDHFedilab {
public static final String kp_public = "kp_public";
public static final String peer_public = "peer_public";
public static final String 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};
static {
Security.addProvider(new org.spongycastle.jce.provider.BouncyCastleProvider());
}
private final KeyPairGenerator kpg;
private final PublicKey publicKey;
private final String encodedPublicKey;
private final byte[] authKey;
private final String slug;
private final String pushPublicKey;
private final String encodedAuthKey;
private final String pushAccountID;
private final String pushPrivateKey;
PrivateKey privateKey;
private String pushPrivateKe;
public ECDHFedilab(Context context, String slug) throws Exception {
if (slug == null) {
throw new Exception("slug cannot be null");
}
try {
kpg = KeyPairGenerator.getInstance("EC");
ECGenParameterSpec spec = new ECGenParameterSpec("prime256v1");
kpg.initialize(spec);
KeyPair keyPair = kpg.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];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(authKey);
byte[] randomAccountID = new byte[16];
secureRandom.nextBytes(randomAccountID);
pushPrivateKey = Base64.encodeToString(privateKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
pushPublicKey = Base64.encodeToString(publicKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
encodedAuthKey = Base64.encodeToString(authKey, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
pushAccountID = Base64.encodeToString(randomAccountID, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
SharedPreferences.Editor prefsEditor = PreferenceManager
.getDefaultSharedPreferences(context).edit();
prefsEditor.putString("pushPrivateKey" + slug, pushPrivateKey);
prefsEditor.putString("pushPublicKey" + slug, pushPublicKey);
prefsEditor.putString("encodedAuthKey" + slug, encodedAuthKey);
prefsEditor.putString("pushAccountID" + slug, pushAccountID);
prefsEditor.apply();
this.slug = slug;
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
public static String getServerKey(Context context, String slug) {
SharedPreferences sharedPreferences = PreferenceManager
.getDefaultSharedPreferences(context);
return sharedPreferences.getString("server_key" + slug, null);
}
private static 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;
}
public static Notification decryptNotification(Context context, String slug, byte[] messageEncrypted) {
SharedPreferences sharedPreferences = PreferenceManager
.getDefaultSharedPreferences(context);
String pushPrivateKey = sharedPreferences.getString("pushPrivateKey" + slug, null);
String pushPublicKey = sharedPreferences.getString("pushPublicKey" + slug, null);
String encodedAuthKey = sharedPreferences.getString("encodedAuthKey" + slug, null);
sharedPreferences.getString("pushAccountID" + slug, null);
PublicKey serverKey = null;
serverKey = deserializeRawPublicKey(Base64.decode(getServerKey(context, slug), Base64.URL_SAFE));
PrivateKey privateKey;
PublicKey publicKey;
byte[] authKey;
try {
KeyFactory kf = KeyFactory.getInstance("EC");
privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.decode(pushPrivateKey, Base64.URL_SAFE)));
publicKey = kf.generatePublic(new X509EncodedKeySpec(Base64.decode(pushPublicKey, Base64.URL_SAFE)));
authKey = Base64.decode(encodedAuthKey, Base64.URL_SAFE);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
e.printStackTrace();
return null;
}
byte[] sharedSecret;
try {
KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH");
keyAgreement.init(privateKey);
keyAgreement.doPhase(serverKey, true);
sharedSecret = keyAgreement.generateSecret();
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
return null;
}
byte[] secondSaltInfo = "Content-Encoding: auth\0".getBytes(StandardCharsets.UTF_8);
byte[] deriveKey;
try {
deriveKey = deriveKey(authKey, sharedSecret, secondSaltInfo, 32);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
return null;
}
String decryptedStr;
try {
SecretKeySpec aesKey = new SecretKeySpec(deriveKey, "AES");
byte[] iv = Arrays.copyOfRange(messageEncrypted, 0, 12);
byte[] ciphertext = Arrays.copyOfRange(messageEncrypted, 12, messageEncrypted.length); // Separate ciphertext (the MAC is implicitly separated from the ciphertext)
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gCMParameterSpec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.DECRYPT_MODE, aesKey, gCMParameterSpec);
byte[] decrypted = cipher.doFinal(ciphertext);
decryptedStr = new String(decrypted, 2, decrypted.length - 2, StandardCharsets.UTF_8);
} catch (NoSuchAlgorithmException | NoSuchPaddingException |
InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException |
IllegalBlockSizeException e) {
e.printStackTrace();
return null;
}
return restoreNotificationFromString(decryptedStr);
}
protected static 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 e) {
e.printStackTrace();
}
return null;
}
private static 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);
}
public String getPublicKey() {
return this.encodedPublicKey;
}
public String getAuthKey() {
return this.encodedAuthKey;
}
}

View file

@ -15,6 +15,7 @@ package app.fedilab.android.mastodon.helper;
* see <http://www.gnu.org/licenses>. */
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
@ -35,20 +36,26 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.unifiedpush.android.connector.UnifiedPush;
import java.util.ArrayList;
import java.net.IDN;
import java.util.List;
import java.util.concurrent.TimeUnit;
import app.fedilab.android.R;
import app.fedilab.android.mastodon.client.endpoints.MastodonInstanceService;
import app.fedilab.android.mastodon.client.entities.api.InstanceV2;
import app.fedilab.android.mastodon.client.entities.app.Account;
import app.fedilab.android.mastodon.client.entities.app.BaseAccount;
import app.fedilab.android.mastodon.jobs.NotificationsWorker;
import okhttp3.OkHttpClient;
import retrofit2.Call;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class PushHelper {
public static void startStreaming(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String typeOfNotification = prefs.getString(context.getString(R.string.SET_NOTIFICATION_TYPE), "PUSH_NOTIFICATIONS");
switch (typeOfNotification) {
@ -57,8 +64,8 @@ public class PushHelper {
List<BaseAccount> accounts = new Account(context).getPushNotificationAccounts();
Handler mainHandler = new Handler(Looper.getMainLooper());
Runnable myRunnable = () -> {
List<String> distributors = UnifiedPush.getDistributors(context, new ArrayList<>());
if (distributors.size() == 0) {
List<String> distributors = UnifiedPush.getDistributors(context);
if (distributors.isEmpty()) {
AlertDialog.Builder alert = new MaterialAlertDialogBuilder(context);
alert.setTitle(R.string.no_distributors_found);
final TextView message = new TextView(context);
@ -95,7 +102,7 @@ public class PushHelper {
new Thread(() -> {
List<BaseAccount> accounts = new Account(context).getPushNotificationAccounts();
for (BaseAccount account : accounts) {
((Activity) context).runOnUiThread(() -> UnifiedPush.unregisterApp(context, account.user_id + "@" + account.instance));
((Activity) context).runOnUiThread(() -> UnifiedPush.unregister(context, account.user_id + "@" + account.instance));
}
}).start();
break;
@ -108,7 +115,7 @@ public class PushHelper {
List<BaseAccount> accounts = new Account(context).getPushNotificationAccounts();
if (accounts != null) {
for (BaseAccount account : accounts) {
((Activity) context).runOnUiThread(() -> UnifiedPush.unregisterApp(context, account.user_id + "@" + account.instance));
((Activity) context).runOnUiThread(() -> UnifiedPush.unregister(context, account.user_id + "@" + account.instance));
}
}
}).start();
@ -125,13 +132,49 @@ public class PushHelper {
if (accounts == null) {
return;
}
List<String> distributors = UnifiedPush.getDistributors(context, new ArrayList<>());
if (distributors.size() == 1 || !UnifiedPush.getDistributor(context).isEmpty()) {
List<String> distributors = UnifiedPush.getDistributors(context);
if (!distributors.isEmpty()) {
if (distributors.size() == 1) {
UnifiedPush.saveDistributor(context, distributors.get(0));
}
final OkHttpClient okHttpClient = Helper.myOkHttpClient(context.getApplicationContext());
for (BaseAccount account : accounts) {
UnifiedPush.registerApp(context, account.user_id + "@" + account.instance, new ArrayList<>(), "");
new Thread(()->{
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://" + (account.instance != null ? IDN.toASCII(account.instance, IDN.ALLOW_UNASSIGNED) : null) + "/api/v2/")
.addConverterFactory(GsonConverterFactory.create(Helper.getDateBuilder()))
.client(okHttpClient)
.build();
MastodonInstanceService mastodonInstanceService = retrofit.create(MastodonInstanceService.class);
Call<InstanceV2> instanceV2Call = mastodonInstanceService.instanceV2();
String vapid = null;
if (instanceV2Call != null) {
try {
Response<InstanceV2> instanceResponse = instanceV2Call.execute();
if (instanceResponse.isSuccessful()) {
InstanceV2 instanceV2 = instanceResponse.body();
if (instanceV2 != null && instanceV2.configuration.vapId != null) {
vapid = instanceV2.configuration.vapId.publicKey;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
Handler mainHandler = new Handler(Looper.getMainLooper());
String finalVapid = vapid!=null?vapid.replaceAll("=",""):null;
Runnable myRunnable = () -> {
try {
UnifiedPush.register(context, account.user_id + "@" + account.instance, null, finalVapid);
}catch (Exception e){
e.printStackTrace();
}
};
mainHandler.post(myRunnable);
}).start();
}
return;
}
@ -143,7 +186,7 @@ public class PushHelper {
String distributor = distributorsStr[item];
UnifiedPush.saveDistributor(context, distributor);
for (BaseAccount account : accounts) {
UnifiedPush.registerApp(context, account.user_id + "@" + account.instance, new ArrayList<>(), "");
UnifiedPush.register(context, account.user_id + "@" + account.instance, null, null);
}
dialog.dismiss();
});

View file

@ -15,14 +15,19 @@ package app.fedilab.android.mastodon.helper;
* see <http://www.gnu.org/licenses>. */
import static app.fedilab.android.mastodon.helper.Helper.TAG;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import org.unifiedpush.android.connector.data.PushEndpoint;
import java.net.IDN;
import app.fedilab.android.R;
@ -41,22 +46,13 @@ import retrofit2.converter.gson.GsonConverterFactory;
public class PushNotifications {
public static void registerPushNotifications(Context context, String endpoint, String slug) {
public static void registerPushNotifications(Context context, PushEndpoint pushEndpoint, String slug) {
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
ECDHFedilab ecdh = null;
try {
ecdh = new ECDHFedilab(context, slug);
} catch (Exception e) {
e.printStackTrace();
}
if (ecdh == null) {
return;
}
String pubKey = ecdh.getPublicKey();
String auth = ecdh.getAuthKey();
String pubKey = pushEndpoint.getPubKeySet().getPubKey();
String auth =pushEndpoint.getPubKeySet().getAuth();
boolean notif_follow = prefs.getBoolean(context.getString(R.string.SET_NOTIF_FOLLOW), true);
@ -84,9 +80,10 @@ public class PushNotifications {
PushSubscription pushSubscription;
Call<PushSubscription> pushSubscriptionCall = mastodonNotificationsService.pushSubscription(
accountDb.token,
endpoint,
pushEndpoint.getUrl(),
pubKey,
auth,
true,
notif_follow,
notif_fav,
notif_share,
@ -101,6 +98,7 @@ public class PushNotifications {
Response<PushSubscription> pushSubscriptionResponse = pushSubscriptionCall.execute();
if (pushSubscriptionResponse.isSuccessful()) {
pushSubscription = pushSubscriptionResponse.body();
if (pushSubscription != null) {
pushSubscription.server_key = pushSubscription.server_key.replace('/', '_');
pushSubscription.server_key = pushSubscription.server_key.replace('+', '-');

View file

@ -1,92 +0,0 @@
package app.fedilab.android.mastodon.services;
/* Copyright 2022 Thomas Schneider
*
* This file is a part of Fedilab
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Fedilab is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Fedilab; if not,
* see <http://www.gnu.org/licenses>. */
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.unifiedpush.android.connector.MessagingReceiver;
import app.fedilab.android.R;
import app.fedilab.android.mastodon.helper.NotificationsHelper;
import app.fedilab.android.mastodon.helper.PushNotifications;
public class CustomReceiver extends MessagingReceiver {
public CustomReceiver() {
super();
}
@Override
public void onMessage(@NotNull Context context, @NotNull byte[] message, @NotNull String slug) {
// Called when a new message is received. The message contains the full POST body of the push message
new Thread(() -> {
try {
/*Notification notification = ECDHFedilab.decryptNotification(context, slug, message);
Log.v(Helper.TAG,"notification: " + notification);
if(notification != null) {
Log.v(Helper.TAG,"id: " + notification.id);
}
*/
NotificationsHelper.task(context, slug);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
@Override
public void onReceive(@NonNull Context context, @NonNull Intent intent) {
super.onReceive(context, intent);
}
@Override
public void onNewEndpoint(@Nullable Context context, @NotNull String endpoint, @NotNull String slug) {
if (context != null) {
synchronized (this) {
SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(context);
String storedEnpoint = sharedpreferences.getString(context.getString(R.string.SET_STORED_ENDPOINT) + slug, null);
if (storedEnpoint == null || !storedEnpoint.equals(endpoint)) {
PushNotifications
.registerPushNotifications(context, endpoint, slug);
SharedPreferences.Editor editor = sharedpreferences.edit();
editor.putString(context.getString(R.string.SET_STORED_ENDPOINT) + slug, endpoint);
editor.commit();
}
}
}
}
@Override
public void onRegistrationFailed(@Nullable Context context, @NotNull String s) {
}
@Override
public void onUnregistered(@Nullable Context context, @NotNull String s) {
}
}

View file

@ -0,0 +1,75 @@
package app.fedilab.android.mastodon.services;
import static app.fedilab.android.mastodon.helper.Helper.TAG;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import org.unifiedpush.android.connector.FailedReason;
import org.unifiedpush.android.connector.PushService;
import org.unifiedpush.android.connector.data.PushEndpoint;
import org.unifiedpush.android.connector.data.PushMessage;
import app.fedilab.android.R;
import app.fedilab.android.mastodon.helper.NotificationsHelper;
import app.fedilab.android.mastodon.helper.PushNotifications;
public class PushServiceImpl extends PushService {
@Override
public void onCreate() {
super.onCreate();
}
@Override
public void onMessage(@NonNull PushMessage pushMessage, @NonNull String slug) {
new Thread(() -> {
try {
/*if( pushMessage.getDecrypted()) {
String decryptedMessage = new String(pushMessage.getContent(), StandardCharsets.UTF_8);
JSONObject decryptedMessageJSON = new JSONObject(decryptedMessage);
} else {
}*/
NotificationsHelper.task(getApplicationContext(), slug);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
@Override
public void onNewEndpoint(@NonNull PushEndpoint pushEndpoint, @NonNull String slug) {
if (getApplicationContext() != null) {
synchronized (this) {
SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
String storedEnpoint = sharedpreferences.getString(getApplicationContext().getString(R.string.SET_STORED_ENDPOINT) + slug, null);
if (storedEnpoint == null || !storedEnpoint.equals(pushEndpoint.getUrl())) {
PushNotifications
.registerPushNotifications(getApplicationContext(), pushEndpoint, slug);
SharedPreferences.Editor editor = sharedpreferences.edit();
editor.putString(getApplicationContext().getString(R.string.SET_STORED_ENDPOINT) + slug, pushEndpoint.getUrl());
editor.commit();
}
}
}
}
@Override
public void onRegistrationFailed(@NonNull FailedReason failedReason, @NonNull String s) {
}
@Override
public void onUnregistered(@NonNull String s) {
}
}

View file

@ -35,6 +35,7 @@ import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceScreen;
import org.unifiedpush.android.connector.UnifiedPush;
import org.unifiedpush.android.connector.internal.Store;
import java.util.ArrayList;
import java.util.List;
@ -138,8 +139,8 @@ public class FragmentNotificationsSettings extends PreferenceFragmentCompat impl
preferenceScreen.removePreferenceRecursively("SET_NOTIFICATION_DELAY_VALUE");
}
if (SET_PUSH_DISTRIBUTOR != null) {
List<String> distributors = UnifiedPush.getDistributors(requireActivity(), new ArrayList<>());
SET_PUSH_DISTRIBUTOR.setValue(UnifiedPush.getDistributor(requireActivity()));
List<String> distributors = UnifiedPush.getDistributors(requireActivity());
SET_PUSH_DISTRIBUTOR.setValue("");
SET_PUSH_DISTRIBUTOR.setEntries(distributors.toArray(new String[0]));
SET_PUSH_DISTRIBUTOR.setEntryValues(distributors.toArray(new String[0]));
}

View file

@ -36,6 +36,7 @@ import app.fedilab.android.mastodon.client.entities.api.Emoji;
import app.fedilab.android.mastodon.client.entities.api.EmojiInstance;
import app.fedilab.android.mastodon.client.entities.api.Instance;
import app.fedilab.android.mastodon.client.entities.api.InstanceInfo;
import app.fedilab.android.mastodon.client.entities.api.InstanceV2;
import app.fedilab.android.mastodon.exception.DBException;
import app.fedilab.android.mastodon.helper.Helper;
import okhttp3.OkHttpClient;
@ -50,6 +51,7 @@ public class InstancesVM extends AndroidViewModel {
final OkHttpClient okHttpClient = Helper.myOkHttpClient(getApplication().getApplicationContext());
private MutableLiveData<EmojiInstance> emojiInstanceMutableLiveData;
private MutableLiveData<InstanceInfo> instanceInfoMutableLiveData;
private MutableLiveData<String> vapidMutableLiveData;
public InstancesVM(@NonNull Application application) {
super(application);
@ -65,6 +67,16 @@ public class InstancesVM extends AndroidViewModel {
return retrofit.create(MastodonInstanceService.class);
}
private MastodonInstanceService initV2(String instance) {
Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").create();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://" + (instance != null ? IDN.toASCII(instance, IDN.ALLOW_UNASSIGNED) : null) + "/api/v2/")
.addConverterFactory(GsonConverterFactory.create(Helper.getDateBuilder()))
.client(okHttpClient)
.build();
return retrofit.create(MastodonInstanceService.class);
}
public LiveData<EmojiInstance> getEmoji(@NonNull String instance) {
MastodonInstanceService mastodonInstanceService = init(instance);
emojiInstanceMutableLiveData = new MutableLiveData<>();
@ -146,4 +158,34 @@ public class InstancesVM extends AndroidViewModel {
}).start();
return instanceInfoMutableLiveData;
}
public LiveData<String> getInstanceVapid(@NonNull String instance) {
MastodonInstanceService mastodonInstanceV2Service = initV2(instance);
vapidMutableLiveData = new MutableLiveData<>();
new Thread(() -> {
String vapid = null;
Call<InstanceV2> instanceV2Call = mastodonInstanceV2Service.instanceV2();
if (instanceV2Call != null) {
try {
Response<InstanceV2> instanceResponse = instanceV2Call.execute();
if (instanceResponse.isSuccessful()) {
InstanceV2 instanceV2 = instanceResponse.body();
if (instanceV2 != null && instanceV2.configuration.vapId != null) {
vapid = instanceV2.configuration.vapId.publicKey;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
Handler mainHandler = new Handler(Looper.getMainLooper());
String finalVapid = vapid;
Runnable myRunnable = () -> vapidMutableLiveData.setValue(finalVapid);
mainHandler.post(myRunnable);
}).start();
return vapidMutableLiveData;
}
}

View file

@ -14,9 +14,12 @@ package app.fedilab.android.mastodon.viewmodel.mastodon;
* You should have received a copy of the GNU General Public License along with Fedilab; if not,
* see <http://www.gnu.org/licenses>. */
import static app.fedilab.android.mastodon.helper.Helper.TAG;
import android.app.Application;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
@ -309,7 +312,7 @@ public class NotificationsVM extends AndroidViewModel {
MastodonNotificationsService mastodonNotificationsService = init(instance);
new Thread(() -> {
PushSubscription pushSubscription = null;
Call<PushSubscription> pushSubscriptionCall = mastodonNotificationsService.pushSubscription(token, endpoint, keys_p256dh, keys_auth, follow, favourite, reblog, mention, poll, status, updates, signup, report, "all");
Call<PushSubscription> pushSubscriptionCall = mastodonNotificationsService.pushSubscription(token, endpoint, keys_p256dh, keys_auth, true, follow, favourite, reblog, mention, poll, status, updates, signup, report, "all");
if (pushSubscriptionCall != null) {
try {
Response<PushSubscription> pushSubscriptionResponse = pushSubscriptionCall.execute();

View file

@ -5,17 +5,6 @@
<application android:name=".MainApplication">
<receiver
android:name=".services.EmbeddedDistrib"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.distributor.feature.BYTES_MESSAGE" />
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
<action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
</intent-filter>
</receiver>
<activity
android:name=".expandedcontrols.ExpandedControlsActivity"

View file

@ -1,17 +0,0 @@
package app.fedilab.android.services;
import android.content.Context;
import androidx.annotation.Nullable;
import org.jetbrains.annotations.NotNull;
import org.unifiedpush.android.embedded_fcm_distributor.EmbeddedDistributorReceiver;
public class EmbeddedDistrib extends EmbeddedDistributorReceiver {
@Override
public @NotNull
String getEndpoint(@Nullable Context context, @NotNull String token, @NotNull String instance) {
return "https://gotify.fedilab.app/FCM?v2&token=" + token + "&instance=" + instance;
}
}