PushService.java
/*
* Copyright (c) 2014-present, salesforce.com, inc.
* All rights reserved.
* Redistribution and use of this software in source and binary forms, with or
* without modification, are permitted provided that the following conditions
* are met:
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of salesforce.com, inc. nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission of salesforce.com, inc.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package com.salesforce.androidsdk.push;
import android.app.AlarmManager;
import android.app.IntentService;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.PowerManager;
import android.util.Log;
import com.salesforce.androidsdk.accounts.UserAccount;
import com.salesforce.androidsdk.accounts.UserAccountManager;
import com.salesforce.androidsdk.app.SalesforceSDKManager;
import com.salesforce.androidsdk.auth.HttpAccess;
import com.salesforce.androidsdk.rest.ApiVersionStrings;
import com.salesforce.androidsdk.rest.ClientManager;
import com.salesforce.androidsdk.rest.ClientManager.AccMgrAuthTokenProvider;
import com.salesforce.androidsdk.rest.RestClient;
import com.salesforce.androidsdk.rest.RestClient.ClientInfo;
import com.salesforce.androidsdk.rest.RestRequest;
import com.salesforce.androidsdk.rest.RestResponse;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* This class houses functionality related to push notifications.
* It performs registration and unregistration of push notifications
* against the Salesforce connected app endpoint. It also receives
* push notifications sent by the org to the registered user/device.
*
* @author bhariharan
* @author ktanna
*/
public class PushService extends IntentService {
private static final String TAG = "PushService";
// Intent actions.
public static final String GCM_REGISTRATION_CALLBACK_INTENT = "com.google.android.c2dm.intent.REGISTRATION";
public static final String GCM_RECEIVE_INTENT = "com.google.android.c2dm.intent.RECEIVE";
public static final String SFDC_REGISTRATION_RETRY_INTENT = "com.salesforce.mobilesdk.c2dm.intent.RETRY";
// Extras in the registration callback intents.
private static final String EXTRA_UNREGISTERED = "unregistered";
private static final String EXTRA_ERROR = "error";
private static final String EXTRA_REGISTRATION_ID = "registration_id";
// Error constant when service is not available.
private static final String ERR_SERVICE_NOT_AVAILABLE = "SERVICE_NOT_AVAILABLE";
// Retry time constants.
private static final long MILLISECONDS_IN_SIX_DAYS = 518400000L;
private static final long SFDC_REGISTRATION_RETRY = 30000;
// Salesforce push notification constants.
private static final String MOBILE_PUSH_SERVICE_DEVICE = "MobilePushServiceDevice";
private static final String ANDROID_GCM = "androidGcm";
private static final String SERVICE_TYPE = "ServiceType";
private static final String CONNECTION_TOKEN = "ConnectionToken";
private static final String FIELD_ID = "id";
private static final String NOT_ENABLED = "not_enabled";
// Wake lock instance.
private static PowerManager.WakeLock WAKE_LOCK;
private Context context;
/**
* This method is called from the broadcast receiver, when a push notification
* is received, or when we receive a callback from the GCM service. Processing
* of the message occurs here, and the acquired wake lock is released post processing.
*
* @param intent Intent.
*/
static void runIntentInService(Intent intent) {
final Context context = SalesforceSDKManager.getInstance().getAppContext();
if (WAKE_LOCK == null) {
final PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
WAKE_LOCK = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
}
WAKE_LOCK.acquire();
intent.setClassName(context, PushService.class.getName());
final ComponentName name = context.startService(intent);
if (name == null) {
Log.w(TAG, "Could not start GCM service.");
}
}
/**
* Default constructor.
*/
public PushService() {
super(TAG);
context = SalesforceSDKManager.getInstance().getAppContext();
}
@Override
protected void onHandleIntent(Intent intent) {
final Context context = SalesforceSDKManager.getInstance().getAppContext();
/*
* Grabs the extras from the intent, and determines based on the
* bundle whether to perform the operation for all accounts or
* just the specified account that's passed in.
*/
final Bundle bundle = intent.getBundleExtra(PushMessaging.ACCOUNT_BUNDLE_KEY);
UserAccount account = null;
boolean allAccounts = false;
if (bundle != null) {
final String allAccountsValue = bundle.getString(PushMessaging.ACCOUNT_BUNDLE_KEY);
if (PushMessaging.ALL_ACCOUNTS_BUNDLE_VALUE.equals(allAccountsValue)) {
allAccounts = true;
} else {
account = new UserAccount(bundle);
}
}
final UserAccountManager userAccMgr = SalesforceSDKManager.getInstance().getUserAccountManager();
final List<UserAccount> accounts = userAccMgr.getAuthenticatedUsers();
try {
if (GCM_REGISTRATION_CALLBACK_INTENT.equals(intent.getAction())) {
if (allAccounts) {
if (accounts != null) {
for (final UserAccount userAcc : accounts) {
handleRegistration(intent, userAcc);
}
}
} else if (account != null) {
handleRegistration(intent, account);
} else {
handleRegistration(intent, userAccMgr.getCurrentUser());
}
} else if (GCM_RECEIVE_INTENT.equals(intent.getAction())) {
onMessage(intent);
} else if (SFDC_REGISTRATION_RETRY_INTENT.equals(intent.getAction())) {
if (allAccounts) {
if (accounts != null) {
for (final UserAccount userAcc : accounts) {
final String regId = PushMessaging.getRegistrationId(context,
userAcc);
if (regId != null) {
onRegistered(regId, userAcc);
}
}
}
} else {
if (account == null) {
account = userAccMgr.getCurrentUser();
}
final String regId = PushMessaging.getRegistrationId(context,
account);
if (regId != null) {
onRegistered(regId, account);
}
}
}
} finally {
// Releases the wake lock, since processing is complete.
if (WAKE_LOCK != null && WAKE_LOCK.isHeld()) {
WAKE_LOCK.release();
}
}
}
/**
* Handles a push notification message.
*
* @param intent Intent.
*/
protected void onMessage(Intent intent) {
if (intent != null) {
final Bundle pushMessage = intent.getExtras();
final PushNotificationInterface pnInterface = SalesforceSDKManager.getInstance().getPushNotificationReceiver();
if (pnInterface != null && pushMessage != null) {
pnInterface.onPushMessageReceived(pushMessage);
}
}
}
/**
* Handles errors associated with registration or un-registration.
*
* @param error Error received from the GCM service.
* @param account User account.
*/
private void onError(String error, UserAccount account) {
if (PushMessaging.isRegistered(context, account)) {
handleUnRegistrationError(error, account);
} else {
handleRegistrationError(error, account);
}
}
/**
* Handles registration errors. Retries on service unavailable, and
* bails out on other types of errors.
*
* @param error Error received from the GCM service.
* @param account User account.
*/
private void handleRegistrationError(String error, UserAccount account) {
if (error != null && ERR_SERVICE_NOT_AVAILABLE.equals(error)) {
scheduleGCMRetry(true, account);
}
}
/**
* Handles unregistration errors.
*
* @param error Error received from the GCM service.
* @param account User account.
*/
private void handleUnRegistrationError(String error, UserAccount account) {
if (PushMessaging.isRegisteredWithSFDC(context, account)) {
final String id = PushMessaging.getDeviceId(context, account);
if (id != null) {
unregisterSFDCPushNotification(id, account);
}
}
context.sendBroadcast((new Intent(PushMessaging.UNREGISTERED_ATTEMPT_COMPLETE_EVENT)).setPackage(context.getPackageName()));
scheduleGCMRetry(false, account);
}
/**
* Schedules retry of GCM registration or un-registration.
*
* @param register True - for registration retry, False - for un-registration retry.
* @param account User account.
*/
private void scheduleGCMRetry(boolean register, UserAccount account) {
long backoffTimeMs = PushMessaging.getBackoff(context, account);
final Calendar cal = Calendar.getInstance();
cal.add(Calendar.MILLISECOND, (int) backoffTimeMs);
final Intent retryIntent = new Intent(context, register ? RetryRegistrationAlarmReceiver.class
: UnregisterRetryAlarmReceiver.class);
if (account != null) {
retryIntent.putExtra(PushMessaging.ACCOUNT_BUNDLE_KEY, account.toBundle());
}
final PendingIntent retryPIntent = PendingIntent.getBroadcast(context,
1, retryIntent, PendingIntent.FLAG_ONE_SHOT);
final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
am.set(AlarmManager.RTC_WAKEUP, cal.getTimeInMillis(), retryPIntent);
// Next retry should wait longer.
backoffTimeMs *= 2;
PushMessaging.setBackoff(context, backoffTimeMs, account);
}
/**
* Schedules retry of SFDC registration.
*
* @param when When to retry.
* @param account User account.
*/
private void scheduleSFDCRegistrationRetry(long when, UserAccount account) {
final Calendar cal = Calendar.getInstance();
cal.add(Calendar.MILLISECOND, (int) when);
final Intent retryIntent = new Intent(context, SFDCRegistrationRetryAlarmReceiver.class);
if (account == null) {
final Bundle bundle = new Bundle();
bundle.putString(PushMessaging.ACCOUNT_BUNDLE_KEY, PushMessaging.ALL_ACCOUNTS_BUNDLE_VALUE);
retryIntent.putExtra(PushMessaging.ACCOUNT_BUNDLE_KEY, bundle);
} else {
retryIntent.putExtra(PushMessaging.ACCOUNT_BUNDLE_KEY, account.toBundle());
}
final PendingIntent retryPIntent = PendingIntent.getBroadcast(context,
1, retryIntent, PendingIntent.FLAG_ONE_SHOT);
final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
am.set(AlarmManager.RTC_WAKEUP, cal.getTimeInMillis(), retryPIntent);
}
/**
* This method is called when registration with GCM is successful.
*
* @param registrationId Registration ID received from GCM service.
* @param account User account.
*/
private void onRegistered(String registrationId, UserAccount account) {
if (account == null) {
Log.e(TAG, "Account is null. Will retry registration later");
return;
}
long retryInterval = SFDC_REGISTRATION_RETRY;
try {
final String id = registerSFDCPushNotification(registrationId, account);
if (id != null) {
retryInterval = MILLISECONDS_IN_SIX_DAYS;
PushMessaging.setRegistrationInfo(context, registrationId, id,
account);
} else {
PushMessaging.setRegistrationId(context, registrationId, account);
}
} catch (Exception e) {
Log.e(TAG, "Error occurred during SFDC registration.", e);
} finally {
scheduleSFDCRegistrationRetry(retryInterval, null);
}
}
/**
* This method is called when the device has been un-registered.
*
* @param account User account.
*/
private void onUnregistered(UserAccount account) {
try {
final String id = PushMessaging.getDeviceId(context, account);
unregisterSFDCPushNotification(id, account);
} catch (Exception e) {
Log.e(TAG, "Error occurred during SFDC un-registration.", e);
} finally {
PushMessaging.clearRegistrationInfo(context, account);
context.sendBroadcast((new Intent(PushMessaging.UNREGISTERED_ATTEMPT_COMPLETE_EVENT)).setPackage(context.getPackageName()));
context.sendBroadcast((new Intent(PushMessaging.UNREGISTERED_EVENT)).setPackage(context.getPackageName()));
}
}
/**
* Hits the Salesforce endpoint to register for push notifications.
*
* @param registrationId Registration ID.
* @param account User account.
* @return Salesforce ID that uniquely identifies the registered device.
*/
private String registerSFDCPushNotification(String registrationId,
UserAccount account) {
final Map<String, Object> fields = new HashMap<String, Object>();
fields.put(CONNECTION_TOKEN, registrationId);
fields.put(SERVICE_TYPE, ANDROID_GCM);
try {
final RestClient client = getRestClient(account);
final RestRequest req = RestRequest.getRequestForCreate(ApiVersionStrings.getVersionNumber(context),
MOBILE_PUSH_SERVICE_DEVICE, fields);
if (client != null) {
final RestResponse res = client.sendSync(req);
String id = null;
/*
* If the push notification device object has been created,
* reads the device registration ID. If the status code
* indicates that the resource is not found, push notifications
* are not enabled for this connected app, which means we
* should not attempt to re-register a few minutes later.
*/
if (res.getStatusCode() == HttpURLConnection.HTTP_CREATED) {
final JSONObject obj = res.asJSONObject();
if (obj != null) {
id = obj.getString(FIELD_ID);
}
} else if (res.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
id = NOT_ENABLED;
}
res.consume();
return id;
}
} catch (Exception e) {
Log.e(TAG, "Push notification registration failed.", e);
}
return null;
}
/**
* Hits the Salesforce endpoint to un-register from push notifications.
*
* @param registeredId Salesforce ID that uniquely identifies the registered device.
* @param account User account.
* @return True - if un-registration was successful, False - otherwise.
*/
private boolean unregisterSFDCPushNotification(String registeredId,
UserAccount account) {
final RestRequest req = RestRequest.getRequestForDelete(ApiVersionStrings.getVersionNumber(context),
MOBILE_PUSH_SERVICE_DEVICE, registeredId);
try {
final RestClient client = getRestClient(account);
if (client != null) {
final RestResponse res = client.sendSync(req);
if (res.getStatusCode() == HttpURLConnection.HTTP_NO_CONTENT) {
return true;
}
res.consume();
}
} catch (IOException e) {
Log.e(TAG, "Push notification un-registration failed.", e);
}
return false;
}
/**
* Gets an instance of RestClient.
*
* @param account User account.
* @return Instance of RestClient.
*/
private RestClient getRestClient(UserAccount account) {
final ClientManager cm = SalesforceSDKManager.getInstance().getClientManager();
RestClient client = null;
/*
* The reason we can't directly call 'peekRestClient()' here is because
* ClientManager does not hand out a rest client when a logout is in
* progress. Hence, we build a rest client here manually, with the
* available data in the 'account' object.
*/
if (cm != null) {
try {
final AccMgrAuthTokenProvider authTokenProvider = new AccMgrAuthTokenProvider(cm,
account.getInstanceServer(), account.getAuthToken(), account.getRefreshToken());
final ClientInfo clientInfo = new ClientInfo(account.getClientId(),
new URI(account.getInstanceServer()), new URI(account.getLoginServer()),
new URI(account.getIdUrl()), account.getAccountName(), account.getUsername(),
account.getUserId(), account.getOrgId(),
account.getCommunityId(), account.getCommunityUrl(),
account.getFirstName(), account.getLastName(), account.getDisplayName(), account.getEmail(),
account.getPhotoUrl(), account.getThumbnailUrl(), account.getAdditionalOauthValues());
client = new RestClient(clientInfo, account.getAuthToken(),
HttpAccess.DEFAULT, authTokenProvider);
} catch (Exception e) {
Log.e(TAG, "Failed to get rest client.");
}
}
return client;
}
/**
* Handles registration callback.
*
* @param intent Intent.
* @param account User account.
*/
private void handleRegistration(Intent intent, UserAccount account) {
final String registrationId = intent.getStringExtra(EXTRA_REGISTRATION_ID);
final String error = intent.getStringExtra(EXTRA_ERROR);
final String removed = intent.getStringExtra(EXTRA_UNREGISTERED);
if (removed != null) {
onUnregistered(account);
} else if (error != null) {
onError(error, account);
} else {
onRegistered(registrationId, account);
}
}
/**
* Broadcast receiver to retry the entire push registration process (GCM + SFDC).
*
* @author ktanna
*/
public static class RetryRegistrationAlarmReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent != null) {
final Bundle accBundle = intent.getBundleExtra(PushMessaging.ACCOUNT_BUNDLE_KEY);
if (accBundle != null) {
final String allAccountsValue = accBundle.getString(PushMessaging.ACCOUNT_BUNDLE_KEY);
if (PushMessaging.ALL_ACCOUNTS_BUNDLE_VALUE.equals(allAccountsValue)) {
PushMessaging.register(context, null);
} else {
PushMessaging.register(context, new UserAccount(accBundle));
}
}
}
}
}
/**
* Broadcast receiver to retry SFDC push registration.
*
* @author ktanna
*/
public static class SFDCRegistrationRetryAlarmReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent != null) {
final Bundle accBundle = intent.getBundleExtra(PushMessaging.ACCOUNT_BUNDLE_KEY);
if (accBundle != null) {
final String allAccountsValue = accBundle.getString(PushMessaging.ACCOUNT_BUNDLE_KEY);
if (PushMessaging.ALL_ACCOUNTS_BUNDLE_VALUE.equals(allAccountsValue)) {
PushMessaging.registerSFDCPush(context, null);
} else {
PushMessaging.registerSFDCPush(context, new UserAccount(accBundle));
}
}
}
}
}
/**
* Broadcast receiver to retry GCM registration.
*
* @author ktanna
*/
public static class UnregisterRetryAlarmReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent != null) {
final Bundle accBundle = intent.getBundleExtra(PushMessaging.ACCOUNT_BUNDLE_KEY);
if (accBundle != null) {
PushMessaging.unregister(context, new UserAccount(accBundle));
}
}
}
}
}