안드로이드 4대 컴포넌트 - Content Provider_1(content provider?)

권민주·2024년 11월 4일

안드로이드

목록 보기
9/23
post-thumbnail

안드로이드에는 필수적인 앱 기본 구성 요소로 activity, service, broadcast receiver, content provider 4가지를 가집니다. 그 중 하나인 content provider에 대해 설명하겠습니다.


1. Content Provider란

1)Content provider?

Content provider는 파일 시스템, SQLite 데이터베이스, 웹 및 앱의 기타 영구 저장소에 저장된 데이터에 대한 접근을 관리하고 공유하는 방법을 제공합니다. 안드로이드 프레임워크에서 오디오, 비디오, 이미지뿐만 아니라 개인 연락처 정보와 같은 데이터를 관리합니다. Android.provider 패키지의 참조 문서에 provider의 일부 종류가 나열되어 있습니다. 참조 문서 사이트는 다음과 같습니다.

https://developer.android.com/reference/android/provider/package-summary

Content provider는 ContentProvider의 서브클래스로 구현되며, 다른 앱이 트랜잭션을 수행할 수 있도록 표준 API 세트를 구현해야 합니다.

안드로이드에서 제공하는 content provider도 있지만 다음과 같은 경우에는 자체 content provider가 필요합니다

  • 사용자 정의 추천 검색어 구현
  • 위젯에 앱 데이터 표시
  • 복잡한 데이터나 파일을 다른 앱으로 복사/붙이기

2)접근 관리

데이터의 보안를 정의하는 메커니즘과 캡슐화를 제공하고 애플리케이션의 중앙 데이터 저장소 계층에 대한 접근을 조정합니다. 데이터를 공유할 때 많이 사용됩니다. 일반적으로 다른 앱에 존재하는 content provider에 접근하거나 다른 앱이 content provider에 접근하는 두 가지 시나리오 중 하나를 가지기 때문입니다. 주로 provider 클라이언트 개체를 사용하여 다른 앱의 provider에 접근합니다. Provider와 provider 클라이언트는 함께 프로세스 간 통신과 안전한 데이터 접근을 처리하는 일관된 표준 인터페이스를 데이터에 제공합니다.

데이터를 공유하지 않는경우에도 사용할 수 있습니다. 앱의 데이터 저장소 구현을 수정해도 해당 앱의 데이터 접근에 의존하는 앱에 영향을 주지 않는 추상화를 제공하기 때문이죠. 이 경우, content provider만 영향을 받고 content provider에 접근하는 앱은 영향 받지 않습니다.
예를 들어, 그림과 같이 SQLite 데이터베이스를 대체 스토리지로 교체할 수 있습니다. Content provider에는 API와 지원이 많이 내장되어 있기 때문에 주로 데이터베이스의 추상화로 생각할 수 입니다. 그러나 content provider는 시스템 설계 관점에서 데이터베이스와 다른 핵심 목적을 가지고 있습니다.

또한 외부로 데이터를 공유할 때, 하나 이상의 테이블 모습인데 이는 관계형 데이터베이스 테이블과 유사한 형태입니다. 행은 provider가 수집하는 데이터 유형의 인스턴스를 나타내며, 행의 각 열은 인스턴스에 대해 수집된 개별 데이터 조각을 나타냅니다.

이러한 provider는 몇 가지 제한 사항을 제외하고, 모든 Android 애플리케이션에 접근할 수 있습니다. Content provider는 SQLite 관계형 데이터베이스와 같은 구조화된 데이터 또는 이미지 파일과 같은 비구조화된 데이터를 포함한, 다양한 데이터 저장 소스에 대한 접근 관리에 사용됩니다.

Content provider를 사용하면 애플리케이션의 다양한 데이터 소스에 접근하기 위한 세부 정보를 추상화할 수 있습니다. 예를 들어, 애플리케이션은 비디오 및 오디오 파일뿐만 아니라 SQLite 데이터베이스같은 구조화된 레코드를 저장할 수 있습니다. Content provider를 사용하면 이 모든 데이터에 간단히 접근할 수 있습니다.

3)접근 권한

Content provider는 데이터 접근 권한에 대한 세분화된 제어를 제공합니다. 응용 프로그램 내에 있는 content provider에만 접근를 제한하거나, 다른 응용 프로그램의 데이터에 접근할 수 있는 포괄적인 권한을 부여하거나, 데이터 읽기 및 쓰기에 대한 다양한 권한을 구성하도록 선택할 수 있습니다.

권한이 부여된다면, content provider를 통해 다른 앱이 데이터를 쿼리하거나 수정할 수 있습니다. 그림과 같이, 앱에 다른 앱이 데이터에 안전하게 접근하고 수정할 수 있도록 content provider를 구성합니다.

예를 들어 Android 시스템은 사용자의 연락처 정보를 관리하는 content provider를 제공합니다. 해당 권한을 가진 모든 앱은 ContactsContract.Data 를 사용하여 특정 사람에 대한 정보를 읽고 쓸 수 있도록 content provider에 query 할 수 있습니다. Content provider는 한 프로세스의 데이터와 다른 프로세스에서 실행 중인 코드를 연결하는 표준 인터페이스입니다.

4)Content Resolver

Content provider의 데이터에 접근하려는 경우, 애플리케이션의 context에서 클라이언트인 content resolver 개체가 content provider를 구현한 클래스의 인스턴스인 provider 개체와 통신합니다. Resolver는 이러한 provider와 동일한 이름의 메소드를 호출합니다. 그럼 provider는 클라이언트로부터 데이터 요청을 받고 요청된 작업을 수행한 다음 결과를 반환합니다. ContentResolver 메서드는 영구 스토리지의 기본적인 CRUD(생성, 검색, 업데이트 및 삭제) 기능을 제공합니다.

UI에서 content provider에 접근하는 일반적인 패턴은 Cursor Loader를 사용하여 백그라운드에서 비동기 쿼리를 실행하는 겁니다. UI의 activity 또는 framgnet은 쿼리에 cursor loader를 호출하고, cursor loader는 content resolver를 사용하여 content provider를 가져옵니다. 이를 통해 쿼리가 실행되는 동안에도 사용자는 UI를 계속 사용할 수 있습니다. 이 패턴은 그림에 표시된 것처럼 기본 저장 메커니즘뿐만 아니라 여러 가지 다른 객체의 상호 작용을 만듭니다.

wordapp idfrequency
mapreduceuser1100
precompileruser14200
appletuser2225
constuser1255
intuser5100

Android 플랫폼에 내장된 provider 중 하나인 사용자가 보관하고자 하는 비표준 단어를 저장하는 사용자 사전 provider입니다. 표는 이 provider 테이블의 데이터 모습을 보여줍니다. 표에서 각 행은 표준 사전에 없는 단어의 인스턴스를 나타냅니다. 각 열은 해당 단어에 대한 데이터의 한 부분을 나타내며, 단어의 locale 같은 정보를 포함합니다. 열 헤더는 provider에 저장된 열 이름입니다. 예를 들어 locale 행을 참조하려면 locale을 참조해야 합니다. 이 provider에서 _ID 열은 자동으로 provider가 유지 관리를 해주는 기본 키 역할을 합니다.

사용자 사전 provider에서 단어와 단어의 위치 목록을 얻으려면 ContentResolver.query()를 호출합니다. query() 메서드는 사용자 사전 provider에서 정의한 ContentProvider.query() 메서드를 호출합니다.
다음 코드 줄에는 ContentResolver.query() 호출을 보여줍니다.

// UserDictionary 결과 쿼리
cursor = getContentResolver().query(
    UserDictionary.Words.CONTENT_URI, // 단어 table의 content URI
    projection,                       // 행에서 반환할 열만을 선택    
    selectionClause,                  // 선택 기준
    selectionArgs,                    // 선택 기준
    sortOrder);                       // 반환할 열의 정렬 순서

아래의 표는 query(Uri,projection,selection,selectionArgs,sortOrder) 의 각 매개변수가 SQL SELECT 절에 어떻게 대응되는지 보여줍니다.

query()
argument
SELECT
keyword/parameter
Notes
UriFROM table_nameprovider의 table_name
테이블에 매핑
projectioncol,col,col,...검색된 행에 포함할
열의 배열
selectionWHERE col = value행을 선택하는 기준을 지정
selectionArgs정확히 동등한 표현이 없음.
selection 절의 ? 자리 표시자를 대체.
LIKE연산자에 사용하는 조건과 비슷.
sortOrderORDER BY col,col,...반환된 Cursor의 행이 표시되는 순서 지정

5)URI

콘텐츠 URI는 provider의 데이터를 식별하는 URI으로 데이터를 접근하게 해주는 앱이 진입점 역할을 합니다. 따라서 앱의 데이터는 URI 체계에 따라 매핑되며 이 URI를 외부에 제공하고 해당 URI를 통해 데이터에 접근합니다. 콘텐츠 URI에는 provider의 식별 이름(권한)과 테이블을 가리키는 이름(경로)이 포함됩니다. Provider 테이블에 접근할 때, 테이블의 콘텐츠 URI가 클라이언트 메서드의 인수로 사용됩니다.

URI는 시스템이 앱을 관리하는 데 몇 가지 특별한 기능을 제공합니다

  • URI 할당은 앱의 지속적 실행이 필요 없으므로, URI는 해당 앱이 종료되어도 유지. 시스템은 URI에 대한 데이터를 검색할 때 해당 앱이 여전히 실행 중인지만 확인.
  • URI는 중요하고 세밀한 보안 모델을 제공. 예를 들어, 앱은 클립보드에 이미지의 URI를 둘 수 있지만, content provider는 해당 URI를 잠금 상태로 유지. 다른 앱이 자유롭게 접근할 수 없도록 관리. 다른 앱이 클립보드에 있는 URI에 접근하려고 할 때, 시스템은 해당 앱이 임시적으로 접근하도록 임시 URI 권한 부여. 해당 데이터 외는 접근 불가능.

위에서 사용한 예제 코드에서 상수 UserDictionary.Words.CONTENT_URI는 사용자 사전 provider 단어 테이블의 콘텐츠 URI를 의미합니다. ContentResolver 개체는 URI의 권한을 분석하고 이를 통해 시스템에 등록된 provider와 비교합니다. 해당 provider를 찾으면 ContentResolver의 쿼리를 전달합니다. Content provider는 콘텐츠 URI의 경로 부분을 사용하여 접근할 테이블을 선택합니다. Provider는 일반적으로 각 테이블에 대한 경로를 제공합니다.

이전 코드에서 단어 테이블의 전체 URI는 다음과 같습니다.
content://user_dictionary/words

  • content:// 은 필수 구성 요소로 content URI임을 식별해주는 scheme.
  • user_dictionary 는 제공자 권한.
  • words 은 table 경로.

많은 provider가 URI의 끝에 ID 값을 추가하여 테이블의 단일 행에 접근하게 합니다. 예를 들어, 사용자 사전 provider에서 ID가 4인 행을 검색하려면 코드는 다음과 같습니다.
Uri singleUri = ContentUris.withAppendedId(UserDictionary.Words.CONTENT_URI,4);

행을 검색한 다음, 행 중 하나를 업데이트하거나 삭제할 때 ID 값을 많이 사용합니다. URI 및 URI.Builder 클래스에는 문자열로부터 올바른 형식의 URI 객체를 구성해주는 편리한 메서드를 제공합니다. 그 중 ContentUris 클래스에는 ID 값을 URI에 추가해주는 편의 메서드가 포함되어 있습니다. 위 코드에서 withAppendId()는 사용자 사전 provider 콘텐츠 URI에 ID를 추가합니다. ID가 4인 정보를 URI 주소에 추가하여 테이블 자체를 가리키는 주소에서 ID가 4인 행을 가리키는 주소로 확장하는 겁니다.

6)Provider 다른 컴포넌트 관계

Provider는 다양한 API와 구성 요소를 제공합니다. 다음같은 내용이 포함됩니다.

  • 애플리케이션 데이터에 대한 접근 권한을 다른 애플리케이션과 공유
  • 위젯으로 데이터 송신
  • SearchRecentSuggesProvider를 사용하여 검색 프레임워크를 통해, 응용 프로그램에 대한 사용자 정의 검색 추천어 반환
  • AbstractThreedSyncAdapter 구현을 사용하여 애플리케이션 데이터를 서버와 동기화
  • CursorLoader를 사용하여 UI에 데이터 로드

다음의 클래스들은 Content provider 클래스 구현이 필요합니다.

  • AbstractThreadedSyncAdapter
    동기화 작업 호출을 위해 스레드를 생성해주는 SyncAdapter의 추성 클래스. 기존 작업이 없으면 새로운 스레드가 생성. 기존 작업이 있다면 새로운 요청은 오류로 반환하고 기존 요청 진행. 여기서 content provider가 동기화 작업에서 데이터를 관리를 제공.
  • CursorAdapter
    Cursor의 데이터를 ListView에 표시하는걸 도와주는 Adapter. Content provider를 통해 cursor를 받는 경우가 일반적이기에 콘텐츠 제공자 구현이 필요.

    ❓❓❓
    Cursor는 데이터베이스 쿼리로 반환되는 데이터에 대한 임시 읽기/쓰기 접근을 제공해주는 인터페이스. 이때 데이터는 특정 데이터 리스트가 아닌 특정 행을 의미. 즉, 특정 행을 가리키는 포인터 역할. 쿼리 결과로 여러 행이 반환된다면 현재 위치인 첫 번째행을 가리킴. 그렇기에 메서드를 이용하여 이전/이후 행으로 이동 가능.

  • CursorLoader
    Content provider로부터 Cursor를 반환하는 loader. AsyncTaskLoader를 기반으로 cursor를 쿼리하기 위한 표준 Loader 프로토콜를 구현. 앱의 UI를 차단하지 않도록 백그라운드 스레드에서 실행되어 비동기적으로 cursor 반환.

이러한 클래스를 사용하는 앱에는 content provider를 구현해야 합니다. AbstractThreadedSyncAdapter와 CursorLoader 같은 동기화 adpater 프레임워크로 작업할 때는 테스트나 개발 초기에 content provider 대신 stub content provider를 사용할 수도 있습니다. 또한 CursorLoader 개체는 content provider에 의존하여 비동기 쿼리를 실행한 다음 그 결과를 응용 프로그램의 UI 계층으로 반환합니다.

❓❓❓
Stub content providers는 동기화 adapter 프레임워크의 content provider 구현을 위한 임시적인 content provider입니다. 개발초기나 테스트 단계에서 실제 데이터가 없거나 데이터 소스 구현이 되지 않는 경우에 사용됩니다. 즉, 앱과 provider가 데이터로 상호작용하는 것처럼 작동하게 합니다. 이를 통해 데이터를 전송하기 위한 동기화 adapter를 사용할 수 있게 합니다. stub provider는 content provider 클래스를 구현하지만, 모든 필수 메서드는 null 또는 0을 반환합니다. 쿼리나 업데이트 같은 메스드는 있지만 실제로 처리는 안 되는 겁니다.

2. Provider에서 데이터 검색

위의 설명에 이어서 사용자 사전 provider를 예로 provider에서 어떻게 데이터를 검색하는지 알아봅니다. 크게 두가지로 provider의 읽기 접근 권한 요청과 provider로 쿼리 전송 방법으로 나뉩니다. provider 예시에 대한 명확성을 위해 이 글의 코드는 UI 스레드에서 ContentResolver.query()를 호출합니다. 실제 코드에서는 별도의 스레드에서 비동기식으로 쿼리를 수행해야 합니다. 또한 예시의 한 부분일 뿐 앱에 사용할 수 있는 완전한 코드는 아닙니다.

1) 읽기 접근 권한 요청

provider부터 데이터를 검색하려면 애플리케이션에서 provider에 대한 읽기 접근 권한이 필요합니다. 런타임에 이 권한을 요청할 수 없습니다. 대신 <uses-permission> 요소와 provider가 정의한 정확한 권한 이름을 사용하여 manifest에 이 권한이 필요하다는 것을 지정해야 합니다. manifest에서 이 요소를 지정하면 응용 프로그램에 대해 이 권한을 요청하는 것입니다. 사용자는 응용 프로그램을 설치할 때 암묵적으로 이 요청을 승인합니다.

사용 중인 provider의 읽기 접근 권한의 정확한 이름과 provider가 사용하는 다른 접근 권한의 이름을 찾으려면 해당 공급자의 설명서를 참조하면 됩니다. 사용자 사전 provider는 manifest 파일에 권한 Android.permission.READ_USER_DICONARY를 정의하므로 provider에서 읽으려는 애플리케이션은 이 권한을 요청해야 합니다.

2)쿼리 구성

다음 단계는 provider에서 데이터를 검색하는 쿼리를 구성하는 것입니다. 다음 코드는 사용자 사전 provider에 접근하기 위한 몇 가지 변수를 정의합니다.

// projection는 행에서 반환할 열만을 선택에 대해 정의
String[] mProjection =
{
    UserDictionary.Words._ID,    //_ID 열 이름에 대한 Contract class
    UserDictionary.Words.WORD,   // 단어 열 이름에 대한 Contract class
    UserDictionary.Words.LOCALE  // locale 열 이름에 대한 Contract class
};
// 선택 절을 저장할 문자열 정의
String selectionClause = null;
//선택 인수를 저장할 배열 초기화
String[] selectionArgs = {""};

다음 코드는 사용자 사전 provider를 예로 들어 ContentResolver.query()를 사용하는 방법을 보여줍니다. Provider 클라이언트 쿼리는 SQL 쿼리와 유사하며 반환할 열 집합, 선택 기준 및 정렬 순서를 포함합니다. 변수는 mProjection는 쿼리가 반환하는 열 집합인 프로젝션입니다. 검색할 행을 지정하는 식은 선택 절과 선택 인수로 나뉩니다. 선택 절 변수는 mSelectionClause이며 논리 및 boolean식, 열 이름 그리고 값의 조합입니다. 열 이름은 검색 조건에 해당하는 열이고 값은 열과 비교할 값입니다. 논리 및 boolean 식은 열과 값을 비교할 식입니다. 이때 값은 실제 값 대신 대체 가능한 매개 변수 ?를 지정합니다. 쿼리 메서드가 선택 인수 배열인 변수 mSelectionArgs에서 값을 가져옵니다. 배열 형태인 이유는 ?가 여러 개, 즉 조건이 여러 가지일 수도 있기 때문입니다.

다음 코드에서는 사용자가 단어를 입력하지 않으면 선택 절이 null로 설정되고 쿼리는 provider의 모든 단어를 반환합니다. 사용자가 단어를 입력하면 선택 절은 UserDictionary.WORD + "=?"로 설정되며 선택 인수 배열의 첫 번째 요소는 사용자가 입력한 단어로 설정됩니다.

//선택 인수를 저장할 단일 요소 문자열 배열을 정의
String[] selectionArgs = {""};
// UI에서 얻은 단어
searchString = searchWord.getText().toString();
//유효하지 않거나 악의적인 입력을 방지할 코드 입력 해야됨.
//만약 단어가 비어있으면 전체를 반환
if (TextUtils.isEmpty(searchString)) {
   //모든 단어를 반환하기 위해 선택절에 null로 설정
    selectionClause = null;
    selectionArgs[0] = "";
} else {
    //사용자가 입력한 단어와 일치 확인하는 선택 절 구성
    selectionClause = UserDictionary.Words.WORD + " = ?";
    // 사용자의 입력 문자열을 선택 인수 이동
    selectionArgs[0] = searchString;
}
//테이블에 대해 쿼리를 수행하고 커서 개체를 반환
mCursor = getContentResolver().query(
    UserDictionary.Words.CONTENT_URI, // 단어 table의 content URI
    projection,                       //행에서 반환할 열
    selectionClause,                  // null 또는 사용자가 입력한 단어
    selectionArgs,                    // 비어 있거나 사용자가 입력한 문자열
    sortOrder);                       // 반환된 행의 정렬 순서
//오류가 발생하면 제공자에 따라 null 또는 예외를 반환하기에 이에 따른 예외 처리
if (null == mCursor) {
     //... 오류처리 코드 ...
     //오류 발생에 대비에 Cursor은 사용 금지
     //Android.util.Log.e() 호출하여 오류 기록 가능
// 커서가 비어 있으면 제공자는 일치하는 내용을 찾을 수 없음
} else if (mCursor.getCount() < 1) {
  // ...검색이 실패했음을 사용자에게 알리는 코드 입력...
 /*꼭 오류 발생 의미 아님.  사용자에게 새 행을 삽입하거나 
  검색어를 다시 입력할 수 있는 옵션을 제공 가능*/
} else {
    // ... 결과 처리 코드...
}

이 쿼리는 다음 SQL 문과 유사합니다:

SELECT _ID, word, locale FROM words WHERE word = <userinput> ORDER BY word ASC;

이 SQL 문에서는 contract class 상수 대신 실제 열 이름이 사용됩니다.

3)쿼리 결과 표시

클라이언트 메서드인 ContentResolver.query()는 항상 쿼리의 프로젝션에서 지정되고 선택 기준에 맞는 열을 포함한 커서를 반환합니다. Cursor 객체는 포함된 행과 열에 대한 임의의 읽기 접근을 제공합니다. Cursor 메서드를 사용하면 결과 행을 반복하고, 각 열의 데이터 유형을 확인하며, 열에서 데이터를 가져오고, 결과 값의 다른 속성을 확인할 수 있습니다. 일부 cursor 구현은 provider의 데이터가 변경될 때 자동으로 cursor 객체를 업데이트하거나 cursor가 변경될 때 observer 객체의 메서드를 트리거합니다. 혹은 둘 다 수행할 수 있습니다.

Provider는 쿼리를 수행하는 개체의 특성에 따라 열에 대한 접근을 제한할 수 있습니다. 예를 들어, 연락처 provider는 동기화 adapter에 대해 일부 열의 접근을 제한하므로 해당 열을 activity이나 service로 반환되지 않습니다.

선택 기준과 일치하는 행이 없는 경우 provider는 Cursor.getCount()가 0인 cursor 개체, 즉 빈 cursor를 반환합니다. 내부 오류가 발생하면 특정 provider에 따라 쿼리 결과가 달라집니다. null을 반환하거나 예외가 발생합니다. Cursor는 행 목록이므로 cursor의 내용을 표시하는 좋은 방법은 SimpleCursorAdapter를 사용하여 ListView에 연결하는 것입니다.

다음은 이전 코드를 계속합니다. 쿼리에서 검색한 cursor가 포함된 SimpleCursorAdapter 개체를 생성하고 이 개체를 ListView의 어댑터로 설정합니다.

// Cursor에서 검색할 열과 반환할 행 리스트 정의
String[] wordListColumns =
{
    UserDictionary.Words.WORD,   // 단어 열 이름 Contract class 상수
    UserDictionary.Words.LOCALE  //  locale 열 이름 Contract class 상수
};
// 각 행의 커서 열을 받을 view ID 목록 정의
int[] wordListItems = { R.id.dictWord, R.id.locale};
// SimpleCursorAdapter 생성
cursorAdapter = new SimpleCursorAdapter(
    getApplicationContext(),               // 앱의 Context 객체
    R.layout.wordlistrow,                  // ListView에 사용할 행 xml 레이아웃
    mCursor,                               // 쿼리 결과
    wordListColumns,                       // cursor의 열 이름 담는 문자 배열
    wordListItems,                         // 행 레이아웃 view ID 담는 integer 배열
    0);                                    // 데이터 갱신 Flags(잘 사용 안함)
// ListView에 adapter 설정
wordList.setAdapter(cursorAdapter);

ListView에 cursor를 사용하려면, cursor에 _ID라는 이름의 열이 포함되어야 합니다. 이 때문에 이전에 보여준 쿼리는 단어 테이블에 대한 _ID 열을 검색하며, 비록 ListView에서 이를 표시하지 않더라도 필요합니다. 이러한 제한은 대부분의 provider가 각 테이블에 _ID 열을 갖는 이유를 설명합니다.

4)쿼리 결과에서 데이터 얻기

쿼리 결과는 표시 외 다른 작업에도 사용할 수 있습니다. 예를 들어, 사용자 사전 provider에서 검색한 단어 철자를 다른 provider에서 조회할 수 있습니다. 이를 위해 다음 예시와 같이 Cursor의 행을 반복해서 처리합니다.

//열 이름이 "word"인 index얻기
int index = mCursor.getColumnIndex(UserDictionary.Words.WORD);
/*
 * cursor가 유효할 때 만 실행. 사용자 사전 Provider는 내부적 오류가 발생하면 null 반환.
 * 다른 provider는 null대신 오류를 반환할 수도 있음.
 */
if (mCursor != null) {
    /*
     * cursor를 다음 행으로 이동. cursor를 처음으로 이동하기 전에는 "row pointer"가 -1
     *  만약 -1일 때 데이터 검색하면 오류 발생
     */
    while (mCursor.moveToNext()) {
        // 해당 열에서 값 얻기
        newWord = mCursor.getString(index);
        // 이 부근에 단어 검색 process 코드 삽입
        ...
        // while loop 끝
    }
} else {
    // 커서가 null이거나 제공자가 예외를 던진 경우 오류 알릴 코드 삽입
}

Cursor 구현에는 cursor 개체에서 다양한 유형의 데이터를 검색하기 위한 여러 가지 "get" 메서드가 포함되어 있습니다. 예를 들어, 위의 코드는 getString()을 사용합니다. 또한 열의 데이터 유형 값을 반환하는 getType()도 있습니다.

5)query resource 해제

Cursor 개체가 더 이상 필요하지 않은 경우, 닫아야 연결된 리소스가 더 빨리 해제됩니다. 이 작업은 close()를 호출하거나 자바 프로그래밍 언어의 try-with-resources 절 또는 코틀린 프로그래밍 언어의 use() 함수를 사용하여 수행할 수 있습니다.

6)악의적 입력 방지

Content provider가 관리하는 데이터가 SQL 데이터베이스에 있는 경우, 신뢰할 수 없는 외부 데이터를 원시 SQL 문에 포함하면 SQL 주입이 발생할 수 있습니다.

// 사용자의 입력을 열 이름에 연결하여 선택 절을 구성
String selectionClause = "var = " + userInput;

이렇게 하면 사용자가 잠재적으로 악의적인 SQL을 SQL 문에 연결할 수 있습니다. 예를 들어, 사용자는 mUserInput에 nothing, DOP TABLE *;을 입력하면 선택 절은 var =nothing, DOP TABLE *; 이 됩니다. 선택 절은 SQL 문으로 취급되므로 SQL 주입 방지 코드가 없으면 provider가 기본 SQLite 데이터베이스의 모든 테이블을 지우게 할 수 있습니다. 이 문제를 방지하려면 ?를 대체 가능한 매개 변수로 사용하는 선택 절과 별도의 선택 인수 배열을 사용해야 합니다. 이렇게 하면 사용자 입력이 SQL 문의 일부로 해석되지 않으면서 쿼리에 직접 바인딩됩니다. SQL로 처리되지 않기 때문에 사용자 입력은 악의적인 SQL을 주입할 수 없습니다. 다음과 같이 해결할 수 있습니다.

// 대체 가능한 매개 변수로 선택 절을 구성
String selectionClause =  "var = ?";

선택 인수 배열을 다음과 같이 설정합니다.

// 선택 인수를 정의할 배열를 생성
String[] selectionArgs = {""};

선택 인수에 다음과 같이 값을 입력합니다.

//선택 인수에 사용자 입력 설정
selectionArgs[0] = userInput;

?를 대체 가능한 매개변수로 사용하는 선택 절과 선택 인수 배열을 사용하는 것이 바람직한 선택 방법입니다. 이는 provider가 SQL 데이터베이스에 기반하지 않더라도 마찬가지입니다.

3. Content Provider 권한

Provider 앱은 다른 앱이 자신의 데이터에 접근하는데 필요한 권한을 지정할 수 있습니다. 이러한 권한을 통해 사용자는 앱이 어떤 데이터에 접근하려는지 알 수 있습니다. Provider의 요구 사항에 따라 다른 앱은 provider에 접근하는 데 필요한 권한을 요청합니다. 최종 사용자는 앱을 설치할 때 요청된 권한을 확인합니다. Provider의 응용 프로그램에 권한이 지정되어 있지 않은 경우, provider를 export 설정하지 않는 한 다른 응용 프로그램은 provider의 데이터에 접근할 수 없습니다. 또한 provider 응용 프로그램의 구성 요소는 지정된 권한에 관계없이 항상 전체 읽기 및 쓰기 접근 권한을 갖습니다.

사용자 사전 provider는 데이터를 검색하려면 Android.permission.READ_USER_Dictionary 권한이 필요합니다. 또한 데이터 삽입, 업데이트 또는 삭제를 위한 Android.permission.WRITE_USER_Dictionary 권한도 가집니다.

Provider에 접근하는 데 필요한 권한을 얻으려면 애플리케이션이 manifest 파일에 <uses-permission> 요소를 포함하여 요청합니다. Android 패키지 관리자가 애플리케이션을 설치할 때 사용자는 애플리케이션이 요청하는 모든 권한을 승인해야 합니다. 사용자가 승인하면 패키지 관리자는 설치를 계속합니다. 사용자가 승인하지 않으면 패키지 관리자는 설치를 중지합니다.

4.데이터 삽입,업데이트,삭제

Provider로부터 데이터를 검색하는 방식과 동일하게, 데이터 수정도 클라이언트와 ContentProvider 간 상호작용을 통해 이루어집니다. ContentResolver 메서드를 호출할 때 전달한 인수가 ContentProvider의 해당 메서드에 전달됩니다. Provider와 provider 클라이언트는 보안과 프로세스 간 통신을 자동으로 처리합니다.

1)데이터 삽입

Provider에 데이터를 삽입하려면 ContentResolver.insert() 메서드를 호출합니다. 이 메서드는 provider에 새 행을 삽입하고 해당 행의 콘텐츠 URI를 반환합니다.

//삽입 결과로 받을 uri 객체 정의
Uri newUri;
...
//삽입할 값을 저장할 객체 정의
ContentValues newValues = new ContentValues();
/*
 * 각 열의 값을 설정하고 단어 삽입
 * "put" method의 매개변수는 "열 이름"과 "값"
 */
newValues.put(UserDictionary.Words.APP_ID, "example.user");
newValues.put(UserDictionary.Words.LOCALE, "en_US");
newValues.put(UserDictionary.Words.WORD, "insert");
newValues.put(UserDictionary.Words.FREQUENCY, "100");
newUri = getContentResolver().insert(
    UserDictionary.Words.CONTENT_URI,   //사용자 사전 content URI
    newValues                          // 삽입할 값
);

새 행의 데이터는 한 줄 형태의 Cursor와 유사한 단일 ContentValues 개체로 삽입됩니다. 이 개체의 열은 여러 타입을 지원합니다. 예를 들어 위의 코드는 문자열 타입이지만 정수 타입에서도 사용할 수 있습니다. 값을 전혀 지정하지 않으려면 ContentValues.putNull()을 사용하여 열을 null로 설정할 수 있습니다.

위 코드에서 _ID 열이 자동으로 관리되므로 _ID 열을 추가하지 않습니다. Provider는 추가되는 모든 행에 고유한 _ID 값을 할당합니다. Provider는 일반적으로 _ID 값을 테이블의 기본 키로 사용합니다. 새로운 URI로 반환된 콘텐츠는 새로 추가된 행을 다음과 같은 형식으로 식별합니다

content://user_dictionary/words/<id_value>
<id_value>는 새 행에 대한 _ID의 내용입니다. 대부분의 provider는 이러한 형식의 콘텐츠 URI를 자동으로 인식하고 해당 행에 대해 요청된 작업을 수행할 수 있습니다. 반환된 Uri에서 _ID 값을 얻으려면 ContentUris.parseId()를 호출합니다.

2)데이터 업데이트

행을 업데이트하려면 삽입과 마찬가지로 ContentValues 객체에 업데이트된 값을 설정하고, 쿼리처럼 선택 기준을 사용합니다. 이때 사용하는 클라이언트 메서드는 ContentResolver.update()입니다. ContentValues 객체에는 업데이트할 열에 대한 값만 추가하면 됩니다. 열의 내용을 지우려면 값을 null로 설정합니다. 다음 코드는 언어가 "en"인 모든 행의 locale를 null로 변경합니다. 반환 값은 업데이트된 행의 개수를 나타냅니다.

//업데이트할 값을 넣을 객체 정의
ContentValues updateValues = new ContentValues();
// Defines selection criteria for the rows you want to update
//업데이트를 원하는 행의 선택 기준 정의
String selectionClause = UserDictionary.Words.LOCALE +  " LIKE ?";
String[] selectionArgs = {"en_%"};
//업데이트된 행의 수를 저장할 변수 정의
int rowsUpdated = 0;
...
 //업데이트된 값을 설정하고 선택한 단어를 업데이트
updateValues.putNull(UserDictionary.Words.LOCALE);
rowsUpdated = getContentResolver().update(
    UserDictionary.Words.CONTENT_URI,  // 사용자 사전 content URI
    updateValues,                      //  업데이트할 열
    selectionClause,                   // 선택 기준이 될 열
    selectionArgs                      // 선택 기준과 비교할 값
);

ContentResolver.update() 호출할 때 악의적 입력을 조심해야 합니다.

3)데이터 삭제

행 삭제는 행 데이터를 검색하는 것과 비슷합니다. 삭제할 행에 대한 선택 기준을 지정하면 클라이언트 메서드는 삭제된 행의 수를 반환합니다. 다음 코드는 앱 ID가 "user"와 일치하는 행을 삭제합니다. 이 메서드는 삭제된 행의 수를 반환합니다.

//삭제하고픈 행 선택 기준 정의
String selectionClause = UserDictionary.Words.APP_ID + " LIKE ?";
String[] selectionArgs = {"user"};
//삭제될 열 수를 저장할 변수 정의
int rowsDeleted = 0;
...
//선택 기준와 일치하는 단어 삭제
rowsDeleted = getContentResolver().delete(
    UserDictionary.Words.CONTENT_URI,  // 사용자 사전 content URI
    selectionClause,                   // 선택 기준이 될 열
    selectionArgs                      // 선택 기준과 비교할 값
);

ContentResolver.delete() 호출할 때 악의적 입력을 조심해야 합니다.

5.Provider 데이터 타입

Content provider는 다양한 데이터 유형을 제공합니다. 사용자 사전 provider는 텍스트만 제공하지만 provider는 다음 형식도 제공할 수 있습니다:

  • integer
  • long integer (long)
  • floating point
  • long floating point (double)

Provider가 자주 사용하는 또 다른 데이터 유형은 64KB 바이트 배열로 구현된 바이너리 대형 객체, BLOB 입니다. 용량이 크기에 주로 이미지나 오디오같은 미디어 객체에 사용됩니다. 사용 가능한 데이터 유형은 Cursor 클래스 "get" 메서드를 보면 알 수 있습니다.

Provider의 각 열의 데이터 유형은 일반적으로 설명서에 나열됩니다. 사용자 사전 provider의 데이터 유형은 계약 클래스인 UserDictionary.Words의 참조 설명서에 나열되어 있습니다. 해당 설명서 주소는 다음과 같습니다.

https://developer.android.com/reference/android/provider/UserDictionary.Words

Cursor.getType()을 호출하여 데이터 유형을 확인할 수도 있습니다.

또한 provider는 정의한 각 콘텐츠 URI에 대한 MIME 데이터 유형 정보를 유지 관리합니다. MIME 유형 정보를 사용하여 provider가 제공하는 데이터를 응용 프로그램에서 처리할 수 있는지 확인하거나 MIME 유형에 따라 처리 방식을 선택할 수 있습니다. 예를 들어 MIME 유형이 이미지일 경우 앱에서 이미지를 처리 여부를 확인하고 이미지를 표시할 방법을 처리합니다. 또는 연락처 provider의 ContactsContract.Data 테이블은 MIME 유형을 사용하여 각 행에 저장된 연락처 데이터의 유형을 MIME 타입으로 구분합니다. 콘텐츠 URI에 해당하는 MIME 유형을 얻으려면 ContentResolver.getType()을 호출합니다. 일반적으로 복잡한 데이터 구조나 파일이 포함된 provider와 함께 작업할 때는 MIME 유형이 필요합니다.

6.다른 형식의 provider 접근 방법

앱 개발에서 중요한 세 가지 대안적인 provider 접근 방식이 있습니다.

  • 일괄 접근 : ContentProviderOperation 클래스의 메서드 호출을 통해 일괄 접근을 생성하고 ContentResolver.applyBatch()로 적용
  • 비동기식 쿼리: 별도의 스레드에서 쿼리를 수행. Cusor loader 개체를 사용
  • Intent 사용한 데이터 접근: provider과 직접 통신 대신, provider의 데이터를 수정하는 데 가장 적합한 provider의 앱에 intent를 송신.

일괄 접근과 intent를 통한 접근 방식에 대해 알아보겠습니다.

1)일괄 접근

Provider에 대한 일괄 접근은 많은 수의 행을 삽입하거나 동일한 메서드 호출에서 여러 테이블의 행을 삽입할 때 사용됩니다. 또는 일반적으로 트랜잭션이라 말하는, 프로세스 경계를 넘어 원자 연산이라고 하는 일련의 연산을 수행하는 데 유용합니다.

일괄 모드로 provider에 접근하려면 여러 개의 ContentProviderOperation 개체를 만든 다음 ContentResolver.applyBatch()를 사용하는 content provider에게 보냅니다. 이 메서드에는 특정 콘텐츠 URI 대신 content provider의 권한을 넘겨야 합니다. 배열의 각 ContentProviderOperation 개체가 서로 다른 테이블에서의 작업이 필요할 수 있기 때문입니다. ContentResolver.applyBatch()로 호출하면 결과 배열이 반환됩니다.

// 일괄 처리를 위해 ContentProviderOperation 객체를 담을 리스트 생성
 ArrayList<ContentProviderOperation> ops =
          new ArrayList<ContentProviderOperation>();
 ...
// 현재 ops 리스트의 크기를 기록해, 새로 추가될 RawContact의 인덱스로 사용
 int rawContactInsertIndex = ops.size();
// 새 RawContact를 추가하는 insert 작업을 일괄 처리에 추가
// 새 연락처의 계정 타입과 계정 이름을 지정
 ops.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
          .withValue(RawContacts.ACCOUNT_TYPE, accountType)
          .withValue(RawContacts.ACCOUNT_NAME, accountName)
          .build());
// Data 테이블에 새 insert 작업을 추가
// 위에서 생성한 RawContact를 참조할 수 있도록 백 레퍼런스 인덱스 사용
//백 레퍼런스 인덱스란 직전에 작업한 항목의 인덱스
//삽입할 MIME type과 연락처에 표시할 이름 설정
 ops.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
          .withValueBackReference(Data.RAW_CONTACT_ID, rawContactInsertIndex)
          .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
          .withValue(StructuredName.DISPLAY_NAME, "Mike Sullivan")
          .build());
// 일괄 작업을 Contacts 콘텐츠 제공자에 적용
 getContentResolver().applyBatch(ContactsContract.AUTHORITY, ops);

2)Intent 사용하여 데이터에 접근

Intent는 content provider에 간접적으로 접근할 수 있는 권한을 제공합니다. 권한이 있는 응용 프로그램에서 intent 결과를 반환받거나 권한이 있는 응용 프로그램을 활성화하여 사용자가 content provider에서 작업하도록 허용합니다. 그러면 응용 프로그램에 접근 권한이 없더라도 사용자가 provider 데이터에 접근하도록 할 수 있습니다.

ⅰ)임시 권한으로 접근얻기

적절한 접근 권한이 없는 경우에도 해당 권한이 있는 앱에 intent를 보내고 URI 권한이 포함된 결과 intent를 받습니다. 간접적으로 content provider 데이터에 접근하게 됩니다. 이러한 권한은 특정 콘텐츠 URI를 수신하는 activity가 완료될 때까지 지속됩니다. 즉, 임시일지라도 한번 받으면 종료될 때까지 사용할 수 있습니다. 권한이 있는 앱은 intent에 플래그를 설정하여 임시 권한을 부여하게 만듭니다.

Read permission: FLAG_GRANT_READ_URI_PERMISSION
Write permission: FLAG_GRANT_WRITE_URI_PERMISSION

이러한 플래그는 provider에 대한 전체적인 읽기나 쓰기 접근 권한을 제공하는 건 아닙니다. 즉, 접근 권한은 해당 URI 자체에만 있습니다.

다른 앱으로 콘텐츠 URI를 보낼 때는 위에 언급한 플래그 중 하나 이상을 포함해야합니다. 이 플래그는 intent를 수신하고 Android 11(API 레벨 30) 이상을 타겟팅하는 모든 앱에 다음 기능을 제공합니다:

  • Intent에 포함된 플래그에 따라 콘텐츠 URI가 나타내는 데이터를 읽거나 쓰기 가능.
  • URI 권한과 일치하는 content provider가 포함된 앱에 대한 패키지 가시성을 확보. Intent를 전송하는 수신 앱과 content provider가 포함된 송신 앱은 서로 다른 두 앱일 수 있음. 이때 패키지 가시성은 사용자의 개인정보 강화를 위해 패키지에 대한 정보를 확인할 수 없게 접근 객체를 명확히 정의하는 것을 의미. 그렇기에 접근할 provider와 얻을 권한을 명확히 표시해야 됨.

Provider는 AndroidManifest.xml 파일에서 <provider> 요소의 android:grantUriPermissions 속성과 <provider> 요소의 <grant-uri-permission> 자식 요소를 사용하여 콘텐츠 URI에 대한 URI 권한을 정의합니다.

<provider
  android:grantUriPermissions="">
  <grant-uri-permission/>
</provider>

READ_CONTACTS 권한이 없어도 연락처 provider에서 연락처에 대한 데이터를 검색할 수 있습니다. 예를 들어, 생일 인사하는 내용의 전자 메시지를 보내는 앱에서 연락처를 원할 수 있습니다. 사용자가 앱에서 사용할 연락처만 얻으면 되기에, 모든 연락처 및 정보에 접근할 수 있는 READ_CONTACTS 권한을 요청할 필요는 없습니다. 이를 위해 다음과 같은 과정을 사용합니다.

  1. 앱에서 ACTION_PICK action과 연락처 MIME 타입인 CONTENT_ITEM_TYPE을 포함하는 intent를 startActivityForResult()에 전달. 이 메서드의 intent의 action으로 특정 앱이 열리게 하고 MIME 타입으로 그 중 연락처를 가진 앱을 열게 함.
  2. Intent 필터와 일치한 연락처 앱의 목록 activity가 포그라운드로 보임.
  3. 목록 activity에서 사용자가 사용할 연락처 선택. 이때 목록 activity는 setResult(resultCode, intent)를 호출하여 앱에 되돌려줄 intent을 설정. 이 intent에는 사용자가 선택한 연락처의 콘텐츠 URI와 함께 extras 플래그인 FLAG_GRANT_READ_URI_PERMISSION 포함. 이 플래그는 앱이 해당 콘텐츠 URI가 가리키는 연락처의 데이터를 읽을 수 있는 URI 권한 부여. 목록 activity는 이후 finish()를 호출하여 앱으로 제어를 반환.
  4. 본래의 앱 activity가 포그라운드로 돌아오고, 시스템은 해당 activity의 onActivityResult() 호출. 이 메서드는 연락처 앱의 목록 activity에서 생성된 결과 intent를 수신.
  5. 결과 intent에서 콘텐츠 URI를 사용하여, 앱이 manifest에서 provider에 대한 영구 읽기 접근 권한을 요청하지 않았음에도 불구하고 Contacts Provider에서 연락처의 데이터를 읽을 수 있음. 그런 다음 연락처의 생일 정보나 이메일 주소를 가져와서 축하 전자 메시지를 보낼 수 있음.

ⅱ)다른 앱 사용

접근 권한이 없는 데이터를 사용자가 수정할 수 있는 또 다른 방법은 권한이 있는 앱을 활성화하고 사용자가 해당 앱에서 작업을 수행하도록 하는 것입니다. 예를 들어, 캘린더 앱은 입력 UI를 활성화할 수 있는 ACTION_INSERT intent을 허용합니다. 이 intent에서 extras 데이터를 전달할 수 있으며, 이 데이터는 앱 UI에 미리 채워지는데 사용합니다. 데이터를 채우기 위한 반복 이벤트는 구문을 복잡하게 만들기 때문에 캘린더 provider에 이벤트를 삽입하는 가장 선호되는 방법은 ACTION_INSERT로 캘린더 앱을 활성화한 다음 사용자가 직접 데이터를 삽입하는 겁니다.

ⅲ) 도우미 앱을 사용하여 데이터 표시

응용 프로그램에 접근 권한이 있는 경우에도 다른 응용 프로그램으로 데이터를 표시하기 위해 intent를 사용할 수 있습니다. 예를 들어, 캘린더 애플리케이션은 특정 날짜나 이벤트를 표시하는 ACTION_VIEW intent 허용합니다. 이렇게 하면 직접 UI를 만들지 않고도 캘린더 정보를 표시할 수 있습니다.

Intent를 보내는 앱이 provider와 관련된 앱일 필요는 없습니다. 예를 들어 연락처 provider에서 연락처를 검색한 다음, 연락처 이미지를 다른 앱으로 보기 위해 URI 콘텐츠가 포함된 ACTION_VIEW intent를 이미지 뷰어로 전송할 수 있습니다.

7.Contract class

Contract class는 응용 프로그램이 content provider의 콘텐츠 URI, 열 이름, intent action 및 기타 기능과 함께 작동하는 데 도움이 되는 상수를 정의합니다. Contract class는 provider에 자동으로 포함되지 않습니다. 즉, provider를 직접 정의해서 사용한다면 개발자가 이를 정의한 다음 다른 개발자가 사용할 수 있도록 해야 합니다. Android 플랫폼에 포함된 많은 provider는 패키지 Android.provider에 해당하는 계약 클래스를 가지고 있습니다. 예를 들어, 사용자 사전 provider에는 콘텐츠 URI와 열 이름 상수가 포함된 contract class UserDictionary가 있습니다. Words 테이블의 콘텐츠 URI는 상수 UserDictionary.Words.CONTENT_URI에 정의되어 있습니다. UserDictionary.Words 클래스에는 위에서 사용한 예제 코드에 사용되는 열 이름 상수도 포함되어 있습니다. 예를 들어, 쿼리 프로젝션은 다음과 같이 정의될 수 있습니다.

String[] projection =
{
    UserDictionary.Words._ID,
    UserDictionary.Words.WORD,
    UserDictionary.Words.LOCALE
};

또 다른 contract class는 Contacts Provider를 위한 ContactsContract입니다. 이 클래스의 참조 문서에는 부분 예제 코드가 포함되어 있습니다. 이 클래스의 하위 클래스 중 하나인 ContactsContract.Intents.Insert는 intent와 intent 데이터에 대한 상수를 포함하는 contract class입니다.

8.MIME type

Content provider는 표준 MIME 미디어 유형, 사용자 지정 MIME 유형 문자열 또는 둘 다를 반환할 수 있습니다. MIME 유형에는 다음과 같은 형식이 있습니다.

type/subtype

예를 들어, 잘 알려진 MIME 유형의 text/html에는 text유형과 html 하위 유형이 있습니다. Provider가 URI에 대해 이 유형을 반환하면 해당 URI를 사용하는 쿼리에서 HTML 태그가 포함된 텍스트를 반환한다는 의미입니다.

사용자 지정 MIME 유형이라고도 하는 vendor-specific MIME 유형 문자열은, 더 복잡한 유형 및 하위 유형 값이 있습니다. 여러 행의 경우 유형 값은 항상 다음과 같습니다

vnd.android.cursor.dir

단일 행의 경우 유형 값은 항상 다음과 같습니다

vnd.android.cursor.item

하위 유형은 pvodier별로 다릅니다. Android 내장 provider는 일반적으로 간단한 하위 유형을 가지고 있습니다. 예를 들어 연락처 응용 프로그램이 전화 번호 행을 만들 때 행에 다음과 같은 MIME 유형을 설정합니다

vnd.android.cursor.item/phone_v2

하위 유형 값이 phone_v2입니다.

Provider의 권한과 테이블 이름을 기반으로 자체 하위 유형 패턴을 만들 수 있습니다. 예를 들어, 열차 시간표가 포함된 공급자를 고려해 보겠습니다. 공급자의 권한은 com.example.trains이며 테이블 Line1, Line2 및 Line3이 포함되어 있습니다. 테이블 Line1에 대한 다음 콘텐츠 URI에 대한 내용입니다

content://com.example.trains/Line1

provider는 다음과 같은 MIME 타입을 반환할겁니다

vnd.android.cursor.dir/vnd.example.line1

테이블 Line2의 5번째 행에 대한 content uri입니다

content://com.example.trains/Line2/5

Provider는 다음과 같은 MIME 타입을 반환할겁니다.

vnd.android.cursor.item/vnd.example.line2

MIME도 결국 타입이기에 테이블 Line2의 2번째 행이라도 같은 타입을 반환합니다.

대부분의 content provider는 사용하는 MIME 유형에 대한 contract class 상수를 정의합니다. 예를 들어, Contacts Provider 계약 클래스인 ContactsContract.RawContacts는 단일 기본 연락처 행의 MIME 유형에 대한 상수인 CONTENT_ITEM_TYPE을 정의합니다.

출처 : https://developer.android.com/

다음 CONTENT PROVIDER 포스트
<contentProvider_2(생성 과정)>
<contentProvider_3(예제)>
<contentProvider_4(calendar 개념)>
<contentProvider_5(calendar 예제)>
<contentProvider_6(비동기 방식)>
<contentProvider_7(contact)>

profile
안드로이드 개발자:D

0개의 댓글