integrate UnifiedPush distributor

Daniel Gultsch created

Change summary

src/main/AndroidManifest.xml                                              |  18 
src/main/java/eu/siacs/conversations/parser/AbstractParser.java           |  32 
src/main/java/eu/siacs/conversations/parser/IqParser.java                 |  18 
src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java | 244 
src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java      | 277 
src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java | 152 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java  |  30 
src/main/java/eu/siacs/conversations/ui/SettingsActivity.java             |  38 
src/main/java/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(-)

Detailed changes

src/main/AndroidManifest.xml 🔗

@@ -67,6 +67,9 @@
         <intent>
             <action android:name="android.intent.action.VIEW" />
         </intent>
+        <intent>
+            <action android:name="org.unifiedpush.android.connector.MESSAGE"/>
+        </intent>
     </queries>
 
 
@@ -102,6 +105,21 @@
             </intent-filter>
         </receiver>
 
+        <receiver
+            android:name=".services.UnifiedPushDistributor"
+            android:enabled="false"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="org.unifiedpush.android.distributor.REGISTER" />
+                <action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
+                <action android:name="org.unifiedpush.android.distributor.feature.BYTES_MESSAGE" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.PACKAGE_FULLY_REMOVED" />
+                <data android:scheme="package"/>
+            </intent-filter>
+        </receiver>
+
         <activity
             android:name=".ui.ShareLocationActivity"
             android:label="@string/title_activity_share_location" />

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());

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);

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<String> 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<PushTarget> getRenewals(final String account, final String transport) {
+        final ImmutableList.Builder<PushTarget> 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<PushTarget> getPushTargets(final String account, final String transport) {
+        final ImmutableList.Builder<PushTarget> 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);
+        }
+    }
+}

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<Transport> renewUnifiedPushEndpoints() {
+        final Optional<Transport> 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<UnifiedPushDatabase.PushTarget> 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<UnifiedPushDatabase.PushTarget> 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<Transport> 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<UnifiedPushDatabase.PushTarget> getPushTarget(
+            final Account account,
+            final Jid transport,
+            final String application,
+            final String instance) {
+        final String uuid = account.getUuid();
+        final List<UnifiedPushDatabase.PushTarget> 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;
+        }
+    }
+}

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<String> 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<String> 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<String> features) {
+        if (Strings.isNullOrEmpty(application) || Strings.isNullOrEmpty(instance)) {
+            Log.w(Config.LOGTAG, "ignoring invalid UnifiedPush registration");
+            return;
+        }
+        final List<String> 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<String> getBroadcastReceivers(final Context context, final String application) {
+        final Intent messageIntent = new Intent(ACTION_MESSAGE);
+        messageIntent.setPackage(application);
+        final List<ResolveInfo> 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());
+    }
+}

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<UnifiedPushBroker.Transport> 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<UnifiedPushBroker.Transport> 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));

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<CharSequence> accounts =
+                ImmutableList.copyOf(
+                        Lists.transform(
+                                xmppConnectionService.getAccounts(),
+                                a -> a.getJid().asBareJid().toEscapedString()));
+        final ImmutableList.Builder<CharSequence> entries = new ImmutableList.Builder<>();
+        final ImmutableList.Builder<CharSequence> 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();
+            }
         }
     }
 

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";
 }

src/main/res/values/defaults.xml 🔗

@@ -45,4 +45,6 @@
     <string name="video_compression">360</string>
     <string name="default_channel_discovery">JABBER_NETWORK</string>
     <bool name="prevent_screenshots">false</bool>
+    <string name="default_push_server">up.conversations.im</string>
+    <string name="default_push_account">none</string>
 </resources>

src/main/res/values/strings.xml 🔗

@@ -999,5 +999,11 @@
     <string name="audio_video_disabled_tor">Calls are disabled when using Tor</string>
     <string name="switch_to_video">Switch to video</string>
     <string name="reject_switch_to_video">Reject switch to video request</string>
+    <string name="unified_push_distributor">UnifiedPush Distributor</string>
+    <string name="pref_up_push_account_title">XMPP Account</string>
+    <string name="pref_up_push_account_summary">The account through which push messages will be received.</string>
+    <string name="pref_up_push_server_title">Push Server</string>
+    <string name="pref_up_push_server_summary">A user-chosen push server to relay push messages via XMPP to your device.</string>
+    <string name="no_account_deactivated">None (deactivated)</string>
 
 </resources>

src/main/res/xml/preferences.xml 🔗

@@ -206,6 +206,21 @@
             android:summary="@string/pref_create_backup_summary"
             android:title="@string/pref_create_backup" />
     </PreferenceCategory>
+    <PreferenceCategory
+        android:key="unified_push"
+        android:title="@string/unified_push_distributor">
+        <ListPreference
+            android:defaultValue="@string/default_push_account"
+            android:key="up_push_account"
+            android:summary="@string/pref_up_push_account_summary"
+            android:title="@string/pref_up_push_account_title" />
+        <EditTextPreference
+            android:defaultValue="@string/default_push_server"
+            android:key="up_push_server"
+            android:summary="@string/pref_up_push_server_summary"
+            android:title="@string/pref_up_push_server_title" />
+
+    </PreferenceCategory>
     <PreferenceCategory
         android:key="advanced"
         android:title="@string/pref_advanced_options">