From b1f95d2e39e64f3f066afa410c69d0704b1fdfb5 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Wed, 4 Jan 2023 10:23:20 +0100 Subject: [PATCH] integrate UnifiedPush distributor --- src/main/AndroidManifest.xml | 18 ++ .../conversations/parser/AbstractParser.java | 32 ++ .../siacs/conversations/parser/IqParser.java | 18 ++ .../persistance/UnifiedPushDatabase.java | 244 +++++++++++++++ .../services/UnifiedPushBroker.java | 277 ++++++++++++++++++ .../services/UnifiedPushDistributor.java | 152 ++++++++++ .../services/XmppConnectionService.java | 30 +- .../conversations/ui/SettingsActivity.java | 38 ++- .../eu/siacs/conversations/xml/Namespace.java | 1 + src/main/res/values/defaults.xml | 2 + src/main/res/values/strings.xml | 6 + src/main/res/xml/preferences.xml | 15 + 12 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java create mode 100644 src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java create mode 100644 src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 24265da618d211defb27384cb963fc236a4a3a0a..c18addf271067674e66a94d9ba2724dc3a566e8f 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -67,6 +67,9 @@ + + + @@ -102,6 +105,21 @@ + + + + + + + + + + + + diff --git a/src/main/java/eu/siacs/conversations/parser/AbstractParser.java b/src/main/java/eu/siacs/conversations/parser/AbstractParser.java index f4b01b7d3199cf821732226b79cc1c998296ee89..5de637399fb571bfaff80675cee50a2275cc8d60 100644 --- a/src/main/java/eu/siacs/conversations/parser/AbstractParser.java +++ b/src/main/java/eu/siacs/conversations/parser/AbstractParser.java @@ -4,6 +4,7 @@ package eu.siacs.conversations.parser; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Locale; @@ -86,6 +87,37 @@ public abstract class AbstractParser { return Math.min(dateFormat.parse(timestamp).getTime()+ms, System.currentTimeMillis()); } + public static long getTimestamp(final String input) throws ParseException { + if (input == null) { + throw new IllegalArgumentException("timestamp should not be null"); + } + final String timestamp = input.replace("Z", "+0000"); + final SimpleDateFormat simpleDateFormat = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US); + final long milliseconds = getMilliseconds(timestamp); + final String formatted = + timestamp.substring(0, 19) + timestamp.substring(timestamp.length() - 5); + final Date date = simpleDateFormat.parse(formatted); + if (date == null) { + throw new IllegalArgumentException("Date was null"); + } + return date.getTime() + milliseconds; + } + + private static long getMilliseconds(final String timestamp) { + if (timestamp.length() >= 25 && timestamp.charAt(19) == '.') { + final String millis = timestamp.substring(19, timestamp.length() - 5); + try { + double fractions = Double.parseDouble("0" + millis); + return Math.round(1000 * fractions); + } catch (NumberFormatException e) { + return 0; + } + } else { + return 0; + } + } + protected void updateLastseen(final Account account, final Jid from) { final Contact contact = account.getRoster().getContact(from); contact.setLastResource(from.isBareJid() ? "" : from.getResource()); diff --git a/src/main/java/eu/siacs/conversations/parser/IqParser.java b/src/main/java/eu/siacs/conversations/parser/IqParser.java index d02d69ddca3e6fbba2086a12a03162102f6b1a62..0c08c557ebba9d8a052fad0cd33bb7079dcd8758 100644 --- a/src/main/java/eu/siacs/conversations/parser/IqParser.java +++ b/src/main/java/eu/siacs/conversations/parser/IqParser.java @@ -452,6 +452,24 @@ public class IqParser extends AbstractParser implements OnIqPacketReceived { response = mXmppConnectionService.getIqGenerator().entityTimeResponse(packet); } mXmppConnectionService.sendIqPacket(account, response, null); + } else if (packet.hasChild("push", Namespace.UNIFIED_PUSH) && packet.getType() == IqPacket.TYPE.SET) { + final Jid transport = packet.getFrom(); + final Element push = packet.findChild("push", Namespace.UNIFIED_PUSH); + final boolean success = + push != null + && mXmppConnectionService.processUnifiedPushMessage( + account, transport, push); + final IqPacket response; + if (success) { + response = packet.generateResponse(IqPacket.TYPE.RESULT); + } else { + response = packet.generateResponse(IqPacket.TYPE.ERROR); + final Element error = response.addChild("error"); + error.setAttribute("type", "cancel"); + error.setAttribute("code", "404"); + error.addChild("item-not-found", "urn:ietf:params:xml:ns:xmpp-stanzas"); + } + mXmppConnectionService.sendIqPacket(account, response, null); } else { if (packet.getType() == IqPacket.TYPE.GET || packet.getType() == IqPacket.TYPE.SET) { final IqPacket response = packet.generateResponse(IqPacket.TYPE.ERROR); diff --git a/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java b/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java new file mode 100644 index 0000000000000000000000000000000000000000..9b17406f7641ce5711c072d78ec79b7be5c4efcb --- /dev/null +++ b/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java @@ -0,0 +1,244 @@ +package eu.siacs.conversations.persistance; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import com.google.common.base.Optional; +import com.google.common.collect.ImmutableList; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +import eu.siacs.conversations.Config; + +public class UnifiedPushDatabase extends SQLiteOpenHelper { + private static final String DATABASE_NAME = "unified-push-distributor"; + private static final int DATABASE_VERSION = 1; + + private static UnifiedPushDatabase instance; + + public static UnifiedPushDatabase getInstance(final Context context) { + synchronized (UnifiedPushDatabase.class) { + if (instance == null) { + instance = new UnifiedPushDatabase(context.getApplicationContext()); + } + return instance; + } + } + + private UnifiedPushDatabase(@Nullable Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(final SQLiteDatabase sqLiteDatabase) { + sqLiteDatabase.execSQL( + "CREATE TABLE push (account TEXT, transport TEXT, application TEXT NOT NULL, instance TEXT NOT NULL UNIQUE, endpoint TEXT, expiration NUMBER DEFAULT 0)"); + } + + public boolean register(final String application, final String instance) { + final SQLiteDatabase sqLiteDatabase = getWritableDatabase(); + sqLiteDatabase.beginTransaction(); + final Optional existingApplication; + try (final Cursor cursor = + sqLiteDatabase.query( + "push", + new String[] {"application"}, + "instance=?", + new String[] {instance}, + null, + null, + null)) { + if (cursor != null && cursor.moveToFirst()) { + existingApplication = Optional.of(cursor.getString(0)); + } else { + existingApplication = Optional.absent(); + } + } + if (existingApplication.isPresent()) { + sqLiteDatabase.setTransactionSuccessful(); + sqLiteDatabase.endTransaction(); + return application.equals(existingApplication.get()); + } + final ContentValues contentValues = new ContentValues(); + contentValues.put("application", application); + contentValues.put("instance", instance); + contentValues.put("expiration", 0); + final long inserted = sqLiteDatabase.insert("push", null, contentValues); + if (inserted > 0) { + Log.d(Config.LOGTAG, "inserted new application/instance tuple into unified push db"); + } + sqLiteDatabase.setTransactionSuccessful(); + sqLiteDatabase.endTransaction(); + return true; + } + + public List getRenewals(final String account, final String transport) { + final ImmutableList.Builder renewalBuilder = ImmutableList.builder(); + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + try (final Cursor cursor = + sqLiteDatabase.query( + "push", + new String[] {"application", "instance"}, + "account <> ? OR transport <> ? OR expiration < " + + System.currentTimeMillis(), + new String[] {account, transport}, + null, + null, + null)) { + while (cursor != null && cursor.moveToNext()) { + renewalBuilder.add( + new PushTarget( + cursor.getString(cursor.getColumnIndexOrThrow("application")), + cursor.getString(cursor.getColumnIndexOrThrow("instance")))); + } + } + return renewalBuilder.build(); + } + + public ApplicationEndpoint getEndpoint( + final String account, final String transport, final String instance) { + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + try (final Cursor cursor = + sqLiteDatabase.query( + "push", + new String[] {"application", "endpoint"}, + "account = ? AND transport = ? AND instance = ? ", + new String[] {account, transport, instance}, + null, + null, + null)) { + if (cursor != null && cursor.moveToFirst()) { + return new ApplicationEndpoint( + cursor.getString(cursor.getColumnIndexOrThrow("application")), + cursor.getString(cursor.getColumnIndexOrThrow("endpoint"))); + } + } + return null; + } + + @Override + public void onUpgrade( + final SQLiteDatabase sqLiteDatabase, final int oldVersion, final int newVersion) {} + + public boolean updateEndpoint( + final String instance, + final String account, + final String transport, + final String endpoint, + final long expiration) { + final SQLiteDatabase sqLiteDatabase = getWritableDatabase(); + sqLiteDatabase.beginTransaction(); + final String existingEndpoint; + try (final Cursor cursor = + sqLiteDatabase.query( + "push", + new String[] {"endpoint"}, + "instance=?", + new String[] {instance}, + null, + null, + null)) { + if (cursor != null && cursor.moveToFirst()) { + existingEndpoint = cursor.getString(0); + } else { + existingEndpoint = null; + } + } + final ContentValues contentValues = new ContentValues(); + contentValues.put("account", account); + contentValues.put("transport", transport); + contentValues.put("endpoint", endpoint); + contentValues.put("expiration", expiration); + sqLiteDatabase.update("push", contentValues, "instance=?", new String[] {instance}); + sqLiteDatabase.setTransactionSuccessful(); + sqLiteDatabase.endTransaction(); + return !endpoint.equals(existingEndpoint); + } + + public List getPushTargets(final String account, final String transport) { + final ImmutableList.Builder renewalBuilder = ImmutableList.builder(); + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + try (final Cursor cursor = + sqLiteDatabase.query( + "push", + new String[] {"application", "instance"}, + "account = ?", + new String[] {account}, + null, + null, + null)) { + while (cursor != null && cursor.moveToNext()) { + renewalBuilder.add( + new PushTarget( + cursor.getString(cursor.getColumnIndexOrThrow("application")), + cursor.getString(cursor.getColumnIndexOrThrow("instance")))); + } + } + return renewalBuilder.build(); + } + + public boolean deleteInstance(final String instance) { + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + final int rows = sqLiteDatabase.delete("push", "instance=?", new String[] {instance}); + return rows >= 1; + } + + public boolean deleteApplication(final String application) { + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + final int rows = sqLiteDatabase.delete("push", "application=?", new String[] {application}); + return rows >= 1; + } + + public static class ApplicationEndpoint { + public final String application; + public final String endpoint; + + public ApplicationEndpoint(String application, String endpoint) { + this.application = application; + this.endpoint = endpoint; + } + } + + public static class PushTarget { + public final String application; + public final String instance; + + public PushTarget(final String application, final String instance) { + this.application = application; + this.instance = instance; + } + + @NotNull + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("application", application) + .add("instance", instance) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PushTarget that = (PushTarget) o; + return Objects.equal(application, that.application) + && Objects.equal(instance, that.instance); + } + + @Override + public int hashCode() { + return Objects.hashCode(application, instance); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java new file mode 100644 index 0000000000000000000000000000000000000000..101a09fc3542cdd5e002dac0bbab71f541de5429 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java @@ -0,0 +1,277 @@ +package eu.siacs.conversations.services; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.preference.PreferenceManager; +import android.util.Log; + +import com.google.common.base.Optional; +import com.google.common.base.Strings; +import com.google.common.collect.Iterables; +import com.google.common.io.BaseEncoding; + +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.parser.AbstractParser; +import eu.siacs.conversations.persistance.UnifiedPushDatabase; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +public class UnifiedPushBroker { + + private final XmppConnectionService service; + + public UnifiedPushBroker(final XmppConnectionService xmppConnectionService) { + this.service = xmppConnectionService; + } + + public Optional renewUnifiedPushEndpoints() { + final Optional transportOptional = getTransport(); + if (transportOptional.isPresent()) { + renewUnifiedEndpoint(transportOptional.get()); + } else { + Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. No transport selected"); + } + return transportOptional; + } + + private void renewUnifiedEndpoint(final Transport transport) { + final Account account = transport.account; + final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service); + final List renewals = + unifiedPushDatabase.getRenewals( + account.getUuid(), transport.transport.toEscapedString()); + for (final UnifiedPushDatabase.PushTarget renewal : renewals) { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + ": try to renew UnifiedPush " + renewal); + final String hashedApplication = + UnifiedPushDistributor.hash(account.getUuid(), renewal.application); + final String hashedInstance = + UnifiedPushDistributor.hash(account.getUuid(), renewal.instance); + final IqPacket registration = new IqPacket(IqPacket.TYPE.SET); + registration.setTo(transport.transport); + final Element register = registration.addChild("register", Namespace.UNIFIED_PUSH); + register.setAttribute("application", hashedApplication); + register.setAttribute("instance", hashedInstance); + this.service.sendIqPacket( + account, + registration, + (a, response) -> processRegistration(transport, renewal, response)); + } + } + + private void processRegistration( + final Transport transport, + final UnifiedPushDatabase.PushTarget renewal, + final IqPacket response) { + if (response.getType() == IqPacket.TYPE.RESULT) { + final Element registered = response.findChild("registered", Namespace.UNIFIED_PUSH); + if (registered == null) { + return; + } + final String endpoint = registered.getAttribute("endpoint"); + if (Strings.isNullOrEmpty(endpoint)) { + Log.w(Config.LOGTAG, "endpoint was null in up registration"); + return; + } + final long expiration; + try { + expiration = AbstractParser.getTimestamp(registered.getAttribute("expiration")); + } catch (final IllegalArgumentException | ParseException e) { + Log.d(Config.LOGTAG, "could not parse expiration", e); + return; + } + renewUnifiedPushEndpoint(transport, renewal, endpoint, expiration); + } + } + + private void renewUnifiedPushEndpoint( + final Transport transport, + final UnifiedPushDatabase.PushTarget renewal, + final String endpoint, + final long expiration) { + Log.d(Config.LOGTAG, "registered endpoint " + endpoint + " expiration=" + expiration); + final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service); + final boolean modified = + unifiedPushDatabase.updateEndpoint( + renewal.instance, + transport.account.getUuid(), + transport.transport.toEscapedString(), + endpoint, + expiration); + if (modified) { + Log.d( + Config.LOGTAG, + "endpoint for " + + renewal.application + + "/" + + renewal.instance + + " was updated to " + + endpoint); + broadcastEndpoint( + renewal.instance, + new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint)); + } + } + + public boolean reconfigurePushDistributor() { + final boolean enabled = getTransport().isPresent(); + setUnifiedPushDistributorEnabled(enabled); + return enabled; + } + + private void setUnifiedPushDistributorEnabled(final boolean enabled) { + final PackageManager packageManager = service.getPackageManager(); + final ComponentName componentName = + new ComponentName(service, UnifiedPushDistributor.class); + if (enabled) { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP); + Log.d(Config.LOGTAG, "UnifiedPushDistributor has been enabled"); + } else { + packageManager.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP); + Log.d(Config.LOGTAG, "UnifiedPushDistributor has been disabled"); + } + } + + public boolean processPushMessage( + final Account account, final Jid transport, final Element push) { + final String instance = push.getAttribute("instance"); + final String application = push.getAttribute("application"); + if (Strings.isNullOrEmpty(instance) || Strings.isNullOrEmpty(application)) { + return false; + } + final String content = push.getContent(); + final byte[] payload; + if (Strings.isNullOrEmpty(content)) { + payload = new byte[0]; + } else if (BaseEncoding.base64().canDecode(content)) { + payload = BaseEncoding.base64().decode(content); + } else { + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + ": received invalid unified push payload"); + return false; + } + final Optional pushTarget = + getPushTarget(account, transport, application, instance); + if (pushTarget.isPresent()) { + final UnifiedPushDatabase.PushTarget target = pushTarget.get(); + // TODO check if app is still installed? + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": broadcasting a " + + payload.length + + " bytes push message to " + + target.application); + broadcastPushMessage(target, payload); + return true; + } else { + Log.d(Config.LOGTAG, "could not find application for push"); + return false; + } + } + + public Optional getTransport() { + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(service.getApplicationContext()); + final String accountPreference = + sharedPreferences.getString(UnifiedPushDistributor.PREFERENCE_ACCOUNT, "none"); + final String pushServerPreference = + sharedPreferences.getString( + UnifiedPushDistributor.PREFERENCE_PUSH_SERVER, + service.getString(R.string.default_push_server)); + if (Strings.isNullOrEmpty(accountPreference) + || "none".equalsIgnoreCase(accountPreference) + || Strings.nullToEmpty(pushServerPreference).trim().isEmpty()) { + return Optional.absent(); + } + final Jid transport; + final Jid jid; + try { + transport = Jid.ofEscaped(Strings.nullToEmpty(pushServerPreference).trim()); + jid = Jid.ofEscaped(Strings.nullToEmpty(accountPreference).trim()); + } catch (final IllegalArgumentException e) { + return Optional.absent(); + } + final Account account = service.findAccountByJid(jid); + if (account == null) { + return Optional.absent(); + } + return Optional.of(new Transport(account, transport)); + } + + private Optional getPushTarget( + final Account account, + final Jid transport, + final String application, + final String instance) { + final String uuid = account.getUuid(); + final List pushTargets = + UnifiedPushDatabase.getInstance(service) + .getPushTargets(uuid, transport.toEscapedString()); + return Iterables.tryFind( + pushTargets, + pt -> + UnifiedPushDistributor.hash(uuid, pt.application).equals(application) + && UnifiedPushDistributor.hash(uuid, pt.instance).equals(instance)); + } + + private void broadcastPushMessage( + final UnifiedPushDatabase.PushTarget target, final byte[] payload) { + final Intent updateIntent = new Intent(UnifiedPushDistributor.ACTION_MESSAGE); + updateIntent.setPackage(target.application); + updateIntent.putExtra("token", target.instance); + updateIntent.putExtra("bytesMessage", payload); + updateIntent.putExtra("message", new String(payload, StandardCharsets.UTF_8)); + service.sendBroadcast(updateIntent); + } + + private void broadcastEndpoint( + final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) { + Log.d(Config.LOGTAG, "broadcasting endpoint to " + endpoint.application); + final Intent updateIntent = new Intent(UnifiedPushDistributor.ACTION_NEW_ENDPOINT); + updateIntent.setPackage(endpoint.application); + updateIntent.putExtra("token", instance); + updateIntent.putExtra("endpoint", endpoint.endpoint); + service.sendBroadcast(updateIntent); + } + + public void rebroadcastEndpoint(final String instance, final Transport transport) { + final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service); + final UnifiedPushDatabase.ApplicationEndpoint endpoint = + unifiedPushDatabase.getEndpoint( + transport.account.getUuid(), + transport.transport.toEscapedString(), + instance); + if (endpoint != null) { + broadcastEndpoint(instance, endpoint); + } + } + + public static class Transport { + public final Account account; + public final Jid transport; + + public Transport(Account account, Jid transport) { + this.account = account; + this.transport = transport; + } + } +} diff --git a/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java new file mode 100644 index 0000000000000000000000000000000000000000..64c16dbcdf7ff804741282d8e4c66f94907ba2a6 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java @@ -0,0 +1,152 @@ +package eu.siacs.conversations.services; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.util.Log; + +import com.google.common.base.Charsets; +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import com.google.common.collect.Lists; +import com.google.common.hash.Hashing; +import com.google.common.io.BaseEncoding; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.persistance.UnifiedPushDatabase; +import eu.siacs.conversations.utils.Compatibility; + +public class UnifiedPushDistributor extends BroadcastReceiver { + + public static final String ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"; + public static final String ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"; + public static final String ACTION_BYTE_MESSAGE = + "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"; + public static final String ACTION_REGISTRATION_FAILED = + "org.unifiedpush.android.connector.REGISTRATION_FAILED"; + public static final String ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"; + public static final String ACTION_NEW_ENDPOINT = + "org.unifiedpush.android.connector.NEW_ENDPOINT"; + + public static final String PREFERENCE_ACCOUNT = "up_push_account"; + public static final String PREFERENCE_PUSH_SERVER = "up_push_server"; + + public static final List PREFERENCES = + Arrays.asList(PREFERENCE_ACCOUNT, PREFERENCE_PUSH_SERVER); + + @Override + public void onReceive(final Context context, final Intent intent) { + if (intent == null) { + return; + } + final String action = intent.getAction(); + final String application = intent.getStringExtra("application"); + final String instance = intent.getStringExtra("token"); + final List features = intent.getStringArrayListExtra("features"); + switch (Strings.nullToEmpty(action)) { + case ACTION_REGISTER: + register(context, application, instance, features); + break; + case ACTION_UNREGISTER: + unregister(context, instance); + break; + case Intent.ACTION_PACKAGE_FULLY_REMOVED: + unregisterApplication(context, intent.getData()); + break; + default: + Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action); + break; + } + } + + private void register( + final Context context, + final String application, + final String instance, + final Collection features) { + if (Strings.isNullOrEmpty(application) || Strings.isNullOrEmpty(instance)) { + Log.w(Config.LOGTAG, "ignoring invalid UnifiedPush registration"); + return; + } + final List receivers = getBroadcastReceivers(context, application); + if (receivers.contains(application)) { + final boolean byteMessage = features != null && features.contains(ACTION_BYTE_MESSAGE); + Log.d( + Config.LOGTAG, + "received up registration from " + + application + + "/" + + instance + + " features: " + + features); + if (UnifiedPushDatabase.getInstance(context).register(application, instance)) { + Log.d( + Config.LOGTAG, + "successfully created UnifiedPush entry. waking up XmppConnectionService"); + final Intent serviceIntent = new Intent(context, XmppConnectionService.class); + serviceIntent.setAction(XmppConnectionService.ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS); + serviceIntent.putExtra("instance", instance); + Compatibility.startService(context, serviceIntent); + } else { + Log.d(Config.LOGTAG, "not successful. sending error message back to application"); + final Intent registrationFailed = new Intent(ACTION_REGISTRATION_FAILED); + registrationFailed.setPackage(application); + registrationFailed.putExtra("token", instance); + context.sendBroadcast(registrationFailed); + } + } else { + Log.d( + Config.LOGTAG, + "ignoring invalid UnifiedPush registration. Unknown application " + + application); + } + } + + private List getBroadcastReceivers(final Context context, final String application) { + final Intent messageIntent = new Intent(ACTION_MESSAGE); + messageIntent.setPackage(application); + final List resolveInfo = + context.getPackageManager().queryBroadcastReceivers(messageIntent, 0); + return Lists.transform( + resolveInfo, ri -> ri.activityInfo == null ? null : ri.activityInfo.packageName); + } + + private void unregister(final Context context, final String instance) { + if (Strings.isNullOrEmpty(instance)) { + Log.w(Config.LOGTAG, "ignoring invalid UnifiedPush un-registration"); + return; + } + final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(context); + if (unifiedPushDatabase.deleteInstance(instance)) { + Log.d(Config.LOGTAG, "successfully removed " + instance + " from UnifiedPush"); + } + } + + private void unregisterApplication(final Context context, final Uri uri) { + if (uri != null && "package".equalsIgnoreCase(uri.getScheme())) { + final String application = uri.getSchemeSpecificPart(); + if (Strings.isNullOrEmpty(application)) { + return; + } + Log.d(Config.LOGTAG, "app " + application + " has been removed from the system"); + final UnifiedPushDatabase database = UnifiedPushDatabase.getInstance(context); + if (database.deleteApplication(application)) { + Log.d(Config.LOGTAG, "successfully removed " + application + " from UnifiedPush"); + } + } + } + + public static String hash(String... components) { + return BaseEncoding.base64() + .encode( + Hashing.sha256() + .hashString(Joiner.on('\0').join(components), Charsets.UTF_8) + .asBytes()); + } +} diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 4981f0473264deb9d06786e1e8d02d0eb308226a..22e7b38e545b371620c45703cb876914d68935b6 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -53,6 +53,7 @@ import androidx.core.app.RemoteInput; import androidx.core.content.ContextCompat; import com.google.common.base.Objects; +import com.google.common.base.Optional; import com.google.common.base.Strings; import org.conscrypt.Conscrypt; @@ -64,6 +65,7 @@ import java.io.File; import java.security.Security; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -117,6 +119,7 @@ import eu.siacs.conversations.parser.MessageParser; import eu.siacs.conversations.parser.PresenceParser; import eu.siacs.conversations.persistance.DatabaseBackend; import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.persistance.UnifiedPushDatabase; import eu.siacs.conversations.ui.ChooseAccountForProfilePictureActivity; import eu.siacs.conversations.ui.RtpSessionActivity; import eu.siacs.conversations.ui.SettingsActivity; @@ -124,6 +127,7 @@ import eu.siacs.conversations.ui.UiCallback; import eu.siacs.conversations.ui.interfaces.OnAvatarPublication; import eu.siacs.conversations.ui.interfaces.OnMediaLoaded; import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable; +import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.ConversationsFileObserver; import eu.siacs.conversations.utils.CryptoHelper; @@ -185,6 +189,7 @@ public class XmppConnectionService extends Service { public static final String ACTION_END_CALL = "end_call"; public static final String ACTION_PROVISION_ACCOUNT = "provision_account"; private static final String ACTION_POST_CONNECTIVITY_CHANGE = "eu.siacs.conversations.POST_CONNECTIVITY_CHANGE"; + public static final String ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS = "eu.siacs.conversations.UNIFIED_PUSH_RENEW"; private static final String SETTING_LAST_ACTIVITY_TS = "last_activity_timestamp"; @@ -217,6 +222,7 @@ public class XmppConnectionService extends Service { private final FileBackend fileBackend = new FileBackend(this); private MemorizingTrustManager mMemorizingTrustManager; private final NotificationService mNotificationService = new NotificationService(this); + private final UnifiedPushBroker unifiedPushBroker = new UnifiedPushBroker(this); private final ChannelDiscoveryService mChannelDiscoveryService = new ChannelDiscoveryService(this); private final ShortcutService mShortcutService = new ShortcutService(this); private final AtomicBoolean mInitialAddressbookSyncCompleted = new AtomicBoolean(false); @@ -804,6 +810,13 @@ public class XmppConnectionService extends Service { case ACTION_FCM_TOKEN_REFRESH: refreshAllFcmTokens(); break; + case ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS: + final String instance = intent.getStringExtra("instance"); + final Optional transport = renewUnifiedPushEndpoints(); + if (instance != null && transport.isPresent()) { + unifiedPushBroker.rebroadcastEndpoint(instance, transport.get()); + } + break; case ACTION_IDLE_PING: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { scheduleNextIdlePing(); @@ -933,6 +946,10 @@ public class XmppConnectionService extends Service { return pingNow; } + public boolean processUnifiedPushMessage(final Account account, final Jid transport, final Element push) { + return unifiedPushBroker.processPushMessage(account, transport, push); + } + public void reinitializeMuclumbusService() { mChannelDiscoveryService.initializeMuclumbusService(); } @@ -1167,6 +1184,7 @@ public class XmppConnectionService extends Service { editor.putBoolean(EventReceiver.SETTING_ENABLED_ACCOUNTS, hasEnabledAccounts).apply(); editor.apply(); toggleSetProfilePictureActivity(hasEnabledAccounts); + reconfigurePushDistributor(); restoreFromDatabase(); @@ -2334,10 +2352,18 @@ public class XmppConnectionService extends Service { final int targetState = enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; getPackageManager().setComponentEnabledSetting(name, targetState, PackageManager.DONT_KILL_APP); } catch (IllegalStateException e) { - Log.d(Config.LOGTAG, "unable to toggle profile picture actvitiy"); + Log.d(Config.LOGTAG, "unable to toggle profile picture activity"); } } + public boolean reconfigurePushDistributor() { + return this.unifiedPushBroker.reconfigurePushDistributor(); + } + + public Optional renewUnifiedPushEndpoints() { + return this.unifiedPushBroker.renewUnifiedPushEndpoints(); + } + private void provisionAccount(final String address, final String password) { final Jid jid = Jid.ofEscaped(address); final Account account = new Account(jid, password); @@ -4499,6 +4525,8 @@ public class XmppConnectionService extends Service { } } + + private void sendOfflinePresence(final Account account) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending offline presence"); sendPresencePacket(account, mPresenceGenerator.sendOfflinePresence(account)); diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index 07c8a55db1b6bba085d94cb1909929b76fdc9d72..2b2f8110a9a96fdcd4c59fa731257261cce4ff75 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -24,6 +24,8 @@ import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; import java.io.File; import java.security.KeyStoreException; @@ -40,6 +42,7 @@ import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.services.ExportBackupService; import eu.siacs.conversations.services.MemorizingTrustManager; import eu.siacs.conversations.services.QuickConversationsService; +import eu.siacs.conversations.services.UnifiedPushDistributor; import eu.siacs.conversations.ui.util.SettingsUtils; import eu.siacs.conversations.ui.util.StyledAttributes; import eu.siacs.conversations.utils.GeoHelper; @@ -88,7 +91,36 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference } @Override - void onBackendConnected() {} + void onBackendConnected() { + final Preference accountPreference = + mSettingsFragment.findPreference(UnifiedPushDistributor.PREFERENCE_ACCOUNT); + reconfigureUpAccountPreference(accountPreference); + } + + private void reconfigureUpAccountPreference(final Preference preference) { + final ListPreference listPreference; + if (preference instanceof ListPreference) { + listPreference = (ListPreference) preference; + } else { + return; + } + final List accounts = + ImmutableList.copyOf( + Lists.transform( + xmppConnectionService.getAccounts(), + a -> a.getJid().asBareJid().toEscapedString())); + final ImmutableList.Builder entries = new ImmutableList.Builder<>(); + final ImmutableList.Builder entryValues = new ImmutableList.Builder<>(); + entries.add(getString(R.string.no_account_deactivated)); + entryValues.add("none"); + entries.addAll(accounts); + entryValues.addAll(accounts); + listPreference.setEntries(entries.build().toArray(new CharSequence[0])); + listPreference.setEntryValues(entryValues.build().toArray(new CharSequence[0])); + if (!accounts.contains(listPreference.getValue())) { + listPreference.setValue("none"); + } + } @Override public void onStart() { @@ -472,6 +504,10 @@ public class SettingsActivity extends XmppActivity implements OnSharedPreference } } else if (name.equals(PREVENT_SCREENSHOTS)) { SettingsUtils.applyScreenshotPreventionSetting(this); + } else if (UnifiedPushDistributor.PREFERENCES.contains(name)) { + if (xmppConnectionService.reconfigurePushDistributor()) { + xmppConnectionService.renewUnifiedPushEndpoints(); + } } } diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 55f45c6b552d2d20e2a94dde99023cd069f47e56..b614251bd8b2a1b0c2495da21018382af946eab2 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -65,4 +65,5 @@ public final class Namespace { public static final String PARS = "urn:xmpp:pars:0"; public static final String EASY_ONBOARDING_INVITE = "urn:xmpp:invite#invite"; public static final String OMEMO_DTLS_SRTP_VERIFICATION = "http://gultsch.de/xmpp/drafts/omemo/dlts-srtp-verification"; + public static final String UNIFIED_PUSH = "http://gultsch.de/xmpp/drafts/unified-push"; } diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml index 60085d0f9bb0c32636d8d429f15c429c1b43464b..288e4ae74a600311822a3f23eac149e5de74ff1e 100644 --- a/src/main/res/values/defaults.xml +++ b/src/main/res/values/defaults.xml @@ -45,4 +45,6 @@ 360 JABBER_NETWORK false + up.conversations.im + none diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 3fc93601fdd4c04c5a910d609a905a2ccb3e04c8..e5399c15e3ee8f0a6d22d48a5d0399be08d915c7 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -999,5 +999,11 @@ Calls are disabled when using Tor Switch to video Reject switch to video request + UnifiedPush Distributor + XMPP Account + The account through which push messages will be received. + Push Server + A user-chosen push server to relay push messages via XMPP to your device. + None (deactivated) diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index b461558364e17a73999bce7a22008292f6275cc0..b9ea7e871ae6485fe60d06dcdb3ca8d7a80245a1 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -206,6 +206,21 @@ android:summary="@string/pref_create_backup_summary" android:title="@string/pref_create_backup" /> + + + + +