client side support for XEP-0357: Push Notifications

Daniel Gultsch created

Change summary

build.gradle                                                                  | 37 
src/free/java/eu/siacs/conversations/services/PushManagementService.java      | 20 
src/main/java/eu/siacs/conversations/Config.java                              |  1 
src/main/java/eu/siacs/conversations/generator/IqGenerator.java               | 16 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java      | 31 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java              | 13 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                 | 14 
src/main/res/layout/activity_edit_account.xml                                 | 23 
src/main/res/values/strings.xml                                               |  1 
src/playstore/AndroidManifest.xml                                             | 35 
src/playstore/java/eu/siacs/conversations/services/InstanceIdService.java     | 15 
src/playstore/java/eu/siacs/conversations/services/PushManagementService.java | 78 
src/playstore/java/eu/siacs/conversations/services/PushMessageReceiver.java   | 20 
13 files changed, 267 insertions(+), 37 deletions(-)

Detailed changes

build.gradle 🔗

@@ -7,6 +7,7 @@ buildscript {
 	}
 	dependencies {
 		classpath 'com.android.tools.build:gradle:1.3.1'
+		classpath 'com.google.gms:google-services:1.5.0'
 	}
 }
 
@@ -21,12 +22,17 @@ allprojects {
 }
 
 apply plugin: 'com.android.application'
+apply plugin: 'com.google.gms.google-services'
 
 repositories {
 	jcenter()
 	mavenCentral()
 }
 
+configurations {
+	playstoreCompile
+}
+
 dependencies {
 	compile project(':libs:MemorizingTrustManager')
 	compile 'org.sufficientlysecure:openpgp-api:10.0'
@@ -44,6 +50,7 @@ dependencies {
 	compile 'com.kyleduo.switchbutton:library:1.2.8'
 	compile 'org.whispersystems:axolotl-android:1.3.4'
 	compile 'com.makeramen:roundedimageview:2.2.0'
+	playstoreCompile 'com.google.android.gms:play-services-gcm:8.3.0'
 }
 
 android {
@@ -55,7 +62,7 @@ android {
 		targetSdkVersion 23
 		versionCode 123
 		versionName "1.9.4"
-		project.ext.set(archivesBaseName, archivesBaseName + "-" + versionName);
+		archivesBaseName += "-$versionName"
 	}
 
 	compileOptions {
@@ -63,15 +70,10 @@ android {
 		targetCompatibility JavaVersion.VERSION_1_7
 	}
 
-	//
-	// To sign release builds, create the file `gradle.properties` in
-	// $HOME/.gradle or in your project directory with this content:
-	//
-	// mStoreFile=/path/to/key.store
-	// mStorePassword=xxx
-	// mKeyAlias=alias
-	// mKeyPassword=xxx
-	//
+   productFlavors {
+		playstore
+		free
+	}
 	if (project.hasProperty('mStoreFile') &&
 			project.hasProperty('mStorePassword') &&
 			project.hasProperty('mKeyAlias') &&
@@ -89,16 +91,6 @@ android {
 		buildTypes.release.signingConfig = null
 	}
 
-	applicationVariants.all { variant ->
-		if (variant.name.equals('release')) {
-			variant.outputs.each { output ->
-				if (output.zipAlign != null) {
-					output.zipAlign.outputFile = new File(output.outputFile.parent, rootProject.name + "-${variant.versionName}.apk")
-				}
-			}
-		}
-	}
-
 	lintOptions {
 		disable 'ExtraTranslation', 'MissingTranslation', 'InvalidPackage', 'MissingQuantity', 'AppCompatResource'
 	}
@@ -116,4 +108,9 @@ android {
 
 		}
 	}
+
+	packagingOptions {
+		exclude 'META-INF/BCKEY.DSA'
+		exclude 'META-INF/BCKEY.SF'
+	}
 }

src/free/java/eu/siacs/conversations/services/PushManagementService.java 🔗

@@ -0,0 +1,20 @@
+package eu.siacs.conversations.services;
+
+import eu.siacs.conversations.entities.Account;
+
+public class PushManagementService {
+
+	protected final XmppConnectionService mXmppConnectionService;
+
+	public PushManagementService(XmppConnectionService service) {
+		this.mXmppConnectionService = service;
+	}
+
+	public void registerPushTokenOnServer(Account account) {
+		//stub implementation. only affects playstore flavor
+	}
+
+	public boolean available() {
+		return false;
+	}
+}

src/main/java/eu/siacs/conversations/generator/IqGenerator.java 🔗

@@ -289,7 +289,7 @@ public class IqGenerator extends AbstractGenerator {
 	public IqPacket requestHttpUploadSlot(Jid host, DownloadableFile file, String mime) {
 		IqPacket packet = new IqPacket(IqPacket.TYPE.GET);
 		packet.setTo(host);
-		Element request = packet.addChild("request",Xmlns.HTTP_UPLOAD);
+		Element request = packet.addChild("request", Xmlns.HTTP_UPLOAD);
 		request.addChild("filename").setContent(file.getName());
 		request.addChild("size").setContent(String.valueOf(file.getExpectedSize()));
 		if (mime != null) {
@@ -307,4 +307,18 @@ public class IqGenerator extends AbstractGenerator {
 
 		return register;
 	}
+
+	public IqPacket pushTokenToAppServer(Jid appServer, String token, String deviceId) {
+		IqPacket packet = new IqPacket(IqPacket.TYPE.SET);
+		packet.setTo(appServer);
+		Element command = packet.addChild("command", "http://jabber.org/protocol/commands");
+		command.setAttribute("node","register-push-gcm");
+		command.setAttribute("action","execute");
+		Data data = new Data();
+		data.put("token", token);
+		data.put("device-id", deviceId);
+		data.submit();
+		command.addChild(data);
+		return packet;
+	}
 }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -73,7 +73,6 @@ import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.entities.MucOptions;
 import eu.siacs.conversations.entities.MucOptions.OnRenameListener;
 import eu.siacs.conversations.entities.Presence;
-import eu.siacs.conversations.entities.Presences;
 import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.entities.Transferable;
@@ -127,6 +126,8 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 	public static final String ACTION_TRY_AGAIN = "try_again";
 	public static final String ACTION_DISABLE_ACCOUNT = "disable_account";
 	private static final String ACTION_MERGE_PHONE_CONTACTS = "merge_phone_contacts";
+	public static final String ACTION_GCM_TOKEN_REFRESH = "gcm_token_refresh";
+	public static final String ACTION_GCM_MESSAGE_RECEIVED = "gcm_message_received";
 	private final SerialSingleThreadExecutor mFileAddingExecutor = new SerialSingleThreadExecutor();
 	private final SerialSingleThreadExecutor mDatabaseExecutor = new SerialSingleThreadExecutor();
 	private final IBinder mBinder = new XmppConnectionBinder();
@@ -198,6 +199,7 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 			this);
 	private AvatarService mAvatarService = new AvatarService(this);
 	private MessageArchiveService mMessageArchiveService = new MessageArchiveService(this);
+	private PushManagementService mPushManagementService = new PushManagementService(this);
 	private OnConversationUpdate mOnConversationUpdate = null;
 	private final FileObserver fileObserver = new FileObserver(
 			FileBackend.getConversationsImageDirectory()) {
@@ -265,7 +267,7 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 	private OnStatusChanged statusListener = new OnStatusChanged() {
 
 		@Override
-		public void onStatusChanged(Account account) {
+		public void onStatusChanged(final Account account) {
 			XmppConnection connection = account.getXmppConnection();
 			if (mOnAccountUpdate != null) {
 				mOnAccountUpdate.onAccountUpdate();
@@ -296,6 +298,11 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 				}
 				account.pendingConferenceJoins.clear();
 				scheduleWakeUpCall(Config.PING_MAX_INTERVAL, account.getUuid().hashCode());
+
+				if (mPushManagementService.pushAvailable(account)) {
+					mPushManagementService.registerPushTokenOnServer(account);
+				}
+
 			} else if (account.getStatus() == Account.State.OFFLINE) {
 				resetSendingToWaiting(account);
 				if (!account.isOptionSet(Account.OPTION_DISABLED)) {
@@ -512,6 +519,11 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 						refreshAllPresences();
 					}
 					break;
+				case ACTION_GCM_TOKEN_REFRESH:
+					refreshAllGcmTokens();
+					break;
+				case ACTION_GCM_MESSAGE_RECEIVED:
+					Log.d(Config.LOGTAG,"gcm push message arrived in service. extras="+intent.getExtras());
 			}
 		}
 		this.wakeLock.acquire();
@@ -572,7 +584,6 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 							reconnectAccount(account, true, interactive);
 						}
 					}
-
 				}
 				if (mOnAccountUpdate != null) {
 					mOnAccountUpdate.onAccountUpdate();
@@ -2845,6 +2856,14 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 		}
 	}
 
+	private void refreshAllGcmTokens() {
+		for(Account account : getAccounts()) {
+			if (account.isOnlineAndConnected() && mPushManagementService.pushAvailable(account)) {
+				mPushManagementService.registerPushTokenOnServer(account);
+			}
+		}
+	}
+
 	public void sendOfflinePresence(final Account account) {
 		sendPresencePacket(account, mPresenceGenerator.sendOfflinePresence(account));
 	}
@@ -3005,7 +3024,7 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 								databaseBackend.insertDiscoveryResult(disco);
 								injectServiceDiscorveryResult(account.getRoster(), presence.getHash(), presence.getVer(), disco);
 							} else {
-								Log.d(Config.LOGTAG, account.getJid().toBareJid() + ": mismatch in caps for contact " + jid+" "+presence.getVer()+" vs "+disco.getVer());
+								Log.d(Config.LOGTAG, account.getJid().toBareJid() + ": mismatch in caps for contact " + jid + " " + presence.getVer() + " vs " + disco.getVer());
 							}
 						}
 						account.inProgressDiscoFetches.remove(key);
@@ -3041,6 +3060,10 @@ public class XmppConnectionService extends Service implements OnPhoneContactsLoa
 		});
 	}
 
+	public PushManagementService getPushManagementService() {
+		return mPushManagementService;
+	}
+
 	public interface OnMamPreferencesFetched {
 		void onPreferencesFetched(Element prefs);
 		void onPreferencesFetchFailed();

src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java 🔗

@@ -29,6 +29,7 @@ import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.RelativeLayout;
 import android.widget.TableLayout;
+import android.widget.TableRow;
 import android.widget.TextView;
 import android.widget.Toast;
 
@@ -77,6 +78,7 @@ public class EditAccountActivity extends XmppActivity implements OnAccountUpdate
 	private TextView mServerInfoBlocking;
 	private TextView mServerInfoPep;
 	private TextView mServerInfoHttpUpload;
+	private TextView mServerInfoPush;
 	private TextView mSessionEst;
 	private TextView mOtrFingerprint;
 	private TextView mAxolotlFingerprint;
@@ -223,6 +225,7 @@ public class EditAccountActivity extends XmppActivity implements OnAccountUpdate
 		}
 	};
 	private Toast mFetchingMamPrefsToast;
+	private TableRow mPushRow;
 
 	public void refreshUiReal() {
 		invalidateOptionsMenu();
@@ -422,6 +425,8 @@ public class EditAccountActivity extends XmppActivity implements OnAccountUpdate
 		this.mServerInfoSm = (TextView) findViewById(R.id.server_info_sm);
 		this.mServerInfoPep = (TextView) findViewById(R.id.server_info_pep);
 		this.mServerInfoHttpUpload = (TextView) findViewById(R.id.server_info_http_upload);
+		this.mPushRow = (TableRow) findViewById(R.id.push_row);
+		this.mServerInfoPush = (TextView) findViewById(R.id.server_info_push);
 		this.mOtrFingerprint = (TextView) findViewById(R.id.otr_fingerprint);
 		this.mOtrFingerprintBox = (RelativeLayout) findViewById(R.id.otr_fingerprint_box);
 		this.mOtrFingerprintToClipboardButton = (ImageButton) findViewById(R.id.action_copy_to_clipboard);
@@ -680,6 +685,14 @@ public class EditAccountActivity extends XmppActivity implements OnAccountUpdate
 			} else {
 				this.mServerInfoHttpUpload.setText(R.string.server_info_unavailable);
 			}
+
+			this.mPushRow.setVisibility(xmppConnectionService.getPushManagementService().available() ? View.VISIBLE : View.GONE);
+
+			if (features.push()) {
+				this.mServerInfoPush.setText(R.string.server_info_available);
+			} else {
+				this.mServerInfoPush.setText(R.string.server_info_unavailable);
+			}
 			final String otrFingerprint = this.mAccount.getOtrFingerprint();
 			if (otrFingerprint != null) {
 				this.mOtrFingerprintBox.setVisibility(View.VISIBLE);

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -1495,17 +1495,13 @@ public class XmppConnection implements Runnable {
 		}
 
 		public boolean mam() {
-			if (hasDiscoFeature(account.getJid().toBareJid(), "urn:xmpp:mam:0")) {
-				return true;
-			} else {
-				return hasDiscoFeature(account.getServer(), "urn:xmpp:mam:0");
-			}
+			return hasDiscoFeature(account.getJid().toBareJid(), "urn:xmpp:mam:0")
+				|| hasDiscoFeature(account.getServer(), "urn:xmpp:mam:0");
 		}
 
-		public boolean advancedStreamFeaturesLoaded() {
-			synchronized (XmppConnection.this.disco) {
-				return disco.containsKey(account.getServer());
-			}
+		public boolean push() {
+			return hasDiscoFeature(account.getJid().toBareJid(), "urn:xmpp:push:0")
+					|| hasDiscoFeature(account.getServer(), "urn:xmpp:push:0");
 		}
 
 		public boolean rosterVersioning() {

src/main/res/layout/activity_edit_account.xml 🔗

@@ -399,6 +399,26 @@
                             android:textSize="?attr/TextSizeBody"
                             tools:ignore="RtlHardcoded"/>
                     </TableRow>
+                    <TableRow
+                        android:id="@+id/push_row"
+                        android:layout_width="fill_parent"
+                        android:layout_height="wrap_content">
+
+                        <TextView
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:text="@string/server_info_push"
+                            android:textColor="@color/black87"
+                            android:textSize="?attr/TextSizeBody"/>
+
+                        <TextView
+                            android:id="@+id/server_info_push"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_gravity="right"
+                            android:textColor="@color/black87"
+                            android:textSize="?attr/TextSizeBody"/>
+                    </TableRow>
                     <TableRow
                         android:layout_width="fill_parent"
                         android:layout_height="wrap_content">
@@ -416,8 +436,7 @@
                             android:layout_height="wrap_content"
                             android:layout_gravity="right"
                             android:textColor="@color/black87"
-                            android:textSize="?attr/TextSizeBody"
-                            tools:ignore="RtlHardcoded"/>
+                            android:textSize="?attr/TextSizeBody"/>
                     </TableRow>
                 </TableLayout>
 

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

@@ -193,6 +193,7 @@
 	<string name="server_info_stream_management">XEP-0198: Stream Management</string>
 	<string name="server_info_pep">XEP-0163: PEP (Avatars / OMEMO)</string>
 	<string name="server_info_http_upload">XEP-0363: HTTP File Upload</string>
+	<string name="server_info_push">XEP-0357: Push</string>
 	<string name="server_info_available">available</string>
 	<string name="server_info_unavailable">unavailable</string>
 	<string name="missing_public_keys">Missing public key announcements</string>

src/playstore/AndroidManifest.xml 🔗

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    package="eu.siacs.conversations"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <permission android:name="eu.siacs.conversations.permission.C2D_MESSAGE"
+                android:protectionLevel="signature"/>
+    <uses-permission android:name="eu.siacs.conversations.permission.C2D_MESSAGE"/>
+
+    <application>
+
+        <receiver
+            android:name="com.google.android.gms.gcm.GcmReceiver"
+            android:exported="true"
+            android:permission="com.google.android.c2dm.permission.SEND" >
+            <intent-filter>
+                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
+                <category android:name="com.example.gcm" />
+            </intent-filter>
+        </receiver>
+        <service
+            android:name=".services.PushMessageReceiver"
+            android:exported="false" >
+            <intent-filter>
+                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
+            </intent-filter>
+        </service>
+
+        <service android:name=".services.InstanceIdService" android:exported="false">
+            <intent-filter>
+                <action android:name="com.google.android.gms.iid.InstanceID"/>
+            </intent-filter>
+        </service>
+    </application>
+</manifest>

src/playstore/java/eu/siacs/conversations/services/InstanceIdService.java 🔗

@@ -0,0 +1,15 @@
+package eu.siacs.conversations.services;
+
+import android.content.Intent;
+
+import com.google.android.gms.iid.InstanceIDListenerService;
+
+public class InstanceIdService extends InstanceIDListenerService {
+
+	@Override
+	public void onTokenRefresh() {
+		Intent intent = new Intent(this, XmppConnectionService.class);
+		intent.setAction(XmppConnectionService.ACTION_GCM_TOKEN_REFRESH);
+		startService(intent);
+	}
+}

src/playstore/java/eu/siacs/conversations/services/PushManagementService.java 🔗

@@ -0,0 +1,78 @@
+package eu.siacs.conversations.services;
+
+import android.provider.Settings;
+import android.util.Log;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.gms.gcm.GoogleCloudMessaging;
+import com.google.android.gms.iid.InstanceID;
+
+import java.io.IOException;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.xmpp.OnIqPacketReceived;
+import eu.siacs.conversations.xmpp.jid.InvalidJidException;
+import eu.siacs.conversations.xmpp.jid.Jid;
+import eu.siacs.conversations.xmpp.stanzas.IqPacket;
+
+public class PushManagementService {
+
+	private static final String APP_SERVER = "push.conversations.im";
+
+	protected final XmppConnectionService mXmppConnectionService;
+
+	public PushManagementService(XmppConnectionService service) {
+		this.mXmppConnectionService = service;
+	}
+
+	public void registerPushTokenOnServer(final Account account) {
+		Log.d(Config.LOGTAG, account.getJid().toBareJid() + ": has push support");
+		retrieveGcmInstanceToken(new OnGcmInstanceTokenRetrieved() {
+			@Override
+			public void onGcmInstanceTokenRetrieved(String token) {
+				try {
+					final String deviceId = Settings.Secure.getString(mXmppConnectionService.getContentResolver(), Settings.Secure.ANDROID_ID);
+					IqPacket packet = mXmppConnectionService.getIqGenerator().pushTokenToAppServer(Jid.fromString(APP_SERVER), token, deviceId);
+					mXmppConnectionService.sendIqPacket(account, packet, new OnIqPacketReceived() {
+						@Override
+						public void onIqPacketReceived(Account account, IqPacket packet) {
+							Log.d(Config.LOGTAG, "push to app server result: " + packet.toString());
+						}
+					});
+				} catch (InvalidJidException ignored) {
+
+				}
+			}
+		});
+	}
+
+	private void retrieveGcmInstanceToken(final OnGcmInstanceTokenRetrieved instanceTokenRetrieved) {
+		new Thread(new Runnable() {
+			@Override
+			public void run() {
+				InstanceID instanceID = InstanceID.getInstance(mXmppConnectionService);
+				try {
+					String token = instanceID.getToken(mXmppConnectionService.getString(R.string.gcm_defaultSenderId), GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
+					instanceTokenRetrieved.onGcmInstanceTokenRetrieved(token);
+				} catch (IOException e) {
+				}
+			}
+		}).start();
+
+	}
+
+	public boolean available() {
+		return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(mXmppConnectionService) == ConnectionResult.SUCCESS;
+	}
+
+	public boolean pushAvailable(Account account) {
+		return account.getXmppConnection().getFeatures().push() && available();
+	}
+
+	interface OnGcmInstanceTokenRetrieved {
+		void onGcmInstanceTokenRetrieved(String token);
+	}
+}

src/playstore/java/eu/siacs/conversations/services/PushMessageReceiver.java 🔗

@@ -0,0 +1,20 @@
+package eu.siacs.conversations.services;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.google.android.gms.gcm.GcmListenerService;
+
+import eu.siacs.conversations.Config;
+
+public class PushMessageReceiver extends GcmListenerService {
+
+	@Override
+	public void onMessageReceived(String from, Bundle data) {
+		Intent intent = new Intent(this, XmppConnectionService.class);
+		intent.setAction(XmppConnectionService.ACTION_GCM_MESSAGE_RECEIVED);
+		intent.replaceExtras(data);
+		startService(intent);
+	}
+}