/*
* Kontalk Android client
* Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kontalk.service.gcm;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.SystemClock;
import org.kontalk.Log;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesUtil;
import com.google.android.gms.gcm.GoogleCloudMessaging;
import org.kontalk.Kontalk;
import org.kontalk.service.msgcenter.IPushListener;
import org.kontalk.service.msgcenter.IPushService;
import org.kontalk.util.SystemUtils;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* Push service for Google Cloud Messaging.
* @author Daniele Ricci
*/
public class GcmPushService implements IPushService {
private static final String TAG = Kontalk.TAG;
/**
* Default lifespan (7 days) of the {@link #isRegisteredOnServer()}
* flag until it is considered expired.
*/
// NOTE: cannot use TimeUnit.DAYS because it's not available on API Level 8
public static final long DEFAULT_ON_SERVER_LIFESPAN_MS =
1000 * 3600 * 24 * 7;
private static final Random sRandom = new Random();
private static final String BACKOFF_MS = "backoff_ms";
private static final int DEFAULT_BACKOFF_MS = 3000;
private static final int MAX_BACKOFF_MS =
(int) TimeUnit.SECONDS.toMillis(3600); // 1 hour
private static final String PROPERTY_REG_ID = "registration_id";
private static final String PROPERTY_APP_VERSION = "appVersion";
private static final String PROPERTY_ON_SERVER = "onServer";
private static final String PROPERTY_ON_SERVER_EXPIRATION_TIME =
"onServerExpirationTime";
private static final String PROPERTY_ON_SERVER_LIFESPAN =
"onServerLifeSpan";
private IPushListener mListener;
private Context mContext;
private GoogleCloudMessaging mGcm;
public GcmPushService(Context context) {
mContext = context.getApplicationContext();
}
private void ensureGcmInstance() {
if (mGcm == null)
mGcm = GoogleCloudMessaging.getInstance(mContext);
}
@Override
public void register(final IPushListener listener, final String senderId) {
mListener = listener;
resetBackoff();
new Thread(new Runnable() {
public void run() {
ensureGcmInstance();
try {
String regId = mGcm.register(senderId);
// persist the regID - no need to register again.
storeRegistrationId(regId);
// call the listener
listener.onRegistered(mContext, regId);
}
catch (IOException e) {
listener.onError(mContext, e.toString());
}
}
}).start();
}
@Override
public void unregister(final IPushListener listener) {
mListener = listener;
resetBackoff();
new Thread(new Runnable() {
public void run() {
ensureGcmInstance();
try {
mGcm.unregister();
// persist the regID - no need to register again.
storeRegistrationId("");
// call the listener
listener.onUnregistered(mContext);
}
catch (IOException e) {
listener.onError(mContext, e.toString());
retryOnError();
}
}
}).start();
}
public void retry() {
String senderId = mListener.getSenderId(mContext);
if (isRegistered() || senderId == null) {
// force unregister if sender id is not present
unregister(mListener);
}
else {
register(mListener, senderId);
}
}
@Override
public boolean isRegistered() {
return getRegistrationId().length() > 0;
}
@Override
public boolean isServiceAvailable() {
int status = GooglePlayServicesUtil
.isGooglePlayServicesAvailable(mContext);
return status == ConnectionResult.SUCCESS ||
status == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED;
}
@Override
public void setRegisteredOnServer(boolean flag) {
final SharedPreferences prefs = getGCMPreferences(mContext);
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(PROPERTY_ON_SERVER, flag);
// set the flag's expiration date
long lifespan = getRegisterOnServerLifespan();
long expirationTime = System.currentTimeMillis() + lifespan;
Log.v(TAG, "Setting registeredOnServer status as " + flag + " until " +
new Timestamp(expirationTime));
editor.putLong(PROPERTY_ON_SERVER_EXPIRATION_TIME, expirationTime);
editor.commit();
}
@Override
public boolean isRegisteredOnServer() {
final SharedPreferences prefs = getGCMPreferences(mContext);
boolean isRegistered = prefs.getBoolean(PROPERTY_ON_SERVER, false);
Log.v(TAG, "Is registered on server: " + isRegistered);
if (isRegistered) {
// checks if the information is not stale
long expirationTime =
prefs.getLong(PROPERTY_ON_SERVER_EXPIRATION_TIME, -1);
if (System.currentTimeMillis() > expirationTime) {
Log.v(TAG, "flag expired on: " + new Timestamp(expirationTime));
return false;
}
}
return isRegistered;
}
@Override
public String getRegistrationId() {
final SharedPreferences prefs = getGCMPreferences(mContext);
String registrationId = prefs.getString(PROPERTY_REG_ID, "");
if (registrationId == null || registrationId.length() == 0) {
Log.i(TAG, "Registration not found.");
return "";
}
// Check if app was updated; if so, it must clear the registration ID
// since the existing regID is not guaranteed to work with the new
// app version.
int registeredVersion = prefs.getInt(PROPERTY_APP_VERSION, Integer.MIN_VALUE);
int currentVersion = SystemUtils.getVersionCode();
if (registeredVersion != currentVersion) {
Log.i(TAG, "App version changed.");
return "";
}
return registrationId;
}
@Override
public long getRegisterOnServerLifespan() {
final SharedPreferences prefs = getGCMPreferences(mContext);
long lifespan = prefs.getLong(PROPERTY_ON_SERVER_LIFESPAN,
DEFAULT_ON_SERVER_LIFESPAN_MS);
return lifespan;
}
@Override
public void setRegisterOnServerLifespan(long lifespan) {
final SharedPreferences prefs = getGCMPreferences(mContext);
SharedPreferences.Editor editor = prefs.edit();
editor.putLong(PROPERTY_ON_SERVER_LIFESPAN, lifespan);
editor.commit();
}
/**
* Resets the backoff counter.
* <p>
* This method should be called after a GCM call succeeds.
*
*/
void resetBackoff() {
Log.d(TAG, "resetting backoff for " + mContext.getPackageName());
setBackoff(DEFAULT_BACKOFF_MS);
}
/**
* Gets the current backoff counter.
*
* @return current backoff counter, in milliseconds.
*/
int getBackoff() {
final SharedPreferences prefs = getGCMPreferences(mContext);
return prefs.getInt(BACKOFF_MS, DEFAULT_BACKOFF_MS);
}
/**
* Sets the backoff counter.
* <p>
* This method should be called after a GCM call fails, passing an
* exponential value.
*
* @param backoff new backoff counter, in milliseconds.
*/
void setBackoff(int backoff) {
final SharedPreferences prefs = getGCMPreferences(mContext);
SharedPreferences.Editor editor = prefs.edit();
editor.putInt(BACKOFF_MS, backoff);
editor.commit();
}
private void storeRegistrationId(String regId) {
final SharedPreferences prefs = getGCMPreferences(mContext);
int appVersion = SystemUtils.getVersionCode();
prefs.edit()
.putString(PROPERTY_REG_ID, regId)
.putInt(PROPERTY_APP_VERSION, appVersion)
.commit();
}
private void retryOnError() {
int backoffTimeMs = getBackoff();
int nextAttempt = backoffTimeMs / 2 + sRandom.nextInt(backoffTimeMs);
Log.d(TAG, "Scheduling registration retry, backoff = "
+ nextAttempt + " (" + backoffTimeMs + ")");
PendingIntent retryPendingIntent = GcmIntentService.getRetryIntent(mContext);
AlarmManager am = (AlarmManager) mContext
.getSystemService(Context.ALARM_SERVICE);
am.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime()
+ nextAttempt, retryPendingIntent);
// Next retry should wait longer.
if (backoffTimeMs < MAX_BACKOFF_MS) {
setBackoff(backoffTimeMs * 2);
}
}
private static SharedPreferences getGCMPreferences(Context context) {
// This sample app persists the registration ID in shared preferences, but
// how you store the regID in your app is up to you.
return context.getSharedPreferences(context.getPackageName() + ".gcm",
Context.MODE_PRIVATE);
}
}