Android

Android | Intent(인텐트) 개념

8iggy 2022. 6. 17. 21:02

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

안드로이드 개발자라면서 안드로이드에 대해 집중하여 공부한 적이 단 한번도 없었기에 해당 포스트를 작성합니다. 해당 포스트는 구글 안드로이드 개발자 문서와 몇몇 블로그 포스팅을 참고하여 작성되었습니다. 이번 포스팅에서는 인텐트가 무엇이며 인텐트에 데이터 보관/조회, 명시적or암시적 인텐트와 인텐트 필터, 펜딩 인텐트에 대해 기초적으로 다루며 API Level 31 기준 어떤 이슈가 존재하는 지 간략하게 정리합니다.

인텐트(Intent?)

인텐트(Intent) : 컴포넌트를 실행하기 위해 시스템에 전달하는 메세지 객체로 기능을 수행하는 함수를 제공하지 않고 데이터를 담는 클래스이다.
안드로이드의 컴포넌트 클래스는 개발자가 코드에서 직접 생성해 실행할 수 없기에 Activity 객체를 직접 생성할 수 없으므로 Intent를 시스템에 전달하여 컴포넌트를 실행해야 한다. 그리고 앱 내에서 메세지를 전달함에 그치지 않고 외부 앱의 컴포넌트와 연동할 때도 사용한다.
액티비티 등 안드로이드 컴포넌트를 manifest에 등록하는 것도 시스템에 컴포넌트에 알린 뒤 요청을 받은 Intent가 시스템에 요청하여 해당 컴포넌트를 실행하기 때문이다.

인텐트가 주로 사용되는 곳은 액티비티 시작, 서비스 시작, 브로드캐스트 전달 등이 있다.

인텐트 엑스트라 데이터(Intent Extra Data)

A 액티비티에서 B 액티비티를 실행하면서 데이터를 전송하려면 액티비티 객체에 매개변수로 넘기는 방법밖에 떠오르지 않는다. 그러나, 컴포넌트 객체는 시스템이 생성하므로 개발자 코드로 접근할 수 없기 때문에 불가능하다. intent에 컴포넌트 실행을 요청할 때 데이터를 함꼐 전달하기 위해 Extra Data를 사용한다. putExtra()메소드를 호출하여 그 안에 데이터를 추가한다. putExtra()메소드는 각 타입 별 데이터를 담을 수 있도록 오버로딩되어 있다.

val intent: Intent = Intetn(this, SecondActivity::class.java)
intent.putExtra("data1", "hello")
intent.putExtra("data2", 10)
startActivity(intent)

위 코드와 같이 컴포넌트를 실행하기 앞서 intent에 키-값 형태로 데이터를 넣는다. 컴포넌트가 실행된 뒤 함께 전달받은 데이터를 조회하기 위해 각 타입 별로 get[type]Extra()형태의 메소드를 지원한다.

val data1 = intent.getStringExtra("data1")
// 2번째 파라미터는 데이터가 없을 때 default value를 지정함을 의미
val data2 = intent.getIntExtra("data2", 0)

Explicit Intent, Implicit Intent

Explicit Intent(명시적 인텐트)

클래스 타입 레퍼런스 정보 등 컴포넌트 이름(Component Name)을 활용한 인텐트로 내부 앱의 컴포넌트를 요청하는 인텐트 객체를 만들 때 사용하며 외부 앱의 컴포넌트 호출하려면 패키지 명을 fullname으로 작성해야 한다.

val intent: Intent = Intent(this, SecondActivity::class.java)
// SecondActivity::class.java -> 클래스 타입 레퍼런스
  • 명시적 인텐트로 다른 앱을 실행할 수는 없는가?

    가능하다. ComponentName을 정확히 명시해서 다른 앱의 컴포넌트를 실행시킬 수 있다.

ComponentName componentName = new ComponentName(packageName,className);
    Intent intent = new Intent();
    intent.setComponent(componentName);
    intent.putExtra("key","1234");
    startActivity(intent)

Inplicit Intent(암시적 인텐트)

인텐트 필터와 연관이 있는 인텐트다. 외부 앱의 컴포넌트는 클래스 타입 레퍼런스를 사용할 수 없으며 매니페스트 파일에 선언된 인텐트 필터를 이용하여 속성을 지정한 뒤 코드로 호출한다. 암시적 인텐트를 호출한 액티비티로부터 안드로이드 시스템이 인텐트 객체를 전달받아 실행할 수 있는 액티비티 탐색 후 조건에 맞는 액티비티를 실행한다.

  • manifest.xml
<activity android:name=".FirstActivity"/>
<activity android:name=".SecondActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="ACTION_VIEW"/>
    </intent-filter>
</activity>
  • classCode.kt
// "https://naver.com" uri를 보여줄 수 있는 아무 앱이나 실행
val intent = Intent()
intent.action = Intent.ACTION_VIEW
intent.data = Uri.parse("https://naver.com")
startActivity(intent)

인텐트 구성요소와 인텐트 필터(Intent Filter)

인텐트 필터는 매니페스트 파일에 정의하는 태그 집합이다. 해당 컴포넌트가 수신하고자 하는 인텐트의 유형을 표현한다. 인텐트 필터 정의 시 인텐트 구성요소를 사용한다. 시스템은 정의된 인텐트 필터들 중 통과되는 경우가 있을 때만 암시적 인텐트를 앱 컴포넌트에 전달한다.

인텐트에 포함된 기본적인 구성요소는 암시적 인텐트와 연관이 있고 인텐트 필터 태그 정의 시 비슷하게 등장하기에 함께 작성하였다.

Action, <action>

보통의 액티비티를 정의할 때처럼 name 속성만 매니페스트 파일에 명시한 경우 해당 액티비티는 명시적 인텐트로만 실행할 수 있다. action 태그의 name 값은 개발자가 임의로 지정할 수 있고 그 값이 앱에서 유일하지 않아도 되나 컴포넌트의 기능을 나타낼 것을 권장하고 있다. 예를 들어, 데이터를 보여주는 기능이라면 android.intent.action.VIEW라고 선언하거나 데이터를 편집한다면 android.intent.action.EDIT을, 데이터를 다른 앱을 통해 공유하려면 android.intent.action.SEND로 선언할 수 있다. 코드 상에서는 intent.action = Intent.ACTION_EDIT 와 같이 지정하거나 setAction() 함수를 사용한다.

코드 상에서도 직접 정의할 수 있는데 그 경우엔 앱의 패키지 이름을 접두어로 사용한다. (ex. const val ACTION_TIMETRAVEL = "com.example.action.TIMETRAVEL")

Action 권한 요구

findViewByID<View>(R.id.btnCMainCall).setOnClickListener {
    val intent = Intent().apply {
        action = Intent.ACTION_CALL
        data = Uri.parse("tel:123-4567")
    }
    startActivity(intent)
}

위와 같이 버튼 클릭 리스너를 정의 후 버튼을 클릭하면 에러가 발생한다. 그 이유는 ACTION_CALL의 경우 코드에서 직접 전화를 걸게끔 하기 때문에 권한 요청이 필요하기 때문이다. 먼저 매니페스트에 <uses-permission> 태그를 사용해 권한 요청 선언을 하고 코드로 권한을 요구해야 한다.

<uses-permission android:name="android.permission.CALL_PHONE"/>

위와 같이 매니페스트에 권한 요청을 등록해두고 사용자에게 권한을 요청하기 위해registerForActivityResult() 함수를 사용할 수 있다. Contracts 객체로 한 가지의 권한을 요청하는 RequestPermission 을 선언하고 Callback 객체를 lambda로 정의하자.

val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
    // 사용자가 권한을 허용했으면 isGranted = True
    if (isGranted) {
        val intent = Intent().apply {
            action = Intent.ACTION_CALL
            data = Uri.parse("tel:123-4567")
        }
        startActivity(intent)
    } else {
        Toast.makeText(this, "Call Permission Denied", Toast.LENGTH_SHORT).show()
    }
}

위 코드에서 isGranted를 수신하는 파라미터 변수명으로 쓰였는데 it이 아니라 isGranted로 파라미터 이름을 바꾼 이유는 RequestPermission 의 내부 코드를 보면 알 수 있다. 아래 코드는 RequestPermission 클래스의 정의와 parseResult 메소드 코드 일부를 발췌한 결과다.

public static final class RequestPermission extends ActivityResultContract<String, Boolean> {
        @NonNull
        @Override
        public Boolean parseResult(int resultCode, @Nullable Intent intent) {
            if (intent == null || resultCode != Activity.RESULT_OK) return false;
            int[] grantResults = intent.getIntArrayExtra(EXTRA_PERMISSION_GRANT_RESULTS);
            if (grantResults == null || grantResults.length == 0) return false;
            return grantResults[0] == PackageManager.PERMISSION_GRANTED;
        }
    ...
}

grantResult[0]의 값이 PackageManger.PERMISSON_GRANTED를 만족시키지 못하면 결과를 리턴하지 않는 것으로 보아 grant 여부가 중요해 보인다. 따라서, lambda 함수 정의 시에도 단순하게 수신하는 파라미터를 it으로 정의하지 않고 grant 여부를 알 수 있도록 isGranted로 명시함이 코드 관리 측면에서 좋다. launcher를 정의했으니 클릭 리스너 코드를 정의해보자. 아래 코드에서 분기문을 사용했는데 launcher 객체를 사용하여 이미 권한을 획득한 상태인 경우 더 이상 권한 요청할 필요가 없으므로 바로 인텐트를 실행한다. 권한 여부를 비교하는 상수는 RequestPermission() 내부 코드에서 볼 수 있듯이 PackageManager.PERMISSION_GRANTED 상수임을 확인했으므로 분기문 조건 내에 삽입했다.

val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
    // 사용자가 권한을 허용했으면 isGranted = True
    if (isGranted) {
        val intent = Intent().apply {
            action = Intent.ACTION_CALL
            data = Uri.parse("tel:123-4567")
        }
        startActivity(intent)
    } else {
        Toast.makeText(this, "Call Permission Denied", Toast.LENGTH_SHORT).show()
    }
}
findViewById<View>(R.id.btnMainCall).setOnClickListener {
    if ((ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)) == PackageManager.PERMISSION_GRANTED) {
        val intent = Intent().apply {
            action = Intent.ACTION_CALL
            data = Uri.parse("tel:123-4567")
        }
        startActivity(intent)
    } else {
        launcher.launch(Manifest.permission.CALL_PHONE)
    }
}

위 코드를 살펴보니 인텐트를 실행하는 코드가 중복이 되고 있다. 이걸 공통 함수로 정리하면 아래 코드와 같이 클래스 코드를 정의할 수 있다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
            if (isGranted) {
                startCallActivity()
            } else {
                Toast.makeText(this, "Call Permission Denied", Toast.LENGTH_SHORT).show()
            }
        }
        findViewById<View>(R.id.btnMainCall).setOnClickListener {
            if ((ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)) == PackageManager.PERMISSION_GRANTED) {
                startCallActivity()
            } else {
                launcher.launch(Manifest.permission.CALL_PHONE)
            }
        }
    }
    private fun startCallActivity() {
        val intent = Intent().apply {
            action = Intent.ACTION_CALL
            data = Uri.parse("tel:123-4567")
        }
        startActivity(intent)
    }
}
  • Action 커스텀
val intent = Intent("NAVER", Uri.parse("https://naver.com"))
startActivity(intent)

위 코드를 실행하면 NAVER라는 액션을 갖는 액티비티를 찾을 수 없어 Exception이 발생할 것이다. 그렇다면 커스텀 액션 사용 시 사용자 기기에 해당 액션을 갖는 액티비티를 찾는 기능을 함께 적용해야 한다. 매번 개발자가 액션 존재 여부를 파악할 수 없으므로 resolveActivity 함수를 사용해 실제로 액션을 갖는 액티비티가 존재하는지 확인할 수 있다.

val intent = Intent().apply {
    action = "android.intent.action.NAVER"
    data = Uri.parse("https://naver.com")
}
// API Level 30부터는 manifest에 queries 태그를 선언해야 외부 앱 탐색이 가능하다
val resolveResult = intent.resolveActivity(packageManager)
if (result != null) {
    startActivity(intent)
} else {
    Toast.makeText(this, "resolve result is null", Toast.LENGTH_SHORT).show()
}
<quries>
    <intent>
        <action android:name="android.intent.action.NAVER"/>
        <data android:scheme="https"/>
    </intent>
</quries>

Category, <category>

category 태그는 컴포넌트가 어느 범주인지 등 추가 정보를 담는다. 개발자가 임의로 지정할 수는 있지만 대부분 플랫폼 API에서 제공하는 문자열을 이용한다. 예를 들어 android.intent.category.LAUNCHER는 런쳐가 실행하는 컴포넌트임을 의미한다. android.intent.category.BROWSABLE은 브러우저가 실행하는 컴포넌트임을 의미한다

암시적 인텐트를 수신하는 입장이라면 CATEGORY_DEFAULT 카테고리를 반드시 인텐트 필터에 포함시켜야 한다.

Data, <data>

data 태그는 컴포넌트가 어떤 성격의 데이터를 처리하는지 보여준다. 필요한 만큼 선언할 수 있으며 URI 형태로 표현한다. URI의 각 부분은 별도의 특성(scheme, host, port, path)으로 구성된다. android:scheme, android:host, android:port, android:mimeType 등의 속성을 이용한다.

  • URI(Uniform Resource Identifier)?

    리소스를 나타내는 식별자이다. <scheme>://<host>:<port>/<path>의 구조로 이루어진다.

  • MIME Type?

    전달되는 파일, 콘텐츠 식별자이다. type/subtype으로 이루어진다. 예를 들자면, text/plain, image/png, video/mp4 등이 있다.

코드에서는 MiME Type과 URI를 지정한다. 메소드는 setData(), setType(), setDataAndType() 3가지가 있으며 각각에 대해 서로 덮어씌우기 때문에 3가지 함수 중 1개만 사용해야 한다.

  1. setData() : 데이터 URI 설정

        public Intent setData(Uri data) {
            mData = data;
            mType = null;
            return this;
        }
  2. setType() : 데이터 MIME Type 설정

        public Intent setType(String type) {
            mData = null;
            mType = type;
            return this;
        }
  3. setDataAndType() : 두 가지 모두 설정

        public Intent setDataAndType(Uri data, String type) {
            mData = data;
            mType = type;
            return this;
        }

인텐트 필터 정의 시<scheme>://<host>:<port>/<path>의 형태로 요청하기에 각각에 대해 명시해야 한다. 다만, 순서대로 종속성을 갖기에 앞쪽 속성이 생략되면 뒤의 속성들도 모두 생략된다. content://com.example.project:200/folder/subfolder/etc/test.jpg uri의 데이터를 다룬다고 하면 다음과 같이 매니페스트에서 정의할 수 있다.

    <data android:scheme="content"/>
    <data android:host="com.example.project"/>
    <data android:port="200"/>
    <data android:path="/folder/subfolder/etc/test.jpg"/>
    <data android:mimeType="image/jpeg"/>

코드에서 인텐트 전송 시 매니페스트에 선언된 데이터 태그 형식과 인텐트 데이터 설정이 정확히 일치해야 성공적으로 전송할 수 있다. 만약 매니페스트에서 URI와 MIME Type을 모두 선언했는데 코드에선 MIME Type가 누락되면 정상적으로 동작하지 않는다.

Extra

추가 정보를 담는 key-value이다. 인텐트를 이용해 액티비티 간 데이터를 주고받는 목적으로 많이 사용된다.

// 인텐트를 보내는 액티비티에서 데이터를 담음
intent.putExtra("foo", "bar")

// 인텐트를 받는 액티비티에서 조회하는 데이터 자료형에 따라 적절한 함수 호출
val data1 = intent.getStringExtra("foo")

Flag

액티비티를 어떻게 시작할 것인지 설정을 정의한다. launchMode로 이해할 수 있다. 코드 상에선 setFlags() 함수를 이용해 Intent.FLAG_ACTIVITY_CLEAR_TOP과 같이 정의할 수 있으며 직접 커스텀할 수는 없다.

메인 액티비티 인텐트 필터

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

위 xml 코드는 메인 액티비티를 매니페스트 파일에 정의한 형태이다. 여기선 action, category 태그가 보인다. 별다른 인텐트를 기대하지 않는 앱의 첫 화면이기에 action name은 MAIN으로 지정되었다. 매니페스트 파일에 인텐트 필터를 등록했기에 사용자가 런쳐 앱(외부 앱)에서 아이콘을 클릭해 실행할 수 있는 것이다. 외부 앱인 런쳐 앱은 시스템에서 LAUNCHER 카테고리의 정보를 가져와 앱 아이콘을 나열한다. 그렇기에 앱의 첫 화면에 해당하는 액티비티의 인텐트 필터는 action name을 MAIN으로 지정하고 category를 LAUNCHER로 지정해야 한다.

메인 액티비티가 아닌 다른 액티비티의 인텐트 필터

메인 액티비티가 아니더라도 인텐트 필터를 명시하면 외부 앱과 연동할 수 있다.

<activity
    android:name=".SecondActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="ACTION_EDIT"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <data android:scheme="https"/>
        <data android:host="www.google.com"/>
    </intent-filter>
</activity>

위 xml 코드대로 SecondActivity 정보를 매니페스트에 작성했다고 가정하자. 해당 액티비티를 시작하기 위해 인텐트를 사용하려면 인텐트 필터에 선언된 정보에 맞춰야 한다.

val intent = Intent()
intent.action = "ACTION_EDIT"
intent.data = Uri.parse("https://www.google.com")
startActivity(intent)

인텐트의 action과 data 프로퍼티에 실행 대상인 컴포넌트 정보를 지정하는 코드이다. 매니페스트 파일에 선언한 대로 컴포넌트 정보를 지정하였다. 프로퍼티에 설정하지 않고 생성자의 매개변수로 지정해도 된다.

val intent = Intent("ACTION_EDIT", Uri.parse("http://www.google.com")
startActivity(intent)

앞서 매니페스트 파일에 SecondActivity의 인텐트 필터 category 태그의 값을 DEFAULT로 지정했으나 코드에서는 설정하지 않았는데, 인텐트에 카테고리 정보를 전달하지 않으면 기본값으로 DEFAULT로 지정된다. 인텐트가 필터를 통과하기 위해 코드 상으로 요청하는 모든 인텐트 카테고리는 매니페스트에 선언한 카테고리 범주 내에 있어야 한다. 다만, 역은 성립하지 않기에 인텐트가 갖는 카테고리보다 필터가 더 많은 카테고리를 가지더라도 인텐트가 가진 카테고리 정보가 모두 필터에 존재한다면 통과된다. 따라서, 어떠한 카테고리를 지정하지 않은 인텐트 수신을 위해 매니페스트의 인텐트 필터 카테고리에 DEFAULT 추가를 권장하는 것이다.

<activity
    android:name=".SecondActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="ACTION_EDIT"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <data android:mimeType="image/*"/>
    </intent-filter>
</activity>

위와 같이 scheme, host 태그를 제거하고 mimeType 태그를 새로이 선언했을 경우 인텐트를 다음과 같이 정의해야 한다.

val intent = Intent("ACTION_EDIT")
intent.type = "image/*"
startActivity(intent)

SecondActivity의 인텐트 필터를 하나 추가해보자.

<activity
    android:name=".SecondActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="ACTION_EDIT"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <data android:mimeType="image/*"/>
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.SEND"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data 
              android:scheme="http"
              android:port="80"
              android:host="naver.com"
              android:mimeType="text/plain"/>
    </intent-filter>
</activity>

그리고 버튼을 하나 추가하고 그 안에 리스너 코드를 작성하자.

        findViewById<Button>(R.id.btnSecond).setOnClickListener {
            val intent = Intent()
            intent.action = Intent.ACTION_SEND
            intent.addCategory(Intent.CATEGORY_BROWSABLE)
            intent.setDataAndType(Uri.parse("http://naver.com:80"), "text/plain")
            startActivity(intent)
        }

만약 코드 상으로 BROWSABLE을 설정했는데 인텐트 필터에 정의가 되어있지 않아 수신할 컴포넌트가 없다면 error가 발생한다. 그러나 역으로 코드에선 설정하지 않고 인텐트 필터 상으로 정의해두면 필터 범주 내에 포함된 경우 정상적으로 동작한다. 위에서 정의한 버튼 리스너를 동작시키면 코드에서 암시적 인텐트를 전송한다. 이후 매니페스트에 SecondActivity 인텐트 필터로 해당 인텐트를 수신받게끔 선언했으므로 버튼 클릭 시 코드에서 전송한 인텐트를 SecondActivity가 받아 재생성됨을 확인할 수 있다.

Intent 수신 테스트

앱이 인텐트를 잘 수신하는지 테스트를 위해 매번 다른 앱을 이용하기에는 조금 번거로울 수 있다. 그럴 땐 terminal에서 ADB를 사용하면 된다.

adb shell am start -a <ACTION> -t <MIME_TYPE> -d <DATA> \ -e <EXTRA_NAME> <EXTRA_VALUE> -n <ACTIVITY>
adb shell am start -a android.intent.action.SEND -d http://naver.com:80 -t "text/plain"

개수에 다른 액티비티 인텐트 동작

인텐트로 액티비티를 실행할 경우 실행할 액티비티가 0, 1, n의 가지수를 갖게 된다. 다만, 명시적 인텐트는 클래스 타입 레퍼런스 정보를 이용하므로 액티비티가 없거나 여러 개일 수는 없다. 암시적 인텐트는 모든 경우에 해당될 수 있다.

  1. 실행할 액티비티의 개수가 0 : 인텐트를 시작한 곳에서 오류 발생
  2. 실행할 액티비티의 개수가 1 : 문제없이 실행됨
  3. 실행할 액티비티의 개수가 여러 개 : 사용자 선택으로 하나만 실행됨

인텐트로 실행할 액티비티가 없음

val intent = Intent("ACTION_FOO")
startActivity(intent)

ACTION_FOO라는 액션 값은 존재하지 않으므로 시스템에 해당 정보로 실행할 액티비티가 없을 것이다. 이 경우 ActivityNotFoundException 에러가 발생한다. 오류가 발생하면 앱이 강제 종료되는 상황이 발생하기 때문에 이에 대해 예외처리를 해야 한다.

val intent = Intent("ACTION_FOO")
try{
    startActivity(intent)
} catch(e: Exception) {
    Toast.makeText(this, "액티비티의 action 값을 찾을 수 없습니다", Toast.LENGTH_SHORT).show()
}

인텐트로 실행할 액티비티가 여러 개

인텐트로 실행할 수 있는 액티비티가 여러 개라면 사용자에게 선택할 수 있는 옵션을 제공한다. 다음의 코드는 암시적 인텐트를 실행하는 동작을 정의한다.

val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194")
startActivity(intent)

특정 좌표를 검색하는 코드이다. 만약 사용자 단말기에 여러 가지의 앱(여기서는 지도)들이 설치되었다면 다이얼로그(App Chooser)를 띄워 어떤 앱의 액티비티를 실행할 것인지를 묻는다.

Android_Intent_001.png

만약 특정 플랫폼에 의존하는 앱을 제작한다면(구글이나 네이버 등) 사용자에게 옵션을 제공하기 보다는 특정 앱의 액티비티로 실행하고 싶을 수 있다. 이럴 때는 패키지 명을 지정해야 한다.

val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194")
intent.setPackage("com.google.android.apps.maps") // 원칙적으로 구글 맵 앱을 실행
startActivity(intent)
  • App Chooser는 기본값으로만 활용해야 하는가?

    그렇지 않다. val chooser = Intent.createChooser(intext, "Custom Chooser Title") 의 코드를 첨부하여 startActivity 코드를 실행하면 원하는 타이틀이 작성된 Chooser가 화면에 출력된다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194"))
        /*
        val intent = Intent(Intent.ACTION_VIEW)
        intent.putExtra("body", "else?")
        */
        val chooser = Intent.createChooser(intent, "Custom Chooser Title!")
        startActivity(chooser)
    }
}
  • 유저 단말기에 setPackage 메소드에 지정한 앱이 존재하지 않는 경우
    무작정 setPackage에 패키지 이름을 넣고 실행할 경우, 열고자 하는 앱이 설치되지 않은 단말기에서는 에러가 발생하며 강제종료된다. 따라서, PackageManager로 설치여부를 검사한 뒤 통과되지 않으면 setPackage 메소드로 경로를 지정하지 말아야 한다. 혹은, 앱 시작 시 원하는 앱을 필수 설치 항목으로 제한해야 한다. 간략하게 예제를 작성하면 다음과 같다.
class MainActivity : AppCompatActivity() {
    private val context: Context = this
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.359413874091246, 127.10488305986283"))
        try {
            val packageManager:PackageManager = context.packageManager
            packageManager.getPackageInfo("com.nhn.android.nmap", PackageManager.GET_ACTIVITIES)
            intent.setPackage("com.google.android.apps.maps")
        } catch (e:Exception) {
            Toast.makeText(context, "don't have naver", Toast.LENGTH_SHORT).show()
        }
        startActivity(intent)
    }
}

Pending Intent(보류 인텐트)

다른 Application에게 권한을 허가하여 내 Application 프로세스에서 앱 컴포넌트를 실행하는 것처럼 동작하기 위해 Intent를 wrapping하고 있는 Class이다. 즉, 액티비티의 실행 주체가 자신의 앱이 아님에도 실행할 수 있도록 한다.

  1. 사용자가 Notification으로 특정 작업 수행 시 인텐트가 실행되도록 선언

    LINE SNS 앱의 경우 새로운 메세지 푸시 알림 터치 시 라인 메세지 앱이 실행되는데 시스템의 NotificationManager가 라인 메세지 엑티비티를 실행하도록 하는 Intent를 실행한다.

  2. App Widget으로 어떤 작업을 수행할 때 인텐트가 실행되도록 선언

    App Widget이란 다른 앱에 삽입되어 추가적인 업데이트를 받을 수 있는 소형 애플리케이션 뷰다. 유튜브나 음악 스트리밍 앱 실행 중 홈 키를 누르면 메인 화면에 소형화된 상태로 축소됨을 볼 수 있는데 해당 동작이 홈 스크린 앱이 Intent를 실행한 결과이다.

  3. 향후 지정된 시각에 인텐트가 실행되도록 선언

    AlarmManager가 인텐트를 실행하는 동작이 포함된다.

Pending Intent 선언

인텐트는 앱 컴포넌트(액티비티, 브로드캐스트 리시버, 서비스)가 처리한다. Pending Intent 또한 각 컴포넌트와 연결해줘야 한다. Pending Intent 선언은 PendingIntent 클래스를 활용한다.

  • Activity를 시작하는 인텐트 정의 시 PendingIntent.getActivity()
  • Service를 시작하는 인텐트 정의 시 PendingIntent.getService()
  • BroadcastReceiver를 시작하는 인텐트 정의 시 PendingIntent.getBroadcast()

다음 코드에서는 액티비티를 시작하는 간단한 보류 인텐트를 정의한다.

// requestCode를 통해 PendingIntent를 수신할 때 구분하기 위한 id 값이다.
// intent를 통해 실제로 실행할 Intent를 정의
val requestCode = 1234
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
    this,
    requestCode,
    intent,
    PendingIntent.FLAG_UPDATE_CURRENT)

4번째 파라미터에 플래그를 선언했음을 볼 수 있다. 어떤 플래그가 있는지 확인해보자.

  1. FLAG_UPDATE_CURRENT : 이미 존재하면 덮어씌움

  2. FLAG_CANCEL_CURRENT : 이미 존재하면 cancel하고 재생성

  3. FLAG_IMMUTABLE : 이미 생성한 경우 새로 전송한 인텐트 파라미터 수정 요청을 무시

  4. FLAG_MUTABLE : 생성된 인텐트 수정 가능

    API Level 31에서 추가된 flag로 API Level 30 이전까지는 기본 값으로 mutable하게 처리되었으나 31부터는 pending intent 생성 시 mutability를 정확히 명시해야 한다. 물론, 레퍼런스에서는 FLAG_IMMUTABLE 사용을 강력히 권장하고 있다.

  5. FLAG_NO_CREATE : 생성하지 않고 이미 존재하는 인텐트를 가져옴

  6. FLAG_ONE_SHOT : 일회용으로 FLAG_NO_CREATE로 가져올 수 없음

Immutable Pending Intent 업데이트

레퍼런스에서는 API Level 30 이전까지는 PendingIntent의 기본값이 MUTABLE이었으나 이젠 Mutable Flag 사용 시 주의해야 한다고 설명한다. PendingIntent 업데이트를 하려면 변경가능한(mutable) 상태여야 할 것 같은데 immutable 상태에서 어떻게 수행해야 하느지 의문이 든다. IMMUTABLE 플래그를 적용한 상태에서 PendingIntent 업데이트를 하려면 추가로 FLAG_UPDATE_CURRENT 플래그를 함께 적용해서 생성하면 된다. 아래 코드에서는 API Level 수준을 고려하여 분기문을 적용하였다. IMMUTABLE 플래그는 API Level 23부터 추가되었기에 분기문 조건을 strict하게 설정하였다.

val requestCode = 1234
val pedingIntentFlag =
  if (SDK_INT >= Build.VERSION_CODES.M) {
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
  } else {
    PendingIntent.FLAG_UPDATE_CURRENT
  }
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
    this,
    requestCode,
    intent,
    pedingIntentFlag)

Mutable Pending Intent 사용

어떤 상황에서 Mutable한 Pending Intent를 사용해야 하는지 의문이 생긴다. 리서치를 조금 해본 결과 pending event가 mutable함을 보장받지 못하면 Geofences가(위치 정보를 사용하여 사용자가 특정 위치 반경에 진입/머물기/이탈 등의 이벤트를 처리할 수 있는 기능) trigger되지 않는다는 이슈를 볼 수 있다. 즉, 레퍼런스의 권장사항과는 달리 Mutable flag 사용이 강제되는 상황이 있음을 알 수 있다.

레퍼런스에서는 기능적으로 이미 존재하는 인텐트를 수정해야 하는 로직에서만 적용할 것을 권고하는데 PendingIntent that needs to be used with inline reply or bubbles.라는 예시를 제시한다.

  • inline reply

    푸시알림을 수신받았을 때 알림창에서 답장할 수 있게끔 동작하는 기능이 inline reply이다.

  • bubble

    페이스북 메신저 기능에서 볼 수 있듯이 다른 앱 위에서 floating하며 사용자가 의도하는 위치에 표시하는 기능

mutable pending intent를 사용해야 하는 경우 어떻게 인텐트를 수정하는지 코드로 작성해보자. 아래는 이제까지 작성해 온 PendingIntent 객체 생성 코드에서 flag 설정을 MUTABLE로 바꾼 결과이다. 인텐트가 실행되면 MainActivity가 실행된다.

val requestCode = 1234
val pedingIntentFlag =
  if (SDK_INT >= Build.VERSION_CODES.S) {
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
  } else {
    PendingIntent.FLAG_UPDATE_CURRENT
  }
val intent = Intent(this, MainActivity::class.java)
val mutablePendingIntent = PendingIntent.getActivity(
    this,
    notificationRequestCode,
    intent,
    pedingIntentFlag)

아래 코드는 기존에 생성했던 pendingIntent를 새롭게 추가할 정보와 함께 요청 코드를 수정하고 send 함수를 호출한다. PendingIntentsend 함수를 호출하면 인텐트를 실행한다. 따라서, send 함수를 호출하여 pendingIntent를 실행하면 이전에 getActivity로 액티비티를 지정한 값이 남아있으므로 해당 액티비티에 대해 startActivity 함수를 실행하되 수정된 requestCode와 추가된 message가 담긴 인텐트를 전달한다. 결과적으로 MainActivity가 열리면서 PENDING_INTENT_CODE로 전달된 인텐트로 메세지를 수신받아 동작을 하게 된다.

val intentWithExtraMessage = Intent().apply {
   putExtra(EXTRA_MESSAGE_KEY, messageBody)
}
mutablePendingIntent.send(
   applicationContext,
   PENDING_INTENT_CODE,
   intentWithExtraMessage
)

참고자료

  1. Android Developers - 인텐트 및 인텐트 필터
  2. All About PendingIntents - Mutable PendingIntents
  3. Android 12, PendingIntent Mutability, and Geofences
  4. Android Developers - PendingIntent
  5. Do it! 깡쌤의 안드로이드 앱 프로그래밍 with 코틀린