Detailed changes
@@ -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" />
@@ -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());
@@ -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);
@@ -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);
+ }
+ }
+}
@@ -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;
+ }
+ }
+}
@@ -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());
+ }
+}
@@ -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));
@@ -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();
+ }
}
}
@@ -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";
}
@@ -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>
@@ -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>
@@ -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">