In this post we will continue developing the GCM Adroid Ruby application form the [previous minimal working example]. We want to improve it with:
- When a device installs our client app, it will send its registration_id to our server. When we create a new notification we will send it to all the registered devices.
- We will send text messages (message and title) that will be displayed as notifications in the device. The server will keep track of this notifications.
You can download from github the Android app and the RoR app from [GitHub]
Server Models
Lets see what models we will need to store in the server
Device
We will create a Device model that will contain information about a device. It will have the following attributes:
- id (UUID): Unique non modificable identifier of a device. We will use UUIDs.
- registration_id (String): GCM registration id
Notes
A single device may update its registration_id. In order to store this changes we have added an unmodificable id (using UUIDs). We will use UUIDs so that an offline device can generate instances with id (maybe I will talk more about this in another post).
Notifications
We will store the notifications sent. It will have an String attribute with the title and another one for the message.
The RoR app
Lets create our rails app. In order to work with UUIDs we will use Postgresql database.
$ rails new gcm_sample2_server -d postgresql
$ cd gcm_sample2_server
Enable UUids
In order to use UUIDs we need to enable the uuid extension. To do so we need to create a migration
$ rails g migration EnableUuidOsspExtension
{% endhighlight%}
with the following contents
```ruby
class EnableUuidOsspExtension < ActiveRecord::Migration
def change
enable_extension 'uuid-ossp'
end
end
We will execute this migration later.
Server Devices
We will use scaffold generator for model/cotroller/view generation. This may generate more features than the needed ones, but we can use them for testing porposes. Clean will be required if you are going to use this in a real project.
$ rails g scaffold Device registration_id:string
We will need to change the CreateDevices migration so that the id is a UUID. We will also set the registration_id as a database index for faster queries.
class CreateDevices < ActiveRecord::Migration
def change
create_table :devices, id: :uuid do |t|
t.string :registration_id
t.timestamps null: false
end
add_index :devices, :registration_id
end
end
Moreover, we will need to change the default controller (devices_controller.rb) so that it allows that on the create operation, to recieve a device id. By default this is not allowed. We will need to change the create function:
# POST /devices
# POST /devices.json
def create
@device = Device.new(device_params)
#we allow the id to be sent as a parameter when model created
if device_params[:id]
@device.id = device_params[:id]
end
...
end
And the device_params function to allow the id as a parameter
def device_params
params.require(:device).permit(:registration_id, :id)
end
Nothing more to change on Devices.
Server Notifications
Lets create Notifications; scaffold generation will be enough.
$ rails g scaffold Notification title:string message:string
GCM Messages
Finnaly, we need to create a GCM message each time a notification is created. We first need to configure RPush Gem.
We add the RPush Gem to our gemfile.
gem 'rpush'
And initialize it (rpush)
$ bundle install
$ rpush init
We will configure rpush for canonical-IDs, as descrived in the [Canonical-IDs rpus wiki]. To do so we will edit rpush.rb (in the initializers folder) and add (or uncomment) the following inside Rpush.reflect
Rpush.reflect do |on|
(...)
# Called when the GCM returns a canonical registration ID.
# You will need to replace old_id with canonical_id in your records.
on.gcm_canonical_id do |old_id, canonical_id|
d = Device.find_by_registration_id(old_id)
d.update_attributes(registration_id: canonical_id)
end
(...)
end
Now lets edit our notification controller so that when a notification is created, we send it to the existing devices. We will add the following private method to notifications_controller.rb that sends the GCM notifications to all the registered devices.
def send_gcm_notification notification
if !Rpush::Gcm::App.find_by_name("android-gcm-sample2")
app = Rpush::Gcm::App.new
app.name = "android-gcm-sample2"
app.auth_key = "UPDATE_WITH_YOUR_SERVER_KEY"
app.connections = 1
app.save!
end
n = Rpush::Gcm::Notification.new
n.app = Rpush::Gcm::App.find_by_name("android-gcm-sample2")
n.registration_ids = Device.all.map{|device| device.registration_id}
message = notification.message
n.data = notification.as_json
n.save!
end
And update the create method to create the notifications
def create
@notification = Notification.new(notification_params)
respond_to do |format|
if @notification.save
send_gcm_notification @notification
format.html { redirect_to @notification, notice: 'Notification was successfully created.' }
format.json { render :show, status: :created, location: @notification }
else
format.html { render :new }
format.json { render json: @notification.errors, status: :unprocessable_entity }
end
end
end
Last steps
We need to do some last steps to finish with our server app.
protectfromforgery
From our mobile application we will call the json api to create devices, therefore we will need to change the default protectfromforgery. You can read more about this in the [rails protectfromforgery definition]. To do so, we edit the application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :null_session
end
Start the server
Finally we need to build the database and start application. Note that db:create may fail if database.yml database credentials are not correct. Notice also that, in the rails s command we have added the "--b YOULOCALIP" (change by you local ip, e.g: 192.168.0.20). The reason for this is that our mobile device will need to connect to the rails server, therefore, we need to listen to our external ip.
$ rake db:create
$ rake db:migrate
$ rails s --b YOU_LOCAL_IP
Separatelly we will need to start also rpush, otherwise GCM messages will not be sent,
rpush start
Our server is finished and running.
Android client
Lets create the Android client. To do so, we will modify the [previous minimal working example].
Device storage and synchronization
For the data storage and synchronization we will use [Android Pillow]. [Android Pillow] is a library that will help us to store data locally and synchronize it to the server when possible. It's still in early development stages but fits perfect for our case. If you prefer using any other libary you will only need to update the Device controller.
Device Model
Lets create the Device model. Apart from the registrationId we will store also the appVersion (as we did in the [first post]).
/**
* Stores information about a device GCM registration
*/
public class Device extends AbstractIdentificableModel {
String registrationId;
int appVersion;
public Device(){}
public Device(String registrationId, int appVersion) {
this.registrationId = registrationId;
this.appVersion = appVersion;
}
public String getRegistrationId() {
return registrationId;
}
public void setRegistrationId(String registrationId) {
this.registrationId = registrationId;
}
public int getAppVersion() {
return appVersion;
}
public void setAppVersion(int appVersion) {
this.appVersion = appVersion;
}
}
Device Controller
The DeviceController will be in charge of the storage our device information locally and synchonize it to our server. A SynchSingleInstanceDataSource from [Android Pillow] will fit our needs.
/**
* Device storage operations
*/
public class DeviceDataSource extends SynchSingleInstanceDataSource<Device> {
public DeviceDataSource(ISynchLocalSingleInstanceDataSource<Device> dbSource, IRestMapping<Device> restMap, Context context) {
super(Device.class, dbSource, restMap, context);
}
}
Configurin Android Pillow
Lets configure Android Pillow to use this Controller. We will first add android_pillow.xml in res/xml. Update the url with you ip (server url).
<?xml version="1.0" encoding="utf-8"?>
<androidpillow xmlns:android="http://schemas.android.com/apk/res/android">
<modelConfigurations class="com.trito.blog.gcmsample2client.pillow.PillowConfiguration"/>
<url value="http://192.168.2.5:3000"/>
</androidpillow>
And create the PillowConfiguration
public class PillowConfiguration implements IModelConfigurations {
@Override
public List<ModelConfiguration<?>> getModelConfigurators(Context context, PillowConfigXml config) {
String url = config.getUrl();
List<ModelConfiguration<?>> configurations = new ArrayList<>();
SharedPreferences preferences = context.getSharedPreferences("PILLOW_PREFERENCE_MODELS", Context.MODE_PRIVATE);
//Device model configuration
DefaultModelConfiguration<Device> deviceConfiguration = new DefaultModelConfiguration<Device>(context, Device.class, new TypeToken<Collection<Device>>(){}, url);
SingleModelKeyValueDataSource<Device> deviceLocalDataSource = new SingleModelKeyValueDataSource<Device>(Device.class, preferences);
deviceConfiguration.setDataSource(new DeviceDataSource(deviceLocalDataSource, deviceConfiguration.getRestMapping(), context));
configurations.add(deviceConfiguration);
return configurations;
}
}
And initialize Pillow in the Application. Remember to set the new GcmSample2ClientApplication to the manifest.xml
public class GcmSample2ClientApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
Pillow.setConfigurationFile(R.xml.android_pillow);
}
}
GCM Registration
In the last example all our codes where in the MainActivity. Now we will separate the GCM functionallity to a class. We have modified the previous behaviour to use the DeviceDataSource to store the registration id.
/**
* Helper class for Google cloud Message Registration.
*/
public class GCMRegistration {
private final static int PLAY_SERVICES_RESOLUTION_REQUEST = 9000;
static final String TAG = "GCMRegistration";
String senderId;
Activity context;
GoogleCloudMessaging gcm;
DeviceDataSource deviceDataSource;
String regid;
public GCMRegistration(Activity context, String senderId) {
this.context = context;
this.senderId = senderId;
deviceDataSource = (DeviceDataSource) Pillow.getInstance(context).getDataSource(Device.class);
}
/**
* Checks if Google Play Services are found. If so, it checks if the device has been registered
* with GCM if not, it registers and stored local & remote the information.
*/
public void register(){
if (checkPlayServices(context)) {
gcm = GoogleCloudMessaging.getInstance(context);
regid = getRegistrationId();
if (regid.isEmpty()) {
registerInBackground();
} else {
Log.i(TAG, "already registered:"+regid);
}
} else {
Log.i(TAG, "No valid Google Play Services APK found.");
}
}
/**
* Registers the application with GCM servers asynchronously.
* Stores the registration ID and app versionCode
*/
private void registerInBackground() {
new AsyncTask<Void, String, String>() {
@Override
protected String doInBackground(Void... params) {
String msg = "";
try {
if (gcm == null) {
gcm = GoogleCloudMessaging.getInstance(context);
}
regid = gcm.register(senderId);
msg = "Device registered, registration ID=" + regid;
storeRegistrationId(regid);
} catch (IOException ex) {
ex.printStackTrace();
msg = "Error :" + ex.getMessage();
// If there is an error, don't just keep trying to register.
// Require the user to click a button again, or perform
// exponential back-off.
}
return msg;
}
@Override
protected void onPostExecute(String msg) {
Log.i(TAG, msg);
}
}.execute(null, null, null);
}
/**
* Stores the registration ID and app versionCode
*
* @param regId registration ID
*/
private void storeRegistrationId(String regId) {
int appVersion = getAppVersion(context);
Device device = new Device(regId, appVersion);
try {
deviceDataSource.set(device).get();
} catch (PillowError pillowError) {
throw new BreakFastException(pillowError);
}
}
/**
* Gets the current registration ID for application on GCM service.
* If result is empty, the app needs to register.
*
* @return registration ID, or empty string if there is no existing
* registration ID.
*/
private String getRegistrationId() {
Device device;
try {
device = deviceDataSource.get().get();
} catch (PillowError pillowError) {
throw new BreakFastException(pillowError);
}
if (device==null) {
Log.i(TAG, "Registration not found.");
return "";
}
int registeredVersion = device.getAppVersion();
int currentVersion = getAppVersion(context);
if (registeredVersion != currentVersion) {
Log.i(TAG, "App version changed.");
return "";
}
return device.getRegistrationId();
}
/**
* Check the device to make sure it has the Google Play Services APK. If
* it doesn't, display a dialog that allows users to download the APK from
* the Google Play Store or enable it in the device's system settings.
* If the error is unRecoverable it finishes the activity
*/
public boolean checkPlayServices(Activity activity) {
int resultCode = GooglePlayServicesUtil.isGooglePlayServicesAvailable(activity);
if (resultCode != ConnectionResult.SUCCESS) {
if (GooglePlayServicesUtil.isUserRecoverableError(resultCode)) {
GooglePlayServicesUtil.getErrorDialog(resultCode, activity, PLAY_SERVICES_RESOLUTION_REQUEST).show();
} else {
Log.i(TAG, "This device is not supported.");
activity.finish();
}
return false;
}
return true;
}
/**
* @return Application's version code from the {@code PackageManager}.
*/
private static int getAppVersion(Context context) {
try {
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
return packageInfo.versionCode;
} catch (PackageManager.NameNotFoundException e) {
// should never happen
throw new RuntimeException("Could not get package name: " + e);
}
}
}
Now we have a much clean MainActivity
public class MainActivity extends AppCompatActivity {
/**
* Substitute you own sender ID here. This is the project number you got
* from the API Console, as described in "Getting Started."
*/
String SENDER_ID = UPDATE_WITH.SENDER_ID;
GCMRegistration gcmRegistration;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
gcmRegistration = new GCMRegistration(this, SENDER_ID);
gcmRegistration.register();
}
@Override
protected void onResume() {
super.onResume();
// You need to do the Play Services APK check here too.
gcmRegistration.checkPlayServices(this);
}
}
Final changes
We will update the GcmIntentService so that we obtain the title and message from the GCM message, and create a nofitication with this data.
@Override
protected void onHandleIntent(Intent intent) {
Bundle extras = intent.getExtras();
GoogleCloudMessaging gcm = GoogleCloudMessaging.getInstance(this);
// The getMessageType() intent parameter must be the intent you received
// in your BroadcastReceiver.
String messageType = gcm.getMessageType(intent);
if (!extras.isEmpty()) { // has effect of unparcelling Bundle
// If it's a regular GCM message, do some work.
if (GoogleCloudMessaging.MESSAGE_TYPE_MESSAGE.equals(messageType)) {
// Post notification of received message.
sendNotification(extras.getString("title"), extras.getString("message"));
Log.i(TAG, "Received: " + extras.toString());
}
}
// Release the wake lock provided by the WakefulBroadcastReceiver.
GcmBroadcastReceiver.completeWakefulIntent(intent);
}
/**
* Put the message into a notification and post it.
* This is just one simple example of what you might choose to do with a GCM message.
* @param msg
*/
private void sendNotification(String title, String msg) {
mNotificationManager = (NotificationManager)
this.getSystemService(Context.NOTIFICATION_SERVICE);
PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
new Intent(this, MainActivity.class), 0);
NotificationCompat.Builder mBuilder =
new NotificationCompat.Builder(this)
.setContentTitle(title)
.setSmallIcon(R.mipmap.ic_launcher)
.setStyle(new NotificationCompat.BigTextStyle().bigText(msg))
.setContentText(msg);
mBuilder.setContentIntent(contentIntent);
mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
}
Thats all.
Showcase
Open the Android application. If you check in http://yourip:3000/devices a new device should be added. If so, create a new notification at http://yourip:3000/notifications/new and see that the notification is displayed in your Android application.
Common dummy errors
If somthing is not working you may have done one of this dummy errors
The devices are not created on the server
When you start your rails server remember to make it listen your local ip, not localhost
$ rails s --b YOU_LOCAL_IP
The notifications are not beeing sent to my device
A part from starting your rails server, remember to start rpush.
rpush start
If this is not the problem, check on you database to see if the notifications are sent.
Work To do
We have still some work to do
- If our Android app, when registerInBackground fails, we should try to connect again (using an exponential back-off)
[previous minimal working example]:/blog/android/rubyonrails/gcm/2015/05/13/Google-CloudMessaging-with-Ruby-On-Rails.html [first post]:/blog/android/rubyonrails/gcm/2015/05/13/Google-CloudMessaging-with-Ruby-On-Rails.html [Canonical-IDs rpus wiki]:https://github.com/rpush/rpush/wiki/Canonical-IDs [rails protectfromforgery definition]:http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection/ClassMethods.html [Android Pillow]:http://androidpillow.com [GitHub]:https://github.com/trito/bloggcmandrorpart2