From 6fe852b23b99fbaa11a7b89a7cf5af8c3136f67f Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Sat, 30 Apr 2016 15:01:38 -0700 Subject: [PATCH 1/4] Upgrade Parse Push to GCM v4 This change: ---------- * Moves Parse back to using public APIs (open [GitHub discussion](ParsePlatform#445)) * Cleans up a lot of code in `GcmRegistrar` that is redundant with GCM APIs or written before Bolts * Fixes a typo in manifest instructions that used a literal `bool` instead of `"bool"` * Fixes a bug where ParseInstallation did not save the GcmSenderId, causing Parse to not use the developer's secrets. * Fixes a bug where Parse incorrectly blames a manifest error when GCM is unavailable because the device doesn't have Play Services. * Add a compatibility shim that lets `ParsePushBroadcastReceiver` correctly handle the standard payloads expected by [com.android.gms.gcm.GcmReceiver](https://developers.google.com/android/reference/com/google/android/gms/gcm/GcmReceiver). This lets customers who previously used another push provider use the `ParsePushBroadcastReceiver` instead. * Add support for GCMv4, including a new optional intent to notify the app when the InstanceID is invalidated. GCM v4 has a number of benefits: --------------- * GCM v4 is based on a device-owned InstanceID. Push tokens are oauth tokens signed by the device, so this fixes double-send bugs that Parse Push has never been able to fix. * If we used the InstanceID as the ParseInstallation.InstallationId, we would also increase stability of the Installation record, which fixes some cases where Installations are wiped & replaced (related to the above bug for senderId stability). * This API has a callback in case the InstanceID is invalidated, which should reduce client/server inconsistencies. * These tokens support new server-side APIs like push-to-topic, which are _dramatically_ faster than the normal ParsePush path. * When a device upgrades to GCMv4, the device keeps GCM topics in sync with channels. This paves the way to implement push-to-channels on top of topics. It also allows the customer to keep some of their targeting info regardless of which push provider they choose to use. This has two possibly controversial requirements: ---------------- * The new API issues one token per sender ID rather than one token that works with all sender IDs. To avoid an invasive/breaking server-side change, we are _no longer requesting tokens for the Parse sender ID_ if the developer provided their own. We will also only support at most one custom sender ID. I've had a number of conversations about this and nobody seems concerned. * This change introduces a dependency on the Google Mobile Services SDK. The dependency is just the GCM .jar and does _not_ limit the Parse SDK to devices with Play Services (tested on an ICS emulator w/o Google APIs). I originally tried doing this without the dependency, but the new API has a large amount of crypto and incredible care for compat shims on older API levels. I assume my hand-crafted copy would be worse quality. Open questions ----------------- * Should Parse use the GMS InstanceID over the InstallationId when available? This makes the server-side Installation deduplication code work better, but could break systems that assume InstallationId is a UUID. * Google workflows provide a `google-services.json` file that GMS uses to auto-initialize various Google products (including GCM). Should we allow the Parse SDK to initialize the developer's sender ID with this file in addition to the Parse-specific way? --- Parse/build.gradle | 1 + Parse/src/main/java/com/parse/GCMService.java | 67 +++- .../src/main/java/com/parse/GcmRegistrar.java | 355 ++++++++---------- .../src/main/java/com/parse/ManifestInfo.java | 20 +- .../java/com/parse/ParseInstallation.java | 5 + .../com/parse/ParseNotificationManager.java | 10 +- .../parse/ParsePushChannelsController.java | 24 +- 7 files changed, 270 insertions(+), 212 deletions(-) diff --git a/Parse/build.gradle b/Parse/build.gradle index 7a3353b37..3909c007e 100644 --- a/Parse/build.gradle +++ b/Parse/build.gradle @@ -41,6 +41,7 @@ android { dependencies { compile 'com.parse.bolts:bolts-tasks:1.4.0' + compile 'com.google.android.gms:play-services-gcm:8.4.0' provided 'com.squareup.okhttp:okhttp:2.4.0' provided 'com.facebook.stetho:stetho:1.1.1' diff --git a/Parse/src/main/java/com/parse/GCMService.java b/Parse/src/main/java/com/parse/GCMService.java index 6ca46a3a8..2581c7f7f 100644 --- a/Parse/src/main/java/com/parse/GCMService.java +++ b/Parse/src/main/java/com/parse/GCMService.java @@ -16,6 +16,9 @@ import org.json.JSONObject; import java.lang.ref.WeakReference; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -33,6 +36,15 @@ "com.google.android.c2dm.intent.REGISTRATION"; public static final String RECEIVE_PUSH_ACTION = "com.google.android.c2dm.intent.RECEIVE"; + public static final String INSTANCE_ID_ACTION = + "com.google.android.gms.iid.InstanceID"; + + private static final String REGISTRATION_ID_EXTRA = "registration_id"; + private static final String GCM_BODY_EXTRA = "gcm.notification.body"; + private static final String GCM_TITLE_EXTRA = "gcm.notification.title"; + private static final String GCM_SOUND_EXTRA = "gcm.notification.sound"; + private static final String GCM_COMPOSE_ID_EXTRA = "google.c.a.c_id"; + private static final String GCM_COMPOSE_TIMESTAMP_EXTRA = "google.c.a.ts"; private final WeakReference parent; private ExecutorService executor; @@ -83,6 +95,8 @@ private void onHandleIntent(Intent intent) { handleGcmRegistrationIntent(intent); } else if (RECEIVE_PUSH_ACTION.equals(action)) { handleGcmPushIntent(intent); + } else if (INSTANCE_ID_ACTION.equals(action)) { + handleInvalidatedInstanceId(intent); } else { PLog.e(TAG, "PushService got unknown intent in GCM mode: " + intent); } @@ -94,7 +108,13 @@ private void handleGcmRegistrationIntent(Intent intent) { // Have to block here since GCMService is basically an IntentService, and the service is // may exit before async handling of the registration is complete if we don't wait for it to // complete. - GcmRegistrar.getInstance().handleRegistrationIntentAsync(intent).waitForCompletion(); + PLog.d(TAG, "Got registration intent in service"); + String registrationId = intent.getStringExtra(REGISTRATION_ID_EXTRA); + // Multiple IDs come back to the legacy intnet-based API with an |ID|num: prefix; cut it off. + if (registrationId.startsWith("|ID|")) { + registrationId = registrationId.substring(registrationId.indexOf(':') + 1); + } + GcmRegistrar.getInstance().setGCMRegistrationId(registrationId).waitForCompletion(); } catch (InterruptedException e) { // do nothing } @@ -116,19 +136,52 @@ private void handleGcmPushIntent(Intent intent) { String channel = intent.getStringExtra("channel"); JSONObject data = null; - if (dataString != null) { - try { - data = new JSONObject(dataString); - } catch (JSONException e) { - PLog.e(TAG, "Ignoring push because of JSON exception while processing: " + dataString, e); - return; + try { + if (dataString != null) { + data = new JSONObject(dataString); + } else if (pushId == null && timestamp == null && dataString == null && channel == null) { + // The Parse SDK is older than GCM, so it has some non-standard payload fields. + // This allows the Parse SDK to handle push providers using the now-standard GCM payloads. + pushId = intent.getStringExtra(GCM_COMPOSE_ID_EXTRA); + String millisString = intent.getStringExtra(GCM_COMPOSE_TIMESTAMP_EXTRA); + if (millisString != null) { + Long millis = Long.valueOf(millisString); + DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mmZ"); + timestamp = df.format(new Date(millis)); + } + data = new JSONObject(); + if (intent.hasExtra(GCM_BODY_EXTRA)) { + data.put("alert", intent.getStringExtra(GCM_BODY_EXTRA)); + } + if (intent.hasExtra(GCM_TITLE_EXTRA)) { + data.put("title", intent.getStringExtra(GCM_TITLE_EXTRA)); + } + if (intent.hasExtra(GCM_SOUND_EXTRA)) { + data.put("sound", intent.getStringExtra(GCM_SOUND_EXTRA)); + } + + String from = intent.getStringExtra("from"); + if (from != null && from.startsWith("/topics/")) { + channel = from.substring("/topics/".length()); + } } + } catch (JSONException e) { + PLog.e(TAG, "Ignoring push because of JSON exception while processing: " + dataString, e); + return; } PushRouter.getInstance().handlePush(pushId, timestamp, channel, data); } } + private void handleInvalidatedInstanceId(Intent intent) { + try { + GcmRegistrar.getInstance().sendRegistrationRequestAsync().waitForCompletion(); + } catch (InterruptedException e) { + // do nothing + } + } + /** * Stop the parent Service, if we're still running. */ diff --git a/Parse/src/main/java/com/parse/GcmRegistrar.java b/Parse/src/main/java/com/parse/GcmRegistrar.java index d4234a0b1..2bfd5ca70 100644 --- a/Parse/src/main/java/com/parse/GcmRegistrar.java +++ b/Parse/src/main/java/com/parse/GcmRegistrar.java @@ -8,42 +8,32 @@ */ package com.parse; -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.ComponentName; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.os.Bundle; -import android.os.SystemClock; + +import com.google.android.gms.gcm.GcmPubSub; +import com.google.android.gms.iid.InstanceID; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Random; import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicInteger; import bolts.Continuation; import bolts.Task; -import bolts.TaskCompletionSource; /** * A class that manages registering for GCM and updating the registration if it is out of date. */ /** package */ class GcmRegistrar { private static final String TAG = "com.parse.GcmRegistrar"; - private static final String REGISTRATION_ID_EXTRA = "registration_id"; private static final String ERROR_EXTRA = "error"; // Client-side key for parseplatform@gmail.com. See parse/config/gcm.yml for server-side key. private static final String PARSE_SENDER_ID = "1076345567071"; private static final String SENDER_ID_EXTRA = "com.parse.push.gcm_sender_id"; - public static final String REGISTER_ACTION = "com.google.android.c2dm.intent.REGISTER"; - private static final String FILENAME_DEVICE_TOKEN_LAST_MODIFIED = "deviceTokenLastModified"; private long localDeviceTokenLastModified; private final Object localDeviceTokenLastModifiedMutex = new Object(); @@ -66,11 +56,17 @@ private static String actualSenderIDFromExtra(Object senderIDExtra) { return null; } - return senderID.substring(3); + String[] splitIds = senderID.split(","); + splitIds[0] = splitIds[0].substring(3); + if (splitIds.length > 1) { + PLog.w(TAG, "Received registration for multiple sender IDs, which is no longer supported. " + + "Will only use the first sender ID (" + splitIds[0] + ")"); + } + + return splitIds[0]; } private final Object lock = new Object(); - private Request request = null; private Context context = null; // This a package-level constructor for unit testing only. Otherwise, use getInstance(). @@ -118,50 +114,77 @@ public Task then(Task task) throws Exception { } } - private Task sendRegistrationRequestAsync() { + private String getSenderID() { + // Look for an element like this as a child of the element: + // + // + // + // The reason why the "id:" prefix is necessary is because Android treats any metadata value + // that is a string of digits as an integer. So the call to Bundle.getString() will actually + // return null for `android:value="567327206255"`. Additionally, Bundle.getInteger() returns + // a 32-bit integer. For `android:value="567327206255"`, this returns a truncated integer + // because 567327206255 is larger than the largest 32-bit integer. + Bundle metaData = ManifestInfo.getApplicationMetadata(context); + if (metaData != null) { + Object senderIDExtra = metaData.get(SENDER_ID_EXTRA); + + if (senderIDExtra != null) { + String senderID = actualSenderIDFromExtra(senderIDExtra); + + if (senderID != null) { + return senderID; + } else { + PLog.e(TAG, "Found " + SENDER_ID_EXTRA + " element with value \"" + + senderIDExtra.toString() + "\", but the value is missing the expected \"id:\" " + + "prefix."); + } + } + } + + return PARSE_SENDER_ID; + } + + boolean requesting = false; + /** package protected so the GCMService can force a refresh when it gets a notice that the + * InstanceID was invalidated + */ + Task sendRegistrationRequestAsync() { synchronized (lock) { - if (request != null) { + if (requesting) { return Task.forResult(null); } - // Look for an element like this as a child of the element: - // - // - // - // The reason why the "id:" prefix is necessary is because Android treats any metadata value - // that is a string of digits as an integer. So the call to Bundle.getString() will actually - // return null for `android:value="567327206255"`. Additionally, Bundle.getInteger() returns - // a 32-bit integer. For `android:value="567327206255"`, this returns a truncated integer - // because 567327206255 is larger than the largest 32-bit integer. - Bundle metaData = ManifestInfo.getApplicationMetadata(context); - String senderIDs = PARSE_SENDER_ID; - if (metaData != null) { - Object senderIDExtra = metaData.get(SENDER_ID_EXTRA); - - if (senderIDExtra != null) { - String senderID = actualSenderIDFromExtra(senderIDExtra); - - if (senderID != null) { - senderIDs += ("," + senderID); - } else { - PLog.e(TAG, "Found " + SENDER_ID_EXTRA + " element with value \"" + - senderIDExtra.toString() + "\", but the value is missing the expected \"id:\" " + - "prefix."); + requesting = true; + + return Task.callInBackground(new Callable() { + @Override + public String call() throws Exception { + // The InstanceID library already handles backoffs and retries, but I've seen the overall + // process throw a timeout exception. This is just a minor defense in depth against + // transient issues. + Exception lastException = null; + String senderID = getSenderID(); + for (int attempt = 0; attempt < 3; attempt++) { + try { + return InstanceID.getInstance(context).getToken(senderID, "GCM"); + } catch (Exception e) { + lastException = e; + } } + throw lastException; } - } - - request = Request.createAndSend(context, senderIDs); - return request.getTask().continueWith(new Continuation() { + }).continueWith(new Continuation() { @Override public Void then(Task task) { Exception e = task.getError(); if (e != null) { PLog.e(TAG, "Got error when trying to register for GCM push", e); + } else { + setGCMRegistrationId(task.getResult()); } synchronized (lock) { - request = null; + requesting = false; } return null; @@ -171,48 +194,61 @@ public Void then(Task task) { } /** - * Should be called by a broadcast receiver or service to handle the GCM registration response - * intent (com.google.android.c2dm.intent.REGISTRATION). + * On older versions of Android that only support GCM v3, the GCM libraries will require the + * com.google.android.c2dm.intent.REGISTRATION to be handled by a service, which gets trampolined + * back here. On newer versions of Android which support GCM v3, the device ID is generated by + * the InstanceID. The InstanceID is easy to fetch and is relatively stable but can be changed. + * Developers are advised to handle the com.google.android.gms.iid.InstanceID intent and re-fetch + * the InstanceID and tokens. */ - public Task handleRegistrationIntentAsync(Intent intent) { + public Task setGCMRegistrationId(final String registrationId) { List> tasks = new ArrayList<>(); - /* - * We have to parse the response here because GCM may send us a new registration_id - * out-of-band without a request in flight. - */ - String registrationId = intent.getStringExtra(REGISTRATION_ID_EXTRA); if (registrationId != null && registrationId.length() > 0) { PLog.v(TAG, "Received deviceToken <" + registrationId + "> from GCM."); - ParseInstallation installation = ParseInstallation.getCurrentInstallation(); + final ParseInstallation installation = ParseInstallation.getCurrentInstallation(); // Compare the new deviceToken with the old deviceToken, we only update the // deviceToken if the new one is different from the old one. This does not follow google // guide strictly. But we find most of the time if user just update the app, the // registrationId does not change so there is no need to save it again. if (!registrationId.equals(installation.getDeviceToken())) { + boolean wasAlreadyV4 = installation.isUsingGCMv4(); + installation.setPushType(PushType.GCM); installation.setDeviceToken(registrationId); + + // GCM v4 has a built-in pubsub concept that is redundant with push to channels. It's + // cheaper and dramatically faster to use push to a GCM topic than to use the standard + // mongo pipeline, so we backfill current subscriptions. + if (installation.isUsingGCMv4() && !wasAlreadyV4) { + List channels = installation.getList(ParseInstallation.KEY_CHANNELS); + tasks.add(subscribeToGCMTopics(channels)); + } + + // Old versions of the SDK would mint tokens that were reachable from the Parse Push servers + // but used the Parse Sender ID. In the transition to GCM v4 we choose to submit a token + // works with the Parse sender ID _or_ the developer sender ID, but not both (because these + // become two tokens in GCM v4 and the server would need breaking changes to handle this). + // To keep Parse.com push working correctly we need to pin this token to the developer's + // sender ID. + String senderID = getSenderID(); + if (senderID != null && senderID != PARSE_SENDER_ID) { + installation.put("GCMSenderId", senderID); + } + + if (installation.getPushType() != PushType.GCM) { + installation.setPushType(PushType.GCM); + } tasks.add(installation.saveInBackground()); } // We need to update the last modified even the deviceToken is the same. Otherwise when the // app is opened again, isDeviceTokenStale() will always return false so we will send // request to GCM every time. tasks.add(updateLocalDeviceTokenLastModifiedAsync()); - } - synchronized (lock) { - if (request != null) { - request.onReceiveResponseIntent(intent); - } - } - return Task.whenAll(tasks); - } - // Only used by tests. - /* package */ int getRequestIdentifier() { - synchronized (lock) { - return request != null ? request.identifier : 0; } + return Task.whenAll(tasks); } /** package for tests */ Task isLocalDeviceTokenStaleAsync() { @@ -225,6 +261,70 @@ public Task then(Task task) throws Exception { }); } + private static final String GCM_TOPIC_PATTERN = "^[0-9a-zA-Z-_.~%]{1,900}$"; + private static final String GCM_TOPIC_PREFIX = "/topics/"; + + /* Used during channel subscription to keep GCM topics in sync. When a device first upgrades to + * GCM v4 it sends all prior channels to backfill GCM. + */ + Task subscribeToGCMTopics(final List channels) { + final ParseInstallation installation = ParseInstallation.getCurrentInstallation(); + if (!installation.isUsingGCMv4()) { + return Task.forResult(null); + } + + return Task.callInBackground(new Callable() { + @Override + public GcmPubSub call() throws Exception { + return GcmPubSub.getInstance(context); + } + }).continueWithTask(new Continuation>() { + public Task then(Task task) { + final GcmPubSub pubSub = task.getResult(); + final String registrationId = installation.getDeviceToken(); + List channels = installation.getList(ParseInstallation.KEY_CHANNELS); + List> registrations = new ArrayList>(); + + for (String channel: channels) { + if (!channel.matches(GCM_TOPIC_PATTERN)) { + PLog.w(TAG, "Cannot subscribe channel " + channel + " as a GCM topic because it is an" + + "invalid GCM topic name"); + continue; + } + + final String topic = GCM_TOPIC_PREFIX + channel; + Task registration = Task.callInBackground(new Callable() { + @Override + public Void call() throws Exception { + pubSub.subscribe(registrationId, topic, null /* extras */); + return null; + } + }); + registrations.add(registration); + } + + return Task.whenAll(registrations); + } + }); + } + + Task unsubscribeFromGCMTopic(final String channel) { + final ParseInstallation installation = ParseInstallation.getCurrentInstallation(); + if (!installation.isUsingGCMv4() || !channel.matches(GCM_TOPIC_PATTERN)) { + return Task.forResult(null); + } + + return Task.callInBackground(new Callable() { + @Override + public Void call() throws Exception { + GcmPubSub pubsub = GcmPubSub.getInstance(context); + String topic = GCM_TOPIC_PREFIX + channel; + pubsub.unsubscribe(installation.getDeviceToken(), topic); + return null; + } + }); + } + /** package for tests */ Task updateLocalDeviceTokenLastModifiedAsync() { return Task.call(new Callable() { @Override @@ -273,125 +373,4 @@ public Long call() throws Exception { /** package for tests */ static void deleteLocalDeviceTokenLastModifiedFile() { ParseFileUtils.deleteQuietly(getLocalDeviceTokenLastModifiedFile()); } - - /** - * Encapsulates the a GCM registration request-response, potentially using AlarmManager to - * schedule retries if the GCM service is not available. - */ - private static class Request { - private static final String RETRY_ACTION = "com.parse.RetryGcmRegistration"; - private static final int MAX_RETRIES = 5; - private static final int BACKOFF_INTERVAL_MS = 3000; - - final private Context context; - final private String senderId; - final private Random random; - final private int identifier; - final private TaskCompletionSource tcs; - final private PendingIntent appIntent; - final private AtomicInteger tries; - final private PendingIntent retryIntent; - final private BroadcastReceiver retryReceiver; - - public static Request createAndSend(Context context, String senderId) { - Request request = new Request(context, senderId); - request.send(); - - return request; - } - - private Request(Context context, String senderId) { - this.context = context; - this.senderId = senderId; - this.random = new Random(); - this.identifier = this.random.nextInt(); - this.tcs = new TaskCompletionSource<>(); - this.appIntent = PendingIntent.getBroadcast(this.context, identifier, new Intent(), 0); - this.tries = new AtomicInteger(0); - - String packageName = this.context.getPackageName(); - Intent intent = new Intent(RETRY_ACTION).setPackage(packageName); - intent.addCategory(packageName); - intent.putExtra("random", identifier); - this.retryIntent = PendingIntent.getBroadcast(this.context, identifier, intent, 0); - - this.retryReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (intent != null && intent.getIntExtra("random", 0) == identifier) { - send(); - } - } - }; - - IntentFilter filter = new IntentFilter(); - filter.addAction(RETRY_ACTION); - filter.addCategory(packageName); - - context.registerReceiver(this.retryReceiver, filter); - } - - public Task getTask() { - return tcs.getTask(); - } - - private void send() { - Intent intent = new Intent(REGISTER_ACTION); - intent.setPackage("com.google.android.gsf"); - intent.putExtra("sender", senderId); - intent.putExtra("app", appIntent); - - ComponentName name = null; - try { - name = context.startService(intent); - } catch (SecurityException exception) { - // do nothing - } - - if (name == null) { - finish(null, "GSF_PACKAGE_NOT_AVAILABLE"); - } - - tries.incrementAndGet(); - - PLog.v(TAG, "Sending GCM registration intent"); - } - - public void onReceiveResponseIntent(Intent intent) { - String registrationId = intent.getStringExtra(REGISTRATION_ID_EXTRA); - String error = intent.getStringExtra(ERROR_EXTRA); - - if (registrationId == null && error == null) { - PLog.e(TAG, "Got no registration info in GCM onReceiveResponseIntent"); - return; - } - - // Retry with exponential backoff if GCM isn't available. - if ("SERVICE_NOT_AVAILABLE".equals(error) && tries.get() < MAX_RETRIES) { - AlarmManager manager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); - int alarmType = AlarmManager.ELAPSED_REALTIME_WAKEUP; - long delay = (1 << tries.get()) * BACKOFF_INTERVAL_MS + random.nextInt(BACKOFF_INTERVAL_MS); - long start = SystemClock.elapsedRealtime() + delay; - manager.set(alarmType, start, retryIntent); - } else { - finish(registrationId, error); - } - } - - private void finish(String registrationId, String error) { - boolean didSetResult; - - if (registrationId != null) { - didSetResult = tcs.trySetResult(registrationId); - } else { - didSetResult = tcs.trySetError(new Exception("GCM registration error: " + error)); - } - - if (didSetResult) { - appIntent.cancel(); - retryIntent.cancel(); - context.unregisterReceiver(this.retryReceiver); - } - } - } } diff --git a/Parse/src/main/java/com/parse/ManifestInfo.java b/Parse/src/main/java/com/parse/ManifestInfo.java index 96451570e..8dd41a081 100644 --- a/Parse/src/main/java/com/parse/ManifestInfo.java +++ b/Parse/src/main/java/com/parse/ManifestInfo.java @@ -238,7 +238,7 @@ public static PushType getPushType() { ParsePushBroadcastReceiver.ACTION_PUSH_DELETE + ". You can do this by adding " + "these lines to your AndroidManifest.xml:\n\n" + " \n" + + " android:exported=\"false\">\n" + " \n" + " \n" + " \n" + @@ -291,9 +291,14 @@ public static PushType getPushType() { * but push isn't actually enabled because the manifest is misconfigured. */ public static String getNonePushTypeLogMessage() { - return "Push is not configured for this app because the app manifest is missing required " + - "declarations. Please add the following declarations to your app manifest to use GCM for " + - "push: " + getGcmManifestMessage(); + if (isGooglePlayServicesAvailable()) { + return "Push is not configured for this app because the app manifest is missing required " + + "declarations. Please add the following declarations to your app manifest to use GCM for " + + "push: " + getGcmManifestMessage(); + } else { + return "Push is not available on this device because Google Play Services are not available " + + "on this device and PPNS is not enabled in your app manifest."; + } } enum ManifestCheckResult { @@ -500,7 +505,11 @@ private static ManifestCheckResult gcmSupportLevel() { } String[] optionalPermissions = new String[] { - "android.permission.VIBRATE" + "android.permission.VIBRATE", + + // Technically this is optional, but the app will not get updates to expired push tokens until + // it performs another manual check. + "com.google.android.gms.iid.InstanceID" }; if (!hasGrantedPermissions(context, optionalPermissions)) { @@ -570,6 +579,7 @@ private static String getGcmManifestMessage() { " \n" + " \n" + " \n" + + " \n" + " \n" + " \n" + "\n" + diff --git a/Parse/src/main/java/com/parse/ParseInstallation.java b/Parse/src/main/java/com/parse/ParseInstallation.java index 70019e314..91d57a425 100644 --- a/Parse/src/main/java/com/parse/ParseInstallation.java +++ b/Parse/src/main/java/com/parse/ParseInstallation.java @@ -243,6 +243,11 @@ private void updateLocaleIdentifier() { } } + /* package */ boolean isUsingGCMv4() { + String token = getDeviceToken(); + return token != null && token.indexOf(':') != -1; + } + // TODO(mengyan): Move to ParseInstallationInstanceController /* package */ void updateDeviceInfo() { updateDeviceInfo(ParsePlugins.get().installationId()); diff --git a/Parse/src/main/java/com/parse/ParseNotificationManager.java b/Parse/src/main/java/com/parse/ParseNotificationManager.java index f206dadc9..1a9848d2e 100644 --- a/Parse/src/main/java/com/parse/ParseNotificationManager.java +++ b/Parse/src/main/java/com/parse/ParseNotificationManager.java @@ -48,7 +48,7 @@ public int getNotificationCount() { return notificationCount.get(); } - public void showNotification(Context context, Notification notification) { + public void showNotification(Context context, int id, Notification notification) { if (context != null && notification != null) { notificationCount.incrementAndGet(); @@ -58,14 +58,16 @@ public void showNotification(Context context, Notification notification) { (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); // Pick an id that probably won't overlap anything - int notificationId = (int)System.currentTimeMillis(); + if (id == 0) { + id = (int) System.currentTimeMillis(); + } try { - nm.notify(notificationId, notification); + nm.notify(id, notification); } catch (SecurityException e) { // Some phones throw an exception for unapproved vibration notification.defaults = Notification.DEFAULT_LIGHTS | Notification.DEFAULT_SOUND; - nm.notify(notificationId, notification); + nm.notify(id, notification); } } } diff --git a/Parse/src/main/java/com/parse/ParsePushChannelsController.java b/Parse/src/main/java/com/parse/ParsePushChannelsController.java index 02cd77b10..d6934c15b 100644 --- a/Parse/src/main/java/com/parse/ParsePushChannelsController.java +++ b/Parse/src/main/java/com/parse/ParsePushChannelsController.java @@ -8,6 +8,8 @@ */ package com.parse; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -29,16 +31,19 @@ public Task subscribeInBackground(final String channel) { return getCurrentInstallationController().getAsync().onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { - ParseInstallation installation = task.getResult(); + final ParseInstallation installation = task.getResult(); + List> tasks = new ArrayList>(); + List channels = installation.getList(ParseInstallation.KEY_CHANNELS); if (channels == null || installation.isDirty(ParseInstallation.KEY_CHANNELS) || !channels.contains(channel)) { installation.addUnique(ParseInstallation.KEY_CHANNELS, channel); - return installation.saveInBackground(); - } else { - return Task.forResult(null); + tasks.add(installation.saveInBackground()); + tasks.add(GcmRegistrar.getInstance().subscribeToGCMTopics(Arrays.asList(channel))); } + + return Task.whenAll(tasks); } }); } @@ -51,15 +56,18 @@ public Task unsubscribeInBackground(final String channel) { return getCurrentInstallationController().getAsync().onSuccessTask(new Continuation>() { @Override public Task then(Task task) throws Exception { - ParseInstallation installation = task.getResult(); + final ParseInstallation installation = task.getResult(); + List> tasks = new ArrayList>(); + List channels = installation.getList(ParseInstallation.KEY_CHANNELS); if (channels != null && channels.contains(channel)) { installation.removeAll( ParseInstallation.KEY_CHANNELS, Collections.singletonList(channel)); - return installation.saveInBackground(); - } else { - return Task.forResult(null); + tasks.add(installation.saveInBackground()); + tasks.add(GcmRegistrar.getInstance().unsubscribeFromGCMTopic(channel)); } + + return Task.whenAll(tasks); } }); } From e2852a74371797da9eb6421398847409f4acde22 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Mon, 9 May 2016 16:02:36 -0700 Subject: [PATCH 2/4] Fix break from abandoned attempt to thread the GCM push ID as the Notification ID --- Parse/src/main/java/com/parse/ParseNotificationManager.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Parse/src/main/java/com/parse/ParseNotificationManager.java b/Parse/src/main/java/com/parse/ParseNotificationManager.java index 1a9848d2e..b80164894 100644 --- a/Parse/src/main/java/com/parse/ParseNotificationManager.java +++ b/Parse/src/main/java/com/parse/ParseNotificationManager.java @@ -48,7 +48,7 @@ public int getNotificationCount() { return notificationCount.get(); } - public void showNotification(Context context, int id, Notification notification) { + public void showNotification(Context context, Notification notification) { if (context != null && notification != null) { notificationCount.incrementAndGet(); @@ -58,9 +58,7 @@ public void showNotification(Context context, int id, Notification notification) (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); // Pick an id that probably won't overlap anything - if (id == 0) { - id = (int) System.currentTimeMillis(); - } + int id = (int) System.currentTimeMillis(); try { nm.notify(id, notification); From 0a8920e0d482ecf1ada8bb5d16f063375c0c36b4 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Mon, 9 May 2016 18:32:46 -0700 Subject: [PATCH 3/4] Update gradle things --- build.gradle | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index e1feb49c5..42b8b35db 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:1.3.1' + classpath 'com.android.tools.build:gradle:2.1.0' } } @@ -16,7 +16,7 @@ allprojects { ext { compileSdkVersion = 22 - buildToolsVersion = "23.0.1" + buildToolsVersion = "23.0.3" minSdkVersion = 9 targetSdkVersion = 23 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cd481edfb..ae5079024 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Nov 06 11:00:14 PST 2014 +#Mon May 09 18:14:42 PDT 2016 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip From b6937df3e903d7e3244b3b4dc7cf27f96acd74aa Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Mon, 9 May 2016 19:02:34 -0700 Subject: [PATCH 4/4] # This is a combination of 2 commits. # The first commit's message is: Fix .travis.yml # The 2nd commit message will be skipped: # Add missing dependencies --- .travis.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ab31f2699..090ea2249 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,11 +7,16 @@ sudo: false android: components: - - build-tools-23.0.1 + - platform-tools + - tools + - build-tools-23.0.3 - android-22 - doc-23 - extra-android-support - extra-android-m2repository + - extra-google-m2repository + - extra-google-google_play_services + - addon-google_apis-google-28 before_install: - pip install --user codecov