[Android] 암시적 인텐트

안드로이드에서는 인텐트 intent 를 사용해 장치의 다른 앱에 있는 액티비티를 시작시킬 수 있다. 명시적 인텐트 explicit intent 에서는 시작시킬 액티비티 클래스를 지정하면 안드로이드 운영체제가 해당 액티비티를 시작시킨다. 반면에 암시적 인텐트 implicit intent 에서는 해야 할 작업을 알려주면 안드로이드 운영체제가 이 작업을 수행하는 데 적합한 앱의 액티비티를 찾아서 시작시킨다.

본문에서는 ① 암시적 인텐트를 사용해서 사용자의 연락처에서 범죄 용의자를 한 명 선택하고, ② 텍스트 형태의 범죄 보고서를 전송할 수 있게 한다. 이때 사용자는 장치에 설치된 연락처 앱과 텍스트 전송 앱을 선택해 사용할 수 있다.

암시적 인텐트를 사용하면 추가로 앱을 개발하지 않아도 다른 앱을 이용할 수 있다. 따라서 모바일 장치의 여러 앱들에게 공통으로 필요한 작업을 쉽게 처리할 수 있다. 따라서 모바일 장치의 여러 앱들에게 공통으로 필요한 작업을 쉽게 처리할 수 있다. 그리고 사용자 또한 다른 앱들을 이 앱과 연계해 사용할 수 있다.

암시적 인텐트 생성에 앞서 다음과 같이 몇 가지 준비할 것이 있다.

  • ‘용의자 선택’ 버튼과 ‘범죄 보고서 전송’ 버튼을 CrimeFragment의 레이아웃에 추가한다.
  • 용의자 이름을 저장하는 suspect 속성을 Crime 클래스에 추가한다.
  • 포맷 리소스 문자열 format resource string 을 사용해서 범죄 보고서를 생성한다.

모델 계층에 용의자 추가하기

용의자 이름을 저장할 새로운 속성을 Crime 클래스에 추가한다.

suspect 속성 추가하기 (Crime.kt)

1
2
3
4
5
6
7
8
@Entity
data class Crime(
@PrimaryKey val id: UUID = UUID.randomUUID(),
var title: String = "",
var date: Date = Date(),
var isSolved: Boolean = false,
var suspect: String = ""
)

그런 다음 suspect 속성값을 저장하도록 데이터베이스의 Crime 테이블 열 column도 추가해야 한다. 이렇게 하려면 CrimeDatabase 클래스의 버전을 높여서 Room이 데이터베이스를 새 버전으로 이행 migration하게 해야 한다(이행은 기존 데이터베이스의 스키마를 업데이트하고 데이터를 새 버전의 스키마에 맞춰 옮기는 것을 말한다.). 이때 Migration 클래스를 사용한다.

새 버전의 데이터베이스로 이행하기 (database/CrimeDatabase.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// @Database(entities = [Crime::class], version = 1)
@Database(entities = [Crime::class], version = 2)
@TypeConverters(CrimeTypeConverters::class)
abstract class CrimeDatabase : RoomDatabase() {

abstract fun crimeDao(): CrimeDao
}

val migration_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE Crime ADD COLUMN suspect TEXT NOT NULL DEFAULT ''"
)
}
}

데이터베이스의 초기 버전이 1이었으므로 위의 코드에서는 2로 올렸다. 그리고 Migration 객체를 생성해 데이터베이스를 업데이트한다.

Migration 클래스의 생성자는 두 개의 인자를 받는다. 첫 번째는 업데이트 전의 데이터베이스 버전이고, 두 번째는 업데이트할 버전이다. 여기서는 버전 번호를 1과 2로 지정하였다.

Migration 객체에는 migrate(SupportSQLiteDatabase) 함수만 구현하면 된다. 이 함수에서는 인자로 전달된 데이터베이스를 사용해서 테이블을 업그레이드하는 데 필요한 SQL 명령을 실행한다. 여기서는 ALTER TABLE 명령으로 suspect 열을 Crime 테이블에 추가한다.

생성된 Migration 객체는 데이터베이스를 생성할 때 제공해야 한다. CrimeRepository에서 CrimeDatabase 인스턴스를 생성할 때 Migration 객체를 Room에 제공하도록 변경한다.

Migration 객체를 Room에 제공하기 (CrimeRepository.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
import com.june0122.criminalintent.database.migration_1_2
...

class CrimeRepository private constructor(context: Context) {

private val database: CrimeDatabase = Room.databaseBuilder(
context.applicationContext,
CrimeDatabase::class.java,
DATABASE_NAME
// ).build()
).addMigrations(migration_1_2)
.build()

private val crimeDao = database.crimeDao()
...
}

Migration 객체를 설정하려면 addMigration(...)를 호출한 후에 build() 함수를 호출해야 한다. addMigration(...) 함수는 여러 개의 Migration 객체를 인자로 받을 수 있으므로 선언했던 모든 Migration 객체를 한꺼번에 전달할 수 있다.

앱이 실행되어 Room이 데이터베이스를 빌드할 때는 맨 먼저 장치의 기존 데이터베이스 버전을 확인한다. 그리고 이 버전이 CrimeDatabase 클래스의 @Database 애노테이션에 지정된 것과 일치하지 않으면, Room이 @Database에 지정된 버전에 맞는 Migration 객체를 찾아서 해당 버전으로 데이터베이스를 업데이트한다.

만일 데이터베이스 버전을 변경할 때 Migration 객체를 제공하지 않으면 Room이 기존 버전의 데이터베이스를 삭제하고 새 버전의 데이터베이스를 다시 생성한다. 이때 기존 데이터가 모두 없어지므로 주의해야 한다.

포맷 문자열 사용하기

마지막으로 특정 범죄의 상세 정보로 구성되는 범죄 보고서의 템플릿을 생성하면 모든 준비가 끝난다. 앱이 실행되기 전까지는 범죄의 상세 정보를 알 수 없으니 런타임 시에 대체될 수 있는 플레이스 홀더를 갖는 다음 포맷 문자열을 사용해야 한다.

1
%1$s! 이 범죄가 발견된 날짜는 %2$s. %3$s, 그리고 %4$s

%1$s, %2$s 등 이 문자열 이자로 대체되는 플레이스 홀더들이다. 이 포맷 문자열을 코드에서 사용할 때는 getString(...) 함수를 호출하며, 이때 포맷 문자열 리소스 ID, 그리고 플레이스 홀더들을 대체하는 순서대로 네 개의 문자열을 인자로 전달한다.

문자열 리소스 추가하기 (res/values/strings.xml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<resources>
...
<string name="crime_suspect_text">용의자 선택</string>
<string name="crime_report_text">범죄 보고서 전송</string>
<string name="crime_report">%1$s!
이 범죄가 발견된 날짜는 %2$s. %3$s, 그리고 %4$s
</string>
<string name="crime_report_solved">이 건은 해결되었음</string>
<string name="crime_report_unsolved">이 건은 미해결임</string>
<string name="crime_report_no_suspect">용의자가 없음.</string>
<string name="crime_report_suspect">용의자는 %s.</string>
<string name="crime_report_subject">CriminalIntent 범죄 보고서</string>
<string name="send_report">범죄 보고서 전송</string>
</resources>

다음으로 문자열 네 개를 생성하고 결합해 하나의 완전한 보고서 문자열로 반환하는 함수를 CrimeFragment.kt에 추가한다.

getCrimeReport() 함수 추가하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
...
private const val REQUEST_TIME = 1
private const val DATE_FORMAT = "yyyy년 M월 d일 H시 m분, E요일"

class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...

private fun updateUI() {
...
}

private fun getCrimeReport(): String {
val solvedString = if (crime.isSolved) {
getString(R.string.crime_report_solved)
} else {
getString(R.string.crime_report_unsolved)
}

val dateString = DateFormat.format(DATE_FORMAT, crime.date).toString()

var suspect = if (crime.suspect.isBlank()) {
getString(R.string.crime_report_no_suspect)
} else {
getString(R.string.crime_report_suspect, crime.suspect)
}

return getString(R.string.crime_report, crime.title, dateString, solvedString, suspect)
}

companion object {
...
}
}

(DateFormat 클래스의 import 문을 추가할 때는 android.text.format.DateFormat을 선택해야 한다.)

준비 작업이 끝났으니 이제 암시적 인텐트를 자세히 알아본다.

암시적 인텐트 사용하기

인텐트는 하고자 원하는 것을 안드로이드 운영체제에 알려주는 객체다. 지금까지는 우리가 생성했던 명시적 인텐트를 사용해서 안드로이드 운영체제가 시작시킬 액티비티 이름을 명시적으로 지정하였다.

1
2
val intent = Intent(this, CheatActivity::class.java)
startActivity(intent)

암시적 인텐트를 사용할 때는 원하는 작업을 안드로이드 운영체제에 알려준다. 그러면 해당 작업을 할 수 있다고 자신을 알린 액티비티를 안드로이드 운영체제가 찾아서 시작시킨다. 단, 안드로이드 운영체제가 그런 능력을 가진 액티비티를 두 개 이상 찾으면 사용자가 선택할 수 있게 해준다.

암시적 인텐트의 구성 요소

원하는 작업을 정의할 때 사용하는 암시적 인텐트의 주요 구성 요소는 다음과 같다.

수행하고자 하는 액션 action

  • Intent 클래스의 상수다. 예를 들어, 웹 URL을 보기 원한다면 Intent.ACTION_VIEW를 액션으로 사용하며, 텍스트 등을 전송할 때는 Intent.ACTION_SEND를 사용한다. 이외에도 여러 가지 상수가 있다.

데이터의 위치

  • 웹 페이지의 URL과 같은 장치 외부 것이 될 수 있다. 또는 파일에 대한 URI나 Content Provider의 레코드(주로 데이터베이스 테이블의 행)를 가리키는 콘텐츠 URI도 될 수 있다.

액션에서 필요한 데이터의 타입

  • text/html이나 audio/mpeg3과 같은 MIME 타입이다. 인텐트가 데이터의 위치를 포함하면 해당 데이터로부터 타입을 유추할 수 있다.

선택적으로 사용하는 카테고리

  • 액션이 무엇(what) 을 하는지를 나타내는 데 사용되는 것이라면 카테고리는 액티비티를 어디서(where), 언제(when), 어떻게(how) 사용할지를 나타낸다. 액티비티가 최상위 수준의 앱 론처에 보여야 함을 나타내기 위해 안드로이드는 android.intent.category.LAUNCHER 카테고리를 사용한다. 반면에 액티비티의 패키지에 관한 정보를 사용자에게 보여주되 론처에는 나타나지 않아야 하는 액티비티를 나타내려면 android.intent.category.INFO 카테고리를 사용한다.

예를 들어, 웹 사이트의 페이지를 보는 간단한 암시적 인텐트는 Intent.ACTION_VIEW 액션과 웹 사이트의 URL인 데이터 URI(Uri 객체)를 포함한다.

안드로이드 운영체제는 이런 정보를 기준으로 적합한 애플리케이션의 액티비티를 찾아서 실행한다(만일 하나 이상의 액티비티를 찾으면 대화상자를 보여주고 사용자가 선택하게 해준다).

액티비티는 매니페스트(AndroidManifest.xml)의 인텐트 필터를 통해서 지정된 액션을 수행할 수 있음을 알린다. 예를 들어, 웹 브라우저 앱이라면 ACTION_VIEW를 수행할 수 있는 액티비티를 선언할 때 다음과 같이 인텐트 필터를 포함하면 된다.

1
2
3
4
5
6
7
8
9
<activity
android:name=".BrowserActivity"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" android:host="www.naver.com" />
<intent-filter>
</activity>

액티비티가 암시적 인텐트에 응답하려면 인텐트 필터에 DEFAULT 카테고리를 갖고 있어야 한다. 인텐트 필터의 action 요소는 이 액티비티가 해당 작업(여기서는 VIEW, 즉 웹 URL의 브라우징)을 수행할 수 있음을 안ㄴ드로이드 운영체제에 알린다. 그리고 DEFAULT 카테고리는 해당 작업을 할 의향이 있음을(암시적으로 인텐트를 받겠다는) 안드로이드 운영체제에 알린다. DEFAULT 카테고리는 모든 암시적 이벤트에 기본으로 추가된다.

명시적 인텐트처럼 암시적 인텐트도 엑스트라를 포함할 수 있다. 그러나 암시적 인텐트의 엑스트라는 안드로이드 운영체제가 적합한 액티비티를 찾기 위해 사용하는 것이 아니라 액션에 따른 추가 데이터를 보낼 때 사용한다.

그리고 인텐트의 액션(action 태그로 지정됨)과 데이터(data 태그로 지정됨)는 명시적 인텐트에서도 사용할 수 있다.

범죄 보고서 전송하기

지금부터는 CriminalIntent 앱에서 암시적 인텐트를 생성해 범죄 보고서를 발송하는 방법을 알아본다. 범죄 보고서는 문자열이므로 텍스트를 전송하는 작업을 해야 한다. 따라서 암시적 인텐트의 액션은 ACTION_SEND가 되며, 어떤 데이터나 카테고리도 지정하지 않지만 타입은 text/plain으로 지정한다.

CrimeFragment의 onCreatView(...)에서 ‘범죄 보고서 전송’ 버튼의 참조를 얻은 후, onStart()에서 이 버튼의 리스너를 설정한다. 그리고 이 리스너 내부에서는 암시적 인텐트를 생성해 startActivity(Intent)의 인자로 전달한다.

범죄 보고서 전송하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
private lateinit var solvedCheckBox: CheckBox
private lateinit var reportButton: Button
...

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
...
solvedCheckBox = view.findViewById(R.id.crime_solved) as CheckBox
reportButton = view.findViewById(R.id.crime_report) as Button

return view
}

...

override fun onStart() {
...

dateButton.setOnClickListener {
...
}

timeButton.setOnClickListener {
...
}

reportButton.setOnClickListener {
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, getCrimeReport())
putExtra(Intent.EXTRA_SUBJECT, getString(R.string.crime_report_subject))
}.also { intent ->
startActivity(intent)
}
}
}
...
}

여기서는 액션을 정의하는 상수 문자열을 인자로 받는 Intent 생성자를 사용한다. 생성하야 할 암시적 인텐트의 종류에 따라 사용할 수 있는 생성자도 달라진다. 이와 관련된 내용은 API 문서에서 Intent 클래스를 찾아보면 알 수 있다. 그런데 타입을 인자로 받는 생성자는 없으므로 Intent의 type 속성으로 지정해야 한다.

보고서의 텍스트와 제목 문자열은 엑스트라의 값에 포함되며, 엑스트라의 키는 Intent 클래스에 정의한 상수들을 사용한다(EXTRA_SUBJECT는 메시지의 제목이며, EXTRA_TEXT는 메시지의 데이터다). 이 인텐트에 응답해 시작되는 액티비티는 엑스트라의 키로 사용된 상수들과 각 키의 값이 무엇을 의미하는지 알아야 한다.

프래그먼트에서 액티비티르 시작시키는 것은 액티비티에서 다른 액티비티를 시작시키는 것과 거의 같다. 위의 코드에서는 Fragment의 startActivity(Intent) 함수를 호출하며, 이 함수는 내부적으로 이것과 대응되는 Activity 함수를 호출한다.

CriminalIntent 앱을 실행하고 범죄 리스트에서 한 항목을 클릭한 후 상세 내역 화면이 나타나면 ‘범죄 보고서 전송’ 버튼을 눌러보면 이 인텐트는 장치의 많은 액티비티와 일치하므로 아래의 이미지과 같이 선택할 액티비티들을 보여준다(ACTION_SEND 인텐트에 응답할 수 있는 액티비티가 하나만 있다면 해당 액티비티의 앱이 바로 실행된다).

범죄 보고서를 전송할 수 있는 액티비티들

선택기 chooser가 보여주는 액티비티 중에서 하나를 선택해 액티비티의 앱에서 범죄 보고서를 전송하고, 종료하면 다시 범죄 상세 내역 화면으로 돌아온다.

‘메시지’ 앱을 선택하면 아래 이미지와 같이 범죄 보고서가 메시지로 작성된 상태에서 새 메시지 화면이 나타난다. 그래고 맨 위의 '받는 사람’만 지정하고 '보내기’를 누르면 메시지가 전송된다.

‘메시지’ 앱으로 범죄 보고서 전송하기

만일 선택기가 나타나지 않는다면 이미 이와 같은 암시적 인텐트의 기본 앱을 설정했거나, 이 인텐트에 응답할 수 있는 액티비티가 장치에 하나만 있어서 그렇다.

액티비티를 시작시키기 위해 암시적 인텐트가 사용될 때마다 매번 선택기가 나타나게 할 수도 있다. 그렇게 하려면 이전처럼 암시적 인텐트를 생성한 후에 Intent.createChooser(Intent, String) 함수를 호출하면 된다. 이때 암시적 인텐트와 선택기의 제목 문자열을 인자로 전달한다. 그다음에 createChooser(...)로부터 반환된 인텐트를 startActivity(...)의 인자로 전달한다.

암시적 인텐트에 응답하는 액티비티들을 보여줄 선택기를 생성하는 코드를 CrimeFragment.kt에 추가한다.

선택기 사용하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
reportButton.setOnClickListener {
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, getCrimeReport())
putExtra(Intent.EXTRA_SUBJECT, getString(R.string.crime_report_subject))
}.also { intent ->
// startActivity(intent)
val chooserIntent = Intent.createChooser(intent, getString(R.string.send_report))
startActivity(chooserIntent)
}
}

앱을 다시 실행해 범죄 리스트에서 한 항목을 선택한 후, 상세 내역 화면이 나타나면 ‘범죄 보고서 전송’ 버튼을 눌러보자. 선택기의 제목이 '범죄 보고서 전송’으로 나타나며, 인텐트를 처리할 수 있는 액티비티가 하나 이상이면 항상 앱 선택 리스트가 나타난다.

안드로이드에 연락처 요청하기

지금부터는 사용자가 자신의 연락처에서 용의자를 선택할 수 있게 또 다른 암시적 인텐트를 생성해본다. 이 암시적 인텐트는 액션 및 관련된 데이터를 찾을 수 있는 위치를 갖는다. 이때 액션은 Intent.ACTION_PICK이며, 연락처의 데이터는 ContactsContract.Contacts.CONTENT_URI에 있다. 요컨대 연락처 데이터베이스에서 한 항목을 선택할 수 있게 해달라고 안드로이드에 요청하는 것이다.

여기서는 인텐트로 시작된 액티비티로부터 결과(연락처 데이터)를 돌려받아야 한다. 따라서 startActivityForResult(...) 함수를 호출하면서 인텐트와 요청 코드를 인자로 전달해야 한다. 그리고 요청 코드의 상수와 ‘용의자 선택’ 버튼을 참조하는 속성을 CrimeFragment.kt에 추가한다.

‘용의자 선택’ 버튼을 참조하는 속성 추가하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
private const val REQUEST_DATE = 0
private const val REQUEST_TIME = 1
private const val REQUEST_CONTACT = 2 // 연락처 요청 코드의 상수
...

class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
private lateinit var reportButton: Button
private lateinit var suspectButton: Button // 버튼 참조 속성

그다음에 onCreateView(...)의 끝에서 ‘용의자 선택’ 버튼 객체의 참조를 얻고, onStart()에서 이 버튼의 클릭 리스너를 설정한다. 클릭 리스너 구현 코드에서는 연락처를 요청하는 암시적 인텐트를 생성해서 startActivityForResult(...)의 인자로 전달한다. 그리고 용의자가 선정되면 이 사람들의 이름을 ‘용의자 선택’ 버튼에 보여준다.

암시적 인텐트 전달하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
...
reportButton = view.findViewById(R.id.crime_report) as Button
suspectButton = view.findViewById(R.id.crime_suspect) as Button

return view
}

...

override fun onStart() {
...

reportButton.setOnClickListener {
...
}

suspectButton.apply {
val pickContactIntent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)

setOnClickListener {
startActivityForResult(pickContactIntent, REQUEST_CONTACT)
}
}
}
...
}

pickContactIntent는 잠시 후에 한번 더 사용하므로 OnClickListener의 외부에 선언하였다.

다음으로 용의자가 선정되었을 때 ‘용의자 선택’ 버튼에 텍스트를 설정하도록 updateUI()를 변경한다.

‘용의자 선택’ 버튼에 텍스트 설정하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
private fun updateUI() {
titleField.setText(crime.title)
dateButton.text = crime.date.toString()
solvedCheckBox.apply {
isChecked = crime.isSolved
jumpDrawablesToCurrentState()
}

if (crime.suspect.isNotEmpty()) {
suspectButton.text = crime.suspect
}
}

앱을 다시 실행해 범죄 리스트에서 한 항목을 선택한 후 상세 내역 화면이 나타나면 ‘용의자 선택’ 버튼을 눌러보자. 아래의 이미지와 같이 연락처 리스트가 나타난다.

용의자 선택을 위한 연락처 리스트

연락처 리스트에서 데이터 가져오기

이제 연락처 앱으로부터 결과를 돌려받아야 한다. 그런데 연락처 정보는 많은 앱이 공유한다. 따라서 안드로이드에서는 ContentProvider를 통해 연락처 데이터와 함께 작동하는 상세한 API를 제공한다. 이 API 클래스의 인스턴스들은 데이터베이스를 포함하며, 다른 앱에서 이 데이터베이스의 데이터를 사용할 수 있게 한다. ContentProviderContentReslover를 통해서 사용할 수 있다(연락처 데이터베이스의 자세한 내용은 Content Provider 기본 사항에서 Content Provider API 참고한다).

다음으로 연락처 앱으로부터 연락처의 이름을 가져오는 onActivityResult(...)를 CrimeFragment에 구현한다. 일단 코드를 작성한 후 하나씩 알아본다.

연락처의 이름 가져오기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...

private fun updateUI() {
...
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when {
requestCode != Activity.RESULT_OK -> return

requestCode == REQUEST_CONTACT && data != null -> {
val contactUri: Uri = data.data ?: return
// 쿼리에서 값으로 반환할 필드를 지정한다
val queryFields = arrayOf(ContactsContract.Contacts.DISPLAY_NAME)
// 쿼리를 수행한다. contactUri는 콘텐츠 제공자의 테이블을 나타낸다
val cursor = requireActivity().contentResolver
.query(contactUri, queryFields, null, null, null)
cursor?.use {
// 쿼리 결과 데이터가 있는지 확인한다
if (it.count == 0) {
return
}
// 첫 번째 데이터 행의 첫 번째 열의 값을 가져온다
// 이 값이 용의자의 이름이다
it.moveToFirst()
val suspect = it.getString(0)
crime.suspect = suspect
crimeDetailViewModel.saveCrime(crime)
suspectButton.text = suspect
}
}
}
}
...
}

위의 코드에서는 반환된 데이터에 있는 연락처의 모든 표시명(display name, 쉽게 말해 테이블의 열 이름)을 가져온다. 그리고 연락처 데이터베이스를 쿼리한 후 반환된 결과셋 result set의 행들을 읽는 데 사용할 Cursor 객체를 얻는다. 그다음에 커서가 최소한 한 행의 데이터를 갖고 있는지 확인한 후, Cursor.moveToFirst()를 호출해 첫 번째 행으로 커서를 이동시킨다. 그리고 Cursor.getString(Int)를 호출해 첫 번째 행의 첫 번째 열 값을 가져오며, 이때 이 값이 바로 용의자의 이름이다. 그다음에 이 값을 Crime 객체의 suspect 속성과 ‘용의자 선택’ 버튼의 text 속성에 설정한다.

여기서는 연락처 앱으로부터 용의자 이름을 받으면 그 즉시 범죄 데이터베이스의 Crime 테이블에 저장한다. 이렇게 해야 하는 이유는 다음과 같다.

  • CrimeFragment가 실행 재개 resumed 상태일 때는 onViewCreated(...) 함수가 호출되므로, 범죄 데이터베이스로부터 범죄 데이터를 쿼리하게 된다.
  • 그러나 onActivityResult(...)가 호출된 후에 onViewCreated(...)가 호출되므로 연락처 앱으로부터 받은 용의자 이름을 범죄 데이터베이스의 범죄 데이터(Crime 테이블의 suspect 열 값)로 덮어쓰게 된다.
  • 따라서 연락처 앱으로부터 받은 용의자 이름이 유실되지 않도록 범죄 데이터베이스에 저장해야 한다.

연락처 앱과 범죄 데이터가 있는 장치에서 앱을 다시 실행한다. 범죄 리스트가 나타나면 한 항목을 선택한 후 상세 내역 화면에서 ‘용의자 선택’ 버튼을 누른다. 그다음에 연락처 리스트에서 한 명을 선택하면 상세 내역 화면으로 돌아오면서 ‘용의자 선택’ 버튼에 해당 이름이 나타난다. 그리고 ‘범죄 보고서 전송’ 버튼을 누른 후 ‘메시지’ 앱을 선택하면, 해당 용의자 이름이 범죄 보고서 메시지에도 나타난다.

‘용의자 선택’ 버튼과 범죄 보고서 메시지에 나타난 용의자 이름

연락처 앱의 퍼미션

연락처 데이터베이스를 읽기 위한 퍼미션 permission은 어떻게 얻을까? 연락처 앱이 우리에게 퍼미션을 부여한다. 연락처 앱은 연락처 데이터베이스의 모든 퍼미션을 갖고 있으며, Intent의 데이터 URI를 부모 액티비티에 반환할 때 Intent.FLAG_GRANT_READ_URI_PERMISSION 플래그도 추가한다. 이 플래그는 안드로이드에게 앱의 부모 액티비티가 해당 데이터를 한번 읽는 것을 허용한다고 알린다. 여기서는 연락처 데이터베이스의 전체 데이터를 액세스할 필요가 없고 하나의 연락처 데이터만 필요하므로 퍼미션 문제는 없다.

응답하는 액티비티 확인하기

본문에서 생성했던 첫 번째 암시적 인텐트(범죄 보고서 전송)는 어떤 경우에도 항상 응답을 받는다. 안드로이드 장치에는 이런저런 종류의 메시지 전송 앱이 반드시 있기 때문이다. 그런데 연락처에서 용의자를 선택하기 위한 두 번째 암시적 인텐트에서는 다르다. 일부 사용자나 장치에는 연락처 앱이 없을 수 있기 때문이다. 따라서 이때는 문제가 되는데, 안드로이드 운영체제가 일치하는 액티비티를 찾을 수 없어 앱이 중단되기 때문이다.

이런 문제의 해결책은 onStart() 함수에서 안드로이드 운영체제의 일부인 PackageManager를 먼저 확인하는 것이다.

연락처 앱이 없을 때를 대비하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
override fun onStart() {
...

suspectButton.apply {
val pickContactIntent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)
setOnClickListener {
startActivityForResult(pickContactIntent, REQUEST_CONTACT)
}

val packageManager: PackageManager = requireActivity().packageManager
val resolvedActivity: ResolveInfo? =
packageManager.resolveActivity(pickContactIntent, PackageManager.MATCH_DEFAULT_ONLY)

if (resolvedActivity == null) {
isEnabled = false
}
}
}

PackageManager는 안드로이드 장치에 설치된 모든 컴포넌트와 이것들의 모든 액티비티를 알고 있다. 따라서 resolveActivity(Intent, Int) 함수를 호출하면 첫 번째 인자로 전달된 인텐트와 일치하는 액티비티를 찾도록 요청한다. 그리고 두 번째 인자로 우리가 원하는 플래그를 전달하면 이 플래그를 갖는 액티비티들만 찾는다. 여기서는 MATCH_DEFAULT_ONLY 플래그를 전달해 CATEGORY_DEFAULT가 매니페스트의 인텐트 필터에 정의된 액티비티들만 찾는데, startActivity(Intent)가 하는 것과 같다.

그리고 찾은 액티비티들이 있으면 이것들의 정보를 갖는 ResolveInfo 인스턴스가 반환되고, 찾지 못하면 null을 반환하므로 이때는 ‘용의자 선택’ 버튼이 작동하지 않도록 비활성화한다.

인텐트 필터의 검사가 제대로 되는지 알아보고 싶지만 연락처 앱이 없는 장치가 없을 수도 있다. 이때는 인텐트에 임의의 카테고리를 추가해 테스트하면 된다. 아래 코드에서는 CATEGORY_HOME 플래그를 인텐트의 카테고리로 추가했다. 이 카테고리는 인텐트와 일치하는 연락처 애플리케이션을 찾지 못하게 일부러 추가한 것이다.

인텐트 필터 테스트 코드 추가하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
override fun onStart() {
...
suspectButton.apply {
...
// CATEGORY_HOME 플래그를 인텐트의 카테고리로 추가
pickContactIntent.addCategory(Intent.CATEGORY_HOME)
val packageManager: PackageManager = requireActivity().packageManager
...
}
}

앱을 다시 실행해 범죄 리스트에서 한 항목을 선택하면 상세 내역 화면의 ‘용의자 선택’ 버튼이 비활성화된 것을 확인할 수 있다.

비활성화된 ‘용의자 선택’ 버튼

댓글