[DSC] 서비스와 수신자 이해하기

2020. 5. 28. 23:22Android

Week4

 

이때까지는 화면을 만들고 화면을 구성할 때 필요한 기본적인 내용들을 살펴봤었습니다.

하지만 앱은 화면을 구성하는 요소뿐만 아니라 다른 구성 요소도 많이 필요합니다.

그 중 대표적인 요소가 서비스(Service)수신자(Broadcast Receiver)입니다.

 

서비스(Service)

 service는 화면에서 실행되는 것이 아니라 화면 뒤(Background)에서 실행되는 앱의 구성요소입니다.

화면이 없으므로 액티비티와 동작하는 방식이 다르고 인텐트(intent) 안에 포함된 메시지를 주고 받을 때 사용합니다.

activity를 만들면 Manifest에 등록했던 것처럼 새로 만든 service도 Manifest에 추가해주어야 합니다.

 

우선 service를 실행하려면 MainActivity에서 startService() 함수를 호출하면 됩니다.

service를 시작시키기 위해 startService() 함수를 호출할 때는 intent 객체를 parameter로 전달합니다.

그리고 service가 실행 중일 때 또 startService() 함수를 호출하면 service는 이미 메모리에 만들어진 상태로 유지되지만 service 시작 목적 이외로 intent를 전달하는 목적으로도 자주 사용됩니다.

 

실습으로 넘어가보겠습니다.

 

우선 app 을 마우스 우클릭하여 New -> Service -> Service를 눌러줍니다.

 

그러면 위와 같은 대화상자가 등장하는데 기본값으로 두고 Finish 버튼을 누르겠습니다.

 

그러면 MyService.java 파일이 생성됨과 동시에

 

AndroidManifest.xml 파일에 <service> 태그가 자동으로 추가된 것을 확인할 수 있습니다.

 

현재 MyService.java 파일에는 기본적으로 onBind() 함수만 있습니다.

하지만 service의 수명주기를 관리하기 위하여 onCreate(), onDestroy(), 그리고 intent 객체를 전달받기 위한 onStartCommand() 함수를 추가하겠습니다.

 

MyService class 안에 마우스를 우클릭하여 Generate를 눌러줍니다.

 

그리고 Override Methods를 눌러줍니다. (Ctrl + O하면 바로 열려요!)

 

Ctrl을 누른 상태로 onCreate(), onDestroy(), onStartCommand()를 눌러주시면 한꺼번에 추가할 수 있습니다. OK 하겠습니다.

 

그러면 자동으로 함수가 추가된 것을 확인할 수 있습니다.

 

이번엔 activity_main.xml 파일로 가서 입력상자와 버튼을 하나씩 만들겠습니다.

 

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"
    android:orientation="vertical"
    android:gravity="center">

    <EditText
        android:id="@+id/editText"
        android:layout_width="200dp"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/btn_service"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="service로 보내기" />

</LinearLayout>

 

'service로 보내기'라는 버튼을 누르면 입력상자에 입력한 글자를 service에 전달하도록 만들 것입니다.

데이터를 service에 전달하려면 startService() 함수를 사용하며 intent 안에 부가 데이터를 추가해서 전달하면 됩니다.

 

MainActivity.java

package com.example.week4;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;

public class MainActivity extends AppCompatActivity {

    EditText editText;
    Button btn_service;

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

        editText = findViewById(R.id.editText);
        btn_service = findViewById(R.id.btn_service);
        btn_service.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                String name = editText.getText().toString();
                Intent intent = new Intent(getApplicationContext(), MyService.class);
                intent.putExtra("command", "show");
                intent.putExtra("name", name);
                startService(intent);
            }
        });
    }
}

 위와 같이 입력해주세요.

이 코드를 설명하자면

intent 안에 두 개의 부가 데이터를 넣었습니다.

command는 서비스 쪽으로 전달한 intent 객체의 데이터가 어떤 목적으로 사용되는지를 구별하기 위해 넣은 것입니다.

name은 입력상자에서 가져온 문자를 전달하기 위한 것입니다.

이 두 개의 부가 데이터를 담은 intent 객체를 startService() 함수에 담았습니다. 이는 MyService에서 onStartCommand() 함수로 전달될 것입니다.

 

MyService.java

package com.example.week4;

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;

import static android.content.ContentValues.TAG;

public class MyService extends Service {
    public MyService() {
    }

    @Override
    public IBinder onBind(Intent intent) {
        // TODO: Return the communication channel to the service.
        throw new UnsupportedOperationException("Not yet implemented");
    }

    @Override
    public void onCreate() {
        super.onCreate();
        Log.d(TAG, "onCreate() 호출됨");
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d(TAG, "onStartCommand() 호출됨");

        if (intent == null) {
            return Service.START_STICKY;
        } else {
            String command = intent.getStringExtra("command");
            String name = intent.getStringExtra("name");

            Log.d(TAG, "command : " + command + ", name : " + name);

            for (int i = 0; i < 5; i++) {
                try {
                    Thread.sleep(1000);
                } catch (Exception e) {};
                Log.d(TAG, "Waiting " + i + "seconds.");
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
    }
}

전달 받은 데이터를 출력하기 위해 Log.d() 함수를 이용했습니다.

실무에서는 토스트 메시지보다 로그를 더 많이 사용하니 알아두는 것이 좋습니다.

 

코드에서 intent가 null이면 Service.START_STICKY를 반환합니다. 이는 service가 비정상 종료되었다는 의미이므로 시스템이 자동으로 재시작합니다. 만약 자동으로 재시작하지 않도록 만들고 싶다면 다른 상수를 사용할 수 있습니다.

null이 아니라면 intent로부터 부가 데이터를 받아 출력하고 5초동안 1초에 한 번씩 로그를 출력하도록 했습니다.

 

service가 서버 역할을 하면서 activity와 연결될 수 있도록 만드는 것을 바인딩(Binding)이라고 합니다. 이를 위해서는 onBind() 함수를 재정의해야 하지만 여기서는 binding 기능을 사용하지 않으므로 그냥 놔두고 진행하겠습니다.

 

한 번 실행시켜보겠습니다.

 

'Rina' 라고 입력하고 'service로 보내기' 버튼을 누르면

 

안드로이드 스튜디오에서 다음과 같이 로그가 나타나게 됩니다.

 

이번엔 onStartCommand()에서 activity 쪽으로 intent를 전달하고 화면에 띄워보도록 하겠습니다.

 

MyService.java 중 onStartCommand 함수

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d(TAG, "onStartCommand() 호출됨");

        if (intent == null) {
            return Service.START_STICKY;
        } else {
            String command = intent.getStringExtra("command");
            String name = intent.getStringExtra("name");

            Log.d(TAG, "command : " + command + ", name : " + name);

            for (int i = 0; i < 5; i++) {
                try {
                    Thread.sleep(1000);
                } catch (Exception e) {};
                Log.d(TAG, "Waiting " + i + "seconds.");
            }
            Intent showIntent = new Intent(getApplicationContext(), MainActivity.class);
            showIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
                                Intent.FLAG_ACTIVITY_SINGLE_TOP |
                                Intent.FLAG_ACTIVITY_CLEAR_TOP);
            showIntent.putExtra("command", "show");
            showIntent.putExtra("name", name + " from service.");
            startActivity(showIntent);
        }
        return super.onStartCommand(intent, flags, startId);
    }

이번엔 intent에 flag를 추가했습니다.

FLAG_ACTIVITY_NEW_TASK는 새로운 태스크(Task)를 생성합니다. 서비스는 화면이 없기 때문에 화면이 없는 서비스에서 화면이 있는 액티비티를 띄우려면 새로운 태스크를 만들어야 하기 때문입니다.

FLAG_ACTIVITY_SINGLE_TOPFLAG_ACTIVITY_CLEAR_TOP은 MainActivity 객체가 이미 메모리에 만들어져 있을 때 재사용하도록 합니다.

 

전달 받은 인텐트를 MainActivity에서 처리해봅시다.

 

MainActivity.java

package com.example.week4;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity {

    EditText editText;
    Button btn_service;

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

        editText = findViewById(R.id.editText);
        btn_service = findViewById(R.id.btn_service);
        btn_service.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                String name = editText.getText().toString();
                Intent intent = new Intent(getApplicationContext(), MyService.class);
                intent.putExtra("command", "show");
                intent.putExtra("name", name);
                startService(intent);
            }
        });

        Intent passedIntent = getIntent();
        processIntent(passedIntent);
    }

    @Override
    protected void onNewIntent(Intent intent) {
        processIntent(intent);
        super.onNewIntent(intent);
    }

    private void processIntent(Intent intent) {
        if (intent != null) {
            String command = intent.getStringExtra("command");
            String name = intent.getStringExtra("name");
            Toast.makeText(this, "command : " + command + ", name : " + name, Toast.LENGTH_SHORT).show();
        }
    }
}

MainActivity가 메모리에 만들어져 있지 않은 상태에서 처음 만들어진다면 onCreate()에 있는 getIntent() 함수를 통해 intent 객체를 참조하고 MainActivity가 메모리에 이미 만들어진 상태라면 onNewIntent() 함수가 호출되어 getIntent()를 수행합니다.

 

실행시켜 보겠습니다.

 

버튼을 누르고 5초 기다리고 나면 토스트 메시지를 확인할 수 있습니다.

 

요즘 앱들은 서비스를 자주 사용하니 액티비티에서 서비스로, 혹은 서비스에서 액티비티로 데이터를 전달하는 방법을 잘 알아두어야 합니다.

 

 


 

수신자(Broadcast Receiver)

브로드캐스팅(Broadcasting)이란 메시지를 여러 객체에 전달하는 것을 말합니다.

예를 들어,  문자를 받았을 때 이 문자를 SMS 수신 앱에 알려줘야 한다면 브로드캐스팅으로 전달하면 됩니다.

메시지는 단말 전체에 적용될 수 있고 이를 '글로벌 이벤트(global event)'라고 합니다.

 

브로드캐스트 수신자에는 onReceiver() 함수를 정의해야 합니다. 이 함수는 원하는 브로드캐스트 메시지가 도착하면 자동으로 호출됩니다. 하지만 시스템의 모든 메시지를 받을 수는 없습니다.

원하는 메시지만 받기 위해서는 인텐트 필터를 사용하여 시스템에 등록하면 됩니다.

 

실습해보겠습니다.

 

app을 마우스 우클릭하여 New -> Other -> Broadcast Receiver를 선택해줍니다.

 

이름은 MyReceiver 그대로하고 Finish 누르겠습니다.

 

그러면 MyReceiver.java 파일이 생성되고 manifest에도 <receiver> 태그가 생성됩니다. 그리고 manifest에 코드를 추가하겠습니다.

 

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.week4">
    
    <uses-permission android:name="android.permission.RECEIVE_SMS"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <receiver
            android:name=".MyReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="android.provider.Telephony.SMS_RECEIVED"/>
            </intent-filter>
        </receiver>

        <service
            android:name=".MyService"
            android:enabled="true"
            android:exported="true" />

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

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

</manifest>

<receiver> 태그 안에 <intent-filter> 태그를 넣으면 어떤 인텐트를 받을 것인지 지정합니다.

<intent-filter> 태그 안에 <action> 태그를 추가하고 name 속성 값으로 android.provider.Telephony.SMS_RECEIVED를 넣었는데 이는 SMS 메시지가 들어간 인텐트를 구분하기 위한 액션 정보입니다.

 

또한 이 앱에서 SMS를 수신하려면 RECEIVE_SMS라는 권한이 있어야 합니다.

<application> 태그 위에 RECEIVE_SMS 권한을 추가해주세요.

그런데 이 권한은 위험 권한입니다. 위험 권한인 경우는 소스 파일에서 앱 실행 후에 사용자가 권한을 부여할 수 있도록 별도의 코드가 추가되어야 합니다. 지금은 외부 라이브러리를 사용하여 간단하게 위험 권한을 추가하는 코드를 넣겠습니다.

 

Gradle Script의 build.gradle (Module: app)을 수정하겠습니다.

 

 

build.gradle (Module: app)

apply plugin: 'com.android.application'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.3"

    defaultConfig {
        applicationId "com.example.week4"
        minSdkVersion 16
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

}

allprojects {
    repositories {
        maven {url 'https://jitpack.io'}
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

    implementation 'com.github.pedroSG94:AutoPermissions:1.0.3'
}

 이렇게 코드를 추가해주시고 상단에 Sync Now를 클릭해주세요.

 

 

이제 MyReceiver.java 파일의 코드를 작성해봅시다.

 

MyReceiver.java

package com.example.week4;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.telephony.SmsManager;
import android.telephony.SmsMessage;
import android.util.Log;

import java.util.Date;

import static android.content.ContentValues.TAG;

public class MyReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        // TODO: This method is called when the BroadcastReceiver is receiving
        // an Intent broadcast.
        Log.i(TAG, "onReceive() 함수 호출");
        Bundle bundle = intent.getExtras(); //intent에서 Bundle 객체 가져옴
        SmsMessage[] messages = parseSmsMessage(bundle);


        if (messages != null && messages.length > 0) {
            String sender = messages[0].getOriginatingAddress();
            Log.i(TAG, "SMS sender : " + sender);

            String contents = messages[0].getDisplayMessageBody();
            Log.i(TAG, "SMS contents : " + contents);

            Date receivedDate = new Date(messages[0].getTimestampMillis());
            Log.i(TAG, "SMS received date : " + receivedDate.toString());
        }
    }
    
    private SmsMessage[] parseSmsMessage(Bundle bundle) {
        Object[] objs = (Object[]) bundle.get("pdus");
        SmsMessage[] messages = new SmsMessage[objs.length];
        
        int smsCount = objs.length;
        for (int i = 0; i < smsCount; i++) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                String format = bundle.getString("format");
                messages[i] = SmsMessage.createFromPdu((byte[])objs[i], format);
            } else {
                messages[i] = SmsMessage.createFromPdu((byte[])objs[i]);
            }
        }
        return messages;
    }
}

 

우선 SMS를 받으면 onReceive() 함수가 자동으로 호출됩니다. 그리고 parameter로 전달되는 Intent 객체 안에 SMS 데이터가 들어 있습니다. intent 객체 안에 Bundle 객체가 들어있는데 이 안에도 부가 데이터가 들어 있습니다.

parseSmsMessage 함수를 호출하면 SMS 메시지 객체를 만들어 주는데 createFromPdu() 함수를 이용하여 Bundle 객체를 SmsMessage 객체로 변환하면 SMS 데이터를 받아올 수 있습니다.

 

getOriginatingAddress() 함수는 발신자 번호를 확인할 수 있습니다.

getMessageBody()는 문자 내용을 확인할 수 있습니다.

getTimestampMillis()를 사용하면 SMS를 받은 시각도 확인할 수 있습니다.

 

Build.VERSION.SDK_INT는 단말의 OS 버전을 확인할 때 사용합니다.

Build.VERSION_CODES에는 안드로이드 OS 버전별로 상수가 정의되어 있습니다.

 

 

이번엔 위험 권한을 추가하는 코드를 작성하겠습니다.

 

MainActivity.java

public class MainActivity extends AppCompatActivity implements AutoPermissionsListener {

    ...

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

        ...

        AutoPermissions.Companion.loadAllPermissions(this, 101);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        AutoPermissions.Companion.parsePermissions(this, requestCode, permissions, this);
    }

    @Override
    public void onDenied(int i, String[] strings) {
        Toast.makeText(this, "permissions denied : " + strings.length, Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onGranted(int i, String[] strings) {
        Toast.makeText(this, "permissions granted : " + strings.length, Toast.LENGTH_SHORT).show();
    }

...

}

이 코드는 위험 권한을 자동으로 부여하는 코드입니다.

 

앱을 실행하면 권한을 요청하는 대화상자가 표시됩니다.

 

여기서 허용 버튼을 누르면 권한이 승인되고 SMS 받을 준비가 된 것입니다.

 

SMS는 이동통신아에 연결되어 있어야 다른 단말로부터 수신할 수 있습니다. 따라서 에뮬레이터로는 실제 SMS 수신이 불가능하지만 가상으로 SMS를 전송할 수 있는 기능이 들어 있습니다.

여기서 ... 버튼을 클릭해주세요.

 

 

그리고 Phone에 들어가셔서 SMS message 내용에 Hello!라고 입력하고 SEND MESSAGE를 보내면 됩니다.

 

그러면 Hello!라는 메시지가 온 것을 확인할 수 있습니다.

 

동시에 안드로이드 스튜디오의 로그창에는 onReceive() 함수가 호출되고 SMS 메시지 정보들이 출력되는 것을 확인할 수 있습니다.

 


위험 권한(Dangerous Permission)

위의 실습에서 위험 권한에 대해 살짝 언급했습니다.

안드로이드의 마시멜로 버전부터 일반 권한(Normal Permission)과 위험 권한(Dangerous Permission)으로 나뉘어져 있습니다.

 

 인터넷을 사용할 때 부여하는 INTERNET 권한은 일반 권한입니다. 앱을 설치할 때 사용자에게 권한이 부여되어야 함을 알려주고 설치할 것인지를 물어봅니다. 사용자가 설명을 보고 수락하면 앱이 설치되고 앱에는 INTERNET 권한이 부여됩니다.

 

 하지만 위험 권한인 RECEIVE_SMS는 설치 시에 부여한 권한은 의미가 없고 실행 시에 사용자에게 권한을 부여할 지 물어봅니다. 사용자가 권한을 부여하지 않으면 해당 기능은 동작하지 않습니다. 앱을 설치해도 권한에 따라 실행할 수 있는 기능에 제약이 생기는 것입니다. 안드로이드 앱을 주로 사용하다보면 위치, 카메라, 마이크, 갤러리, SMS 등 권한을 물어보는 알림을 받은 적이 있을겁니다. 모두 위험 권한인 것을 알 수 있습니다.