본문 바로가기

Android

[안드로이드]GCM이란? node.js로 푸시알람 구현하기

GCM(Google Cloud Message)이란?


Google Cloud Messaging(GCM)

  • 개발자에게 서버에서 안드로이드 디바이스의 application으로 데이터를 전송하는 것을 도와주는 무료 서비스이다.
  • 3rd-party 에서 새로운 데이터가 있을 경우 gcm 서비스를 통하여 message를 특정 device의 특정 application으로 전송해준다. Message는 최대 4kb의 경량 메시지이다.
  • GCM 서비스는 message를 큐잉하며 이를 단말에 전달해주는 처리를 담당한다.
  • GCM connection Servers는 http / xmpp 지원.

GCM Architectural

  • app은 message를 받기위해 GCM으로부터 device를 register함. 이 register를 3rd-party에 등록.
  • 3rd-party server는 app에 message를 보내기 위해 등록된 rgister와 함께 GCM으로 message전송.
  • GCM connection server는 register로 확인된 단말에 해당 message를 전송.
  • 1. 단말에서 GCM으로 register 요청
  • 2. GCM에서 register 성공 후 registration id 전송
  • 3. third-party server로 registration id 전송
  • a. Registration id 값과 message를 GCM으로 전송
  • b. 해당 Registration에 맞는 단말에 message 전송

GCM Secuquence

GCM 용어 정리

Sender ID
  • Google API Console 로부터 획득한 project number 값.
  • Sender ID 값은 GCM에 해당 단말을 register 할때 사용됨. 이는 GCM으로부터 허가되는 application 임을 입증하기 위해 사용됨.
Application ID
  • Message 수신이 설정되어 있는 android application. 이는 manifest 파일의 package name으로 식별됨.
  • 이 값으로 Android 시스템에서 해당 application으로 message를 전송하도록 함.
Registration ID
  • Message를 수신하도록 허용된 android application에게 GCM 서버에 의해 발급된 ID 값.
  • 이 Registration ID값으로 특정 단말의 특정 application과 연결될 수 있는 식별자임.
Google User Account
  • 4.0.4 미만 버전에서 GCM이 정상적으로 구동되기 위해서 한개 이상의 Google 계정이 해당 단말에 설정되어 있어야함.
Sender Auth Token
  • Message를 보내는 3rd party server에 GCM에 접근이 허용되도록 주어진 API KEY.
  • 이 값은 서버에서 GCM으로 전송시 POST 요청의 header에 포함되어 있어야 함.

출처 - http://wiki.gurubee.net/display/SWDEV/GCM+(Google+Cloud+Messaging)


사용 방법


GCM 사용을 위한 프로젝트 생성 후 Service Key 받기


1. 앱에 구글 서비스를 적용할 수 있도록 설정파일을 받아야한다.

https://developers.google.com/mobile/add  ->  Pick a flatfoam -> Android App


* 안드로이드 스튜디오에서 프로젝트를 생성할 때 패키지 구성이 같아야 한다.




Cloud Messaging 클릭 후 Enable버튼


이 후 화면에서

google-services.json 파일을 다운로드 받고 Server API Key를 별도로 저장해둔다.

- google-services.json 파일은 안드로이드 프로젝트 내의 app폴더 내에 저장하여 설정파일로 쓰일것이다.

- Server API Key는 node.js서버에서 클라이언트에 메시지를 보내는데 사용된다.


2. 프로젝트에 google cloud messaging이 사용설정 되어 있는지 확인한다.

https://console.developers.google.com/apis/api/googlecloudmessaging/





안드로이드 프로젝트에서 테스트 해보기


3. 안드로이드 프로젝트의 app폴더 밑에 설정파일을 복사한다.

앱이 빌드될때 내부적으로 google-services.json파일을 사용하여 registration_id를 저장하는 것 같다. 그래서 빌드 후 json파일을 삭제해도 gcm이 잘 동작하지만 release한 후에 다운받아서 쓰면 동작하지 않는다.



4.build.gradle - module

apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services'

android {
compileSdkVersion 23
buildToolsVersion "23.0.3"

defaultConfig {
applicationId "com.test.gcm"
minSdkVersion 15
targetSdkVersion 23
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
testCompile 'junit:junit:4.12'
compile 'com.android.support:appcompat-v7:23.4.0'
compile "com.google.android.gms:play-services-gcm:8.3.0"

}

다음 두줄을 추가해준다.

apply plugin: 'com.google.gms.google-services'

compile "com.google.android.gms:play-services-gcm:8.3.0"


build.gradle - project

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:2.1.2'
classpath 'com.google.gms:google-services:2.0.0-alpha6'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
jcenter()
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

다음 한줄을 추가한다.

classpath 'com.google.gms:google-services:2.0.0-alpha6'



5. AndroidManifest.XML 설정

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.test.gcm">

<!-- [START gcm_permission] -->
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- [END gcm_permission] -->

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- [START gcm_receiver] -->
<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="net.saltfactory.demo.gcm" />
</intent-filter>
</receiver>
<!-- [END gcm_receiver] -->

<!-- [START gcm_listener_service] -->
<service
android:name="com.test.gcm.MyGcmListenerService"
android:exported="false">
<intent-filter>
<action android:name="com.google.android.c2dm.intent.RECEIVE" />
</intent-filter>
</service>
<!-- [END gcm_listener_service] -->

<!-- [START instanceId_listener_service] -->
<service
android:name="com.test.gcm.MyInstanceIDListenerService"
android:exported="false">
<intent-filter>
<action android:name="com.google.android.gms.iid.InstanceID" />
</intent-filter>
</service>
<!-- [END instanceId_listener_service] -->

<!-- [START gcm_registration_service] -->
<service
android:name="com.test.gcm.RegistrationIntentService"
android:exported="false"></service>
<!-- [END gcm_registration_service] -->
</application>

</manifest>

menifest를 설정하여 백그라운드로 도는 서비스들을 실행시켜준다.



MainActivity.java

package com.test.gcm;

import android.content.Intent;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;

public class MainActivity extends AppCompatActivity {
private static final int PLAY_SERVICES_RESOLUTION_REQUEST = 9000;
private static final String TAG = "MainActivity";

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

if (checkPlayServices()) {
// Start IntentService to register this application with GCM.
Intent intent = new Intent(this, RegistrationIntentService.class);
startService(intent); //서비스 실행
}
}

private boolean checkPlayServices() {
GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
int resultCode = apiAvailability.isGooglePlayServicesAvailable(this);
if (resultCode != ConnectionResult.SUCCESS) {
if (apiAvailability.isUserResolvableError(resultCode)) {
apiAvailability.getErrorDialog(this, resultCode, PLAY_SERVICES_RESOLUTION_REQUEST)
.show();
} else {
Log.i(TAG, "This device is not supported.");
finish();
}
return false;
}
return true;
}
}

메인에서 RegistrationIntentService를 실행시킨다. 



RegistrationIntentService.java

package com.test.gcm;

import android.app.IntentService;
import android.content.Intent;
import android.util.Log;

import com.google.android.gms.gcm.GoogleCloudMessaging;
import com.google.android.gms.iid.InstanceID;

/**
* Created by jingyu on 16. 6. 25..
*/
public class RegistrationIntentService extends IntentService {
private static final String TAG = "MyInstanceIDService";

public RegistrationIntentService() {
super(TAG);
Log.d(TAG, "RegistrationIntentService()");
}

@Override
protected void onHandleIntent(Intent intent) {
try {
// [START register_for_gcm]
// Initially this call goes out to the network to retrieve the token, subsequent calls
// are local.
// R.string.gcm_defaultSenderId (the Sender ID) is typically derived from google-services.json.
// See https://developers.google.com/cloud-messaging/android/start for details on this file.
// [START get_token]
//app폴더 밑에 저장한 google-services.json 파일에 있는 클라이언트 키값으로 토큰을 만든다.
InstanceID instanceID = InstanceID.getInstance(this);
String token = instanceID.getToken(getString(R.string.gcm_defaultSenderId),
GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);
// [END get_token]

//TODO server로 token 전송
Log.i(TAG, "token: " + token);
}
catch (Exception e) {
Log.d(TAG, "Failed to complete token refresh", e);
}
}
}

각각의 디바이스는 InstanceID API 를 사용하여  토큰을 생성한다. 이 토큰은 gcm이 설정된 각각의 디바이스를 구분할 수 있게 해준다.

토큰을 생성한 뒤 TODO에 중간서버(나는 node.js로 만듦)로 토큰을 보낸다. node.js서버는 게시글이라던지 채팅방에 이 토큰을 함께 저장한 후 새 글이 생길경우 google 서버(gcm서버)로 토큰과 메시지를 publish해주면 gcm은 해당하는 안드로이드 폰으로 알람이 동작한다.



MyGcmListenerService.java

package com.test.gcm;

import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.NotificationCompat;
import android.util.Log;

import com.google.android.gms.gcm.GcmListenerService;

/**
* Created by jingyu on 16. 6. 25..
*/
public class MyGcmListenerService extends GcmListenerService {
private static final String TAG = "MyGcmListenerService";

@Override
public void onMessageReceived(String from, Bundle data) {
String message = data.getString("message");
Log.d(TAG, "From: " + from);
Log.d(TAG, "Message: " + message);

//TODO message 처리
if (from.startsWith("/topics/")) {
// message received from some topic.
} else {
// normal downstream message.
}

sendNotification(message);
}

private void sendNotification(String message) {
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent,
PendingIntent.FLAG_ONE_SHOT);

Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_stat_ic_notification)
.setContentTitle("GCM Message")
.setContentText(message)
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent);

NotificationManager notificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);

notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
}
}

GcmListenerService를 상속하면 내부적으로 쓰레드가 돌아가서 서버에서 안드로이드로 푸쉬를 보내면 onMessageReceived메서드가 호출된다.

topic 메시지 방식으로 온 경우 매개변수 from은 어떤 토픽인지 알 수 있고, 토픽에 따라서 message를 처리하면 된다.

그 외의 경우 (to 에 토큰을 입력한 경우)는 else문으로 처리한다.

sendNotification에서 상단 바에 알람을 표시한다.



MyInstanceIDListenerService.java

package com.test.gcm;

import android.content.Intent;

import com.google.android.gms.iid.InstanceIDListenerService;

/**
* Created by jingyu on 16. 6. 25..
*/
public class MyInstanceIDListenerService extends InstanceIDListenerService {
private static final String TAG = "MyInstanceIDLS";

@Override
public void onTokenRefresh() {
// Fetch updated Instance ID token and notify our app's server of any changes (if applicable).
Intent intent = new Intent(this, RegistrationIntentService.class);
startService(intent);
}
}



포스트맨으로 테스트 해보기


크롬 확장프로그램인 포스트맨을 설치하고 테스트 해보자.

헤더에 콘텐트타입과 서버용 GCM API키를 입력한다.



body에서 to는 받을 애플리케이션을 입력한다. 

global로 하면 gcm에 등록된 모든 안드로이드에게 푸쉬알람을 보내게 되고 다음과 같이 RegistrationIntentService에서 서버로 보낸 토큰을 입력하면 해당 디바이스로 푸쉬알람을 보낸다.

"to":"fgCoc1EJRIc:APA91bEkhMcMxN5nQU7EQ0SoigFxIqLzePgfR_t-qampODk_rs6GOuEKZYK_4qI2gkknqH6L7gLlbNqKpy0ushXu13nVs11wV0y3xN_6TKeIQsbPEhUYemOHTmOx0nireVRDbgBvVQvT"

메시지 전송은 3가지 방법이 있는데

 topic은 주로 뉴스 같이 특정 범주에 업데이트가 있을때, 여러 사용자들에게 알림을 보낼때 쓰고, token은 1:1채팅같은 경우에 사용된다.

그리고 deviceGroup 메시지 방법은 서버가 여러 사용자를 묶어서 그룹을 만들고 그룹에게 푸시를 보내는 방식으로 사용한다.


data에서는 알람과 함께 보낼 데이터를 작성하면 된다.

주의할 점이 안드로이드에서는 반드시 "data"안에 작성을 해야한다. ( 아이폰은 "notification" )


그러면 다음과 같이 알람이 온다!






node.js로 테스트 해보기


node.js 프로젝트를 생성하고 app.js에 다음 소스를 추가하고 실행하면 똑같이 푸시알람이 오는것을 확인할 수 있다.




//GCM

var gcm = require('node-gcm');

var fs = require('fs');


var message = new gcm.Message({

    collapseKey:'test',

    delayWhileidIdle:true,

    timeToLive:3,

    data: {

            title:"GCM을 통해서 푸시메시지 보내기",

            message:"HELLO GCM !"

    } 

});


 

// Google Developer에서 등록한 Server API Key

var server_api_key = "Server API Key";

var sender = new gcm.Sender(server_api_key);

var registrationIds = [];

 

// Device Token

var token = "d1yPgOWmDRg:APA91bHbqgj6nNgTyp9_OfMkfgzO_Y9B1uKUstEH4pM5gUyJ2RGL-1eYEvvdL9oN5Q5Tzc9a3spuJRx5BkpKOMkc7ktJjlDq4f3qWhVKT1i2IeJH7ljSEIrfeTaR9Rhok4AQapqMPpLG";

registrationIds.push(token);

 

sender.send(message, registrationIds, 4, function (err, result) {

    console.log(result);

});


//end of GCM


여기서 registrationIds는 array로 알람을 받을 사용자를 추가하여 보낼 수 있다.

collapse_key는 단말이 꺼져있을때, 알림 메시지가 쌓이면 쌓여있는 중복된 여러 메시지를 다 보내지 않고 하나만 전달한다.

delay_while_idle은 true로 설정할때, 단말이 sleep이면 보내지 않고 단말을 사용하면 메시지를 보낸다.

time_to_live : 단말이 꺼져있으면 GCM서버에서 얼마동안 보관할지 여부이다. default로 4주로 설정됨.

data : 전달할 데이터. key/value형식으로 되어 있고 크기는 4k로 제한됨.





밑에는 찾아보다가 좋은 글이 있어서 퍼왔음...


node.js와 데이터베이스와 연동하여 각 게시글에 토큰들을 저장하면 해당 게시글에 댓글이 달릴때, 

그 게시글에 댓글을 달았던 모든 사용자에게 푸시 알림을 보낼 수 있다.


Step 3. app.js 수정

3가지 기능을 만들어줄껍니다.
a. registration
b. unregistraion
c. send message

app.js에 아래의 내용을 삽입해 줍니다.

var main = require('./main');
app.post('/register', main.regist);
app.post('/unregister', main.unregist);
app.post('/send', main.send_push);


간단히 설명하자면
app.post('/register', main.regist);는
post형태로 /register경로의 접근이면 main의 regist를 수행하게 됩니다.




Step 4. main.js 작성


app.js 편집을 종료하고 main.js라는 파일을 만들어줍니다.

app.js에서 넣어줫던 이름과 일치하게 3가지 함수를 만들어 줍니다.


exports.regist = function (req, res) {}


exports.unregist = function (req, res){}


exports.send_push = function(req, res) {}


regist에서는 db에 저장하는 기능을 작성하면되고

unregist에서는 db에서 제거하는 기능을 작성합니다.

마지막으로 send_push에서는

 db에 저장된 regid를 읽고

해당 regid로 메지시를 보내도록 요청합니다.



* source code

require('date-utils');
var sys=require('sys');
var mysql = require('mysql');
var dbName = 'PushServer';
var client = mysql.createConnection({
        hostname: 'localhost',
        user: 'username',
        password: 'password'
});

client.connect(function(error, result){
        if(error){
                return;
        }
        logger('connected to mySql');
        connectionDB();
});

function connectionDB(){
        logger('Trying to connect to DB');
        client.query('USE '+dbName,function(error){
                if(error){
                        var string="";
                        string+=error;
                        var has = string.indexOf('ER_BAD_DB_ERROR: Unknown database \''+dbName+'\'');
                        if(has>0){
                                logger('Creating Database...');
                                client.query('CREATE DATABASE '+dbName);
                                connectionDB();
                        }
                }
                else{
                        logger('Creating Table...');
                        client.query('create table regId( id INT NOT NULL AUTO_INCREMENT, senderId TEXT NOT NULL, regId TEXT NOT NULL, PRIMARY KEY (id));',function(error){
                                if(error)
                                        return
                        });
                        logger('DB is Ready for use.');
                }
        });
}

exports.regist = function (req, res) {
        logger('Registering...');
        var body='';
        req.on('data', function(chunk){
                body+=chunk;
        });
        req.on('end', function(){
                var json = JSON.parse(body);
                var regId = json.regId;
                var senderId = json.senderId;

                client.query('insert into regId(regId,senderId) values("'+regId+'", "'+senderId+'");',function(err,rows){
                        if(err){
                                var e=err;
                                logger('Insert Error : \n',e);
                        }
                        else{
                                logger('Insert Complete');
                        }
                });
        });

        res.end();
}

exports.unregist = function (req, res){
        logger('Unregistering...');

        var body='';
        req.on('data', function(chunk){
                body+=chunk;
        });
        req.on('end', function(){
                var json = JSON.parse(body);
                var regId = json.regId;
                var senderId = json.senderId;
                client.query('delete from regId where regId="'+regId+'" AND senderID="'+senderId+'";',function(err, rows){
                        if(err){
                                var e=err;
                                logger('Delete Error : \n',e);
                        }
                        else{
                                logger('Delete Complete');
                        }
                });

        });
        res.end();

}


exports.send_push = function(req, res) {
        logger('Preparing for Send Message...');

        var body='';

        req.on('data', function(chunk){
            body+=chunk;
    });

    req.on('end',function(){
            var json = JSON.parse(body);
            var senderId = json.senderId;
            var gcm=require('node-gcm');
            var key1='keyqwe';

            var message=new gcm.Message();
            message.addData('title','TestMesage');
            message.addData('key3','message2');
            var server_access_key='1번 글에서 확득한 api키';
            var sender=new gcm.Sender(server_access_key);

            var registrationIds=[];
            var rows='';
            client.query('select regId from regId where senderId="' +senderId+ '";',function(err, rows){
                    if(err){
                            var e=err;
                            logger('Could not load regId List : \n',e);
                    }else{
                            for(var i in rows){
                                    var rid=rows[i].regId;
                                    rid = rid.replace(/(\s*)/g,"");
                                    registrationIds.push(rid);
                            }
                            logger('Sendding Messages...');
                            sender.send(message, registrationIds, 4, function (err, result) {
                                    if(err){
                                            var e=err;
                                            logger('Could not Send : \n',e);
                                    } else{
                                            var r=result;
                                            logger('Send result : \n',r);
                                    }
                            });
                            logger('Complete Sending!');
                    }
            });
    });
    res.end();
}
function logger(msg,log){
    var d=new Date();
    d=d.toFormat('MM/D HH24:MI:SS');
    if(typeof log == 'undefined'){
            console.log('['+d+'] ' + msg);
    }
    else{
        console.log('['+d+'] ' + msg, log);
    }
}


출처: http://wowan.tistory.com/83