안녕하세요!
오늘은 4대 컴포넌트 중 하나인 ContentProvider에 대해서 소개해보려고 합니다. 이름에서도 유추할 수 있듯이 앱의 내용, 데이터를 제공하는 컴포넌트일 것 같은 이름입니다.
먼저 ContentProvider란
" 콘텐츠 제공자는 중앙 저장소로의 데이터 액세스를 관리합니다. 제공자는 Android 애플리케이션의 일부이며, 대개 데이터 작업을 위한 고유의 UI를 제공합니다.
그러나 콘텐츠 제공자는 주로 다른 애플리케이션에서 사용하도록 설계되며, 이러한 애플리케이션은 제공자 클라이언트 객체를 사용하여 제공자에 액세스합니다.
제공자와 제공자 클라이언트는 함께 데이터에 일관된 표준 인터페이스를 제공하고, 여기에서는 프로세스 간 통신과 보안 데이터 액세스도 처리합니다.
일반적으로 다음과 같은 두 가지 시나리오 중 하나에서 콘텐츠 제공자를 사용하게 됩니다. 다른 애플리케이션에서 기존 콘텐츠 제공자에 액세스하기 위한 코드를구현하거나, 내 애플리케이션에서 새로운 콘텐츠 제공자를 생성하여 다른 애플리케이션과 데이터를 공유하고자 할 수 있습니다. 이 주제에서는 기존 콘텐츠 제공자를 사용하는 방법과 관련된 기본 사항을 설명합니다. "
라고 안드로이드 공식 문서에서 소개하고 있습니다.
간단하게 요약하면 ContentProvider는 Android 앱 안에서 개발되고 해당 앱의 데이터를 다른 앱에서 접근할 수 있도록 접근 통로를 만들어준다고 생각하면 좋을 것 같습니다. 그리고 때에 따라 본인 자신이 ContentProvider를 사용할 수도 있습니다. 하지만 ContentProvider의 기본적인 설계는 다른 앱이 데이터를 사용할 수 있도록 설계되었다고 합니다. 아래의 사진은 공식 문서에 있는 사진으로 ContentProvider가 데이터 저장소와 다른 요소들로부터 중재자와 같은 역할임을 나타내고 있습니다.
(출처 https://developer.android.com/guide/topics/providers/content-provider-basics?hl=ko#Basics)
ContentProvider는 데이터를 제공하려는 앱 안에서 개발하고 다른 앱 혹은 ContentProvider가 있는 본인 앱에서 사용할 수 있는 컴포넌트입니다. 그리고 이 여러 ContentProvider는 각각을 구분하기 위해서 Authority라는 문자열 값을 갖습니다. 안드로이드 공식 문서에서는 이 Authority가 고유하기 위해서 인터넷 도메인 url을 거꾸로한 규칙으로 Authority를 지정하도록 권장하고 있습니다. 예를 들어 앱의 패키지 이름이 com.smparkworld.providerexample 이면 ContentProvider의 Authority는 com.smparkworld.providerexample.provider 이렇게 됩니다.
그리고 이제 위와 같은 ContentProvider를 사용하기 위해서 데이터를 어떻게 저장할지를 설계해야 합니다. 크게 두 가지의 데이터 분류가 있습니다. 하나는 사진, 오디오, 동영상 같은 파일로된 데이터를 앱 전용 공간에 저장하고 ContentProvider가 다른 앱에서 파일 요청이 오면 그 요청에 따라 파일로 접근할 수 있게 합니다.
두번째는 데이터베이스, Key-Value와 같은 구조화된 데이터입니다. ContentProvider로 요청이 오면 해당 요청에 맞는 데이터 값을 반환합니다.
이렇게 다른 앱이 데이터를 사용할 수 있도록 인터페이스를 만드는 것까지는 알겠는데, 어떤 상황에서 사용해야 할지 감이 잘 안 올 것 같습니다. 공식 문서에서는 ContentProvider가 필요한지 결정하기 위해 도움이되는 5가지 기능을 제시하고 있으며 이 5가지 중 한가지 이상의 기능을 제공하려면 ContentProvider를 사용해야 한다고 가이드하고 있습니다.
AbstractThreadedSyncAdapter는 휴대폰 기기와 서버 간에 데이터 전송할 때 사용되는 클래스고, CursorAdapter는 ListView 위젯에 Cursor를 적용해서 내용을보여주는 클래스입니다. CursorLoader는 ContentResolver에 데이터를 쿼리하고 Cursor를 가져오는 작업을 백그라운드에서 진행하도록 돕는 클래스입니다. 하지만 CursorLoader는 API Level 28에서 Deprecated되었습니다. 대신에 Jetpack의 CursorLoader를 쓰도록 가이드하고 있습니다.
ContentProvider는 다른 앱에게 데이터를 제공하기 위해 주요 5가지 메서드가 있습니다. query, insert, update, delete, getType 이렇게 5가지 메서드와ContentProvider를 초기화할 수 있는 onCreate 메서드를 갖고있습니다. query, insert, update, delete 이 4가지의 메서드만 보면 데이터베이스에서 흔히 알고 있는 CRUD(생성, 검색, 갱신, 삭제)의 기능과 동일함을 알 수 있습니다.
query 메서드가 검색기능에 해당하고 나머지 insert, update, delete 메서드는 이름 그대로 생성, 갱신, 삭제의 기능을 수행합니다. 그러면 ContentProvider도 SQLite와 같은 DBMS인가에 대한 궁금증이 생길 수도 있을 것 같습니다. 물론 아닙니다. 그렇다고 Room 라이브러리와 같이 SQLite를 더 쉽고 효과적으로 활용할 수 있는 라이브러리도 아닙니다. 그냥 위에서 설명한 것처럼 데이터를 제공하는 컴포넌트인 것입니다. 다만 그 데이터를 제공하기 위해 Room 라이브러리를 사용할 수도 있고 때에 따라서 SharedPrefereneces를 사용할 수도 있습니다. ContentProvider는 DBMS처럼 CRUD의 기능을 기본 동작으로, 다른 앱이 ContentProvider가 있는 앱 내의 데이터를 간접적으로 조작할 수 있도록 제공하는 것입니다.
그리고 getType 메서드는 필수메서드이며, 이 메서드는 ContentProvider가 제공하는 데이터의 MIME 형식을 제공합니다. 아규먼트로 전달받는 Uri를 통해 요청 받은 데이터의 MIME 타입을 String형으로 리턴합니다. 특정 데이터 타입이 아니라 DB 테이블의 데이터인 경우에 리턴 타입에 맞춰 String형으로 리턴해야합니다.
예를 들어 ContentProvider의 Authority가 com.smparkworld.memoprovider이고, tb_memo라는 테이블의 데이터 한 줄의 타입을 Cursor 인스턴스로 반환할 경우, 데이터 타입은 vnd.android.cursor.item/com.smparkwold.tb_memo가 됩니다. 혹여 한줄 이상의 데이터일 경우 vnd.android.cursor.dir/com.smparkworld.tb_memo가 됩니다.
( 참고: https://developer.android.com/guide/topics/providers/content-provider-basics?hl=en#MIMETypeReference )
이렇게 ContentProvider를 구현한 뒤 provider 태그를 사용해서 AndroidManifest.xml에 등록해야 합니다. 이 태그에서 ContentProvider를 구분하는 Authority를 설정하고 외부 앱에서 접근할 수 있도록 허용할 것인지 설정하는 exported 속성, ContentProvider 클래스를 지정하는 name 속성 등 설정할 수 있습니다. 또 provider 태그를 통해 등록할 때 권한 설정을 할 수도 있습니다. ContentProvider의 권한 설정은 할 수도 있고 안할 수도 있습니다. 하지만 ContentProvider에서 권한 설정을 했다면 ContentResolver를 사용하는 앱에서 ContentProvider에서 설정한 권한을 사용할 수 있도록 AndroidManifest.xml에서 uses-permission 태그를 사용해야 합니다. 하지만 ContentProvider를 자기 자신이 사용하는 경우에는 권한 설정이 따로 필요 없습니다.
권한에는 4가지 종류가 있습니다. 임시 권한, 읽기/쓰기, 읽기, 쓰기 권한입니다. 용도에 따라 전범위에 적용할 수도 있고 특정 테이블, 혹은 특정 레코드에 적용할 수도 있고 세가지 전부 부여할 수도 있습니다. 읽기/쓰기 권한과 읽기 권한, 쓰기 권한이 중첩됬을 경우 읽기 권한, 쓰기 권한이 우선 시 됩니다. 일부분에 권한을 부여하려면 provider 태그 안에 path-permission 태그를 사용해서 지정할 수 있습니다. 임시 권한을 줄 경우에는 provider 태그에 android:grantUriPermissions 속성을 설정하거나 하위의 태그인 grant-uri-permission 태그를 사용해서 지정할 수 있습니다.
오늘 만들어볼 예제는 아래의 사진과 같습니다.
왼쪽이 ContentProvider를 갖고 있는 앱이고 오른쪽이 ContentProvider를 사용할 ContentResovler가 있는 앱입니다. 왼쪽 앱은 상단의 EditText에서 값을 입력하면 아래의 RecyclerView에 아이템이 추가되고 Room 라이브러리를 사용해서 SQLite에 저장하도록 했습니다. Room 라이브러리는 SQLite를 쉽고 효율적으로 사용할 수 있는 라이브러리입니다. 자세한 내용은 다음 기회에 포스팅할 예정입니다! 오른쪽 앱의 경우 왼쪽 앱의 메모 데이터를 가져오고 메모를 클릭했을 경우 메모의 작성날짜를 쿼리하는 기능과 갤러리 접근해서 가장 최근에 촬영된 사진을 가져와서 ImageView에 넣는 기능이 있습니다. 이렇게 직접 구현한 ContentProvider를 사용할 수도 있고 안드로이드에 내장된 ContentProvider를 사용할 수도 있습니다.
예제 만들기 순서입니다.
ContentProvider 앱
1. Room 의존성 추가
build.gradle.xml(Module)에 들어가셔서 40, 41번째 줄을 추가하고 우측 상단에 Sync Now 버튼을 눌러주시면 됩니다 : )
2. AppDatabase.java
package com.smparkworld.contentproviderexample;
import android.content.Context;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
@Database(entities = { Memo.class }, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
private static final String DB_NAME = "db_memo";
private static AppDatabase sInstance;
public abstract MemoDao memoDao();
public static AppDatabase getInstance(Context context) {
if (sInstance == null) {
synchronized (AppDatabase.class) {
if (sInstance == null) {
sInstance = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DB_NAME)
.allowMainThreadQueries()
.build();
}
}
}
return sInstance;
}
}
Room 라이브러리에 대한 구체적인 설명은 추후 포스팅에서 다루도록 하겠습니다! 그래서 지금은 SQLite를 다루는 Database 인스턴스를 생성한다고 생각하면 좋을 것 같습니다.
3. Memo.java
package com.smparkworld.contentproviderexample;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = "tb_memo")
public class Memo {
@PrimaryKey(autoGenerate = true)
int _id;
String content;
@ColumnInfo(defaultValue = "CURRENT_TIMESTAMP")
String dateCreated;
public int get_id() {
return _id;
}
public void set_id(int _id) {
this._id = _id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getDateCreated() {
return dateCreated;
}
public void setDateCreated(String dateCreated) {
this.dateCreated = dateCreated;
}
}
이 클래스는 Memo 데이터를 저장할 클래스입니다. 각 어노테이션은 Room 라이브러리에 대한 것입니다!
4. MemoDao.java
package com.smparkworld.contentproviderexample;
import android.database.Cursor;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Query;
import java.util.List;
@Dao
public interface MemoDao {
@Query("INSERT INTO tb_memo (content) VALUES (:content)")
long insertMemo(String content);
@Query("SELECT * FROM tb_memo")
List<Memo> getMemoList();
@Query("SELECT * FROM tb_memo WHERE _id = :newId")
Memo getMemoById(int newId);
@Delete
int deleteMemo(Memo m);
@Query("SELECT * FROM tb_memo")
Cursor getMemoListForProvider();
@Query("SELECT * FROM tb_memo WHERE _id = :newId")
Cursor getMemoByIdForProvider(int newId);
}
이것도 Room에 관련된 interface 클래스이며 SQLite에 직접적으로 접근할 DAO 클래스입니다.
5. item_memo_adapter.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="15dp"
android:paddingRight="15dp" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:text="-"
android:textSize="15dp" />
<TextView
android:id="@+id/tvItemMemoAdapter_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:layout_weight="1"
android:text="sample content.."
android:textSize="15dp"/>
<ImageButton
android:id="@+id/btnItemMemoAdapter_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:layout_gravity="center"
android:src="@drawable/ic_baseline_close_24"
android:background="@android:color/transparent"/>
</LinearLayout>
RecyclerView 안에 들어갈 아이템 한개의 View를 정의합니다.
6. MemoAdapter.java
package com.smparkworld.contentproviderexample;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
public class MemoAdapter extends RecyclerView.Adapter<MemoAdapter.MemoViewHolder> {
private Context mContext;
private List<Memo> mMemoList;
@NonNull
@Override
public MemoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
mContext = parent.getContext().getApplicationContext();
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_memo_adapter, parent, false);
return new MemoViewHolder(v);
}
@Override
public void onBindViewHolder(@NonNull MemoViewHolder holder, int position) {
holder.tvContent.setText(mMemoList.get(position).getContent());
}
@Override
public int getItemCount() {
return (mMemoList == null) ? 0 : mMemoList.size();
}
public void setMemoList(List<Memo> memoList) {
mMemoList = memoList;
notifyDataSetChanged();
}
public void addMemo(Memo newMemo) {
if (mMemoList == null) mMemoList = new ArrayList<Memo>();
mMemoList.add(newMemo);
notifyDataSetChanged();
}
class MemoViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
TextView tvContent;
public MemoViewHolder(View v) {
super(v);
tvContent = v.findViewById(R.id.tvItemMemoAdapter_content);
v.findViewById(R.id.btnItemMemoAdapter_remove).setOnClickListener(this);
v.setOnClickListener(this);
}
@Override
public void onClick(View view) {
int position = getAdapterPosition();
if (view.getId() == R.id.btnItemMemoAdapter_remove) {
Memo deleteMemo = mMemoList.get(position);
mMemoList.remove(position);
AppDatabase.getInstance(mContext).memoDao().deleteMemo(deleteMemo);
notifyDataSetChanged();
} else {
String dateCreated = mMemoList.get(position).getDateCreated();
Toast.makeText(mContext, "작성시간: " + dateCreated, Toast.LENGTH_SHORT).show();
}
}
}
}
RecyclerView에 사용되는 Adapter 클래스입니다. RecyclerView.DiffUtil를 사용해서 성능을 더 좋게 구현할 수 있지만 예제 앱에서는 다루지 않고 간단하게 구현했습니다. 추후 RecyclerView에 대해서 포스팅할 때 다뤄보도록 하겠습니다.
7. activity_main.java
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<EditText
android:id="@+id/etActivityMain_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:padding="13dp"
android:background="@drawable/shape_round"
android:singleLine="true"
android:elevation="8dp"
android:imeOptions="actionDone"
android:textSize="15dp"
android:hint="내용을 입력해주세요."/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvActivityMain_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
8. MainActivity.java
package com.smparkworld.contentproviderexample;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.os.Bundle;
import android.view.KeyEvent;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
public class MainActivity extends AppCompatActivity implements TextView.OnEditorActionListener {
private MemoAdapter mMemoAdapter;
private MemoDao mMemoDao;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mMemoDao = AppDatabase.getInstance(this).memoDao();
mMemoAdapter = new MemoAdapter();
mMemoAdapter.setMemoList(mMemoDao.getMemoList());
RecyclerView rvContainer = findViewById(R.id.rvActivityMain_container);
rvContainer.setLayoutManager(new LinearLayoutManager(this));
rvContainer.setAdapter(mMemoAdapter);
((EditText)findViewById(R.id.etActivityMain_content)).setOnEditorActionListener(this);
}
@Override
public boolean onEditorAction(TextView textView, int i, KeyEvent keyEvent) {
if (textView.getText().length() == 0) {
Toast.makeText(this, "값을 입력해주세요.", Toast.LENGTH_SHORT).show();
textView.requestFocus();
} else {
int newId = (int)mMemoDao.insertMemo(textView.getText().toString());
Memo newMemo = mMemoDao.getMemoById(newId);
textView.setText(null);
mMemoAdapter.addMemo(newMemo);
}
return false;
}
}
EditText를 통해 값을 추가할 때 따로 버튼을 만들지 않고 EditText의 Action버튼을 활용하도록 했습니다. 자세한 내용은 "[Android 안드로이드 기초] EditText 위젯" 게시글을 참고해주세요 : )
9. MemoProvider.java
package com.smparkworld.contentproviderexample;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class MemoProvider extends ContentProvider {
private static final int CODE_GET_ALL = 100;
private static final int CODE_GET_ITEM = 101;
private static final UriMatcher mUriMatcher;
static {
mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
mUriMatcher.addURI("com.smparkworld.contentproviderexample.MemoProvider", "tb_memo", CODE_GET_ALL);
mUriMatcher.addURI("com.smparkworld.contentproviderexample.MemoProvider", "tb_memo/#", CODE_GET_ITEM);
}
private MemoDao mMemoDao;
@Override
public boolean onCreate() {
mMemoDao = AppDatabase.getInstance(getContext()).memoDao();
return true;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) {
Cursor c = null;
switch(mUriMatcher.match(uri)) {
case CODE_GET_ALL:
c = mMemoDao.getMemoListForProvider();
break;
case CODE_GET_ITEM:
String id = uri.getPathSegments().get(1);
c = mMemoDao.getMemoByIdForProvider(Integer.parseInt(id));
break;
}
return c;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
String type = null;
switch(mUriMatcher.match(uri)) {
case CODE_GET_ALL:
type = "vnd.android.cursor.dir/vnd.smparkworld.tb_memo";
break;
case CODE_GET_ITEM:
type = "vnd.android.cursor.item/vnd.smparkworld.tb_memo";
break;
}
return type;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
return null;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
return 0;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
return 0;
}
}
UriMatcher 클래스는 ContentResolver로부터 전달받은 Uri를 쉽게 구분할 수 있도록 도와주는 클래스입니다. 그래서 20, 21번째 줄처럼 UriMatcher 인스턴스에 구분할 수 있는 내용들을 추가해줍니다. 첫번째 아규먼트는 Authority고, 두번째 아규먼트는 요청받을 Path입니다. 그리고 이 앞의 두 개 값과 일치하면 반환할 Integer 상수 값입니다.
10. AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.smparkworld.contentproviderexample">
<permission android:name="com.smparkworld.contentproviderexample.READ_DATA_STORAGE" />
<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/Theme.ContentProviderExample">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider android:name=".MemoProvider"
android:authorities="com.smparkworld.contentproviderexample.MemoProvider"
android:exported="true"
android:permission="com.smparkworld.contentproviderexample.READ_DATA_STORAGE" />
</application>
</manifest>
ContentProvider 클래스를 만들었으면 사용할 수 있도록 AndroidManifest.xml에 등록해야 합니다. 22~25번째 줄처럼 등록하면 되는데 name은 구현한 ContentProvider 클래스를 지정합니다. 그리고 ContentProvider를 구분하는 authority를 지정하고 외부 앱에서 접근할 수 있는지 설정하는 exported 속성값, 그리고 ContentProvider에 읽거나 쓰기 위해 필요한 권한을 지정하는 permission 속성을 지정합니다. 읽기 권한만 지정할 때는 readPermission, 쓰기 권한만 지정할 때는 writePermission이 있습니다 그리고 위에서 설명한 것처럼 path-permission 태그나 grant-uri-permission 태그도 추가하는 작업을 할 수 있습니다. 이렇게 추가한 권한들은 5번째 줄과 같이 permission 태그를 통해 등록해주어야 합니다.
ContentResolver 앱
1. Memo.java
package com.smparkworld.contentresolverexample;
public class Memo {
private int _id;
private String content;
public Memo(int _id, String content) {
this._id = _id;
this.content = content;
}
public int get_id() {
return _id;
}
public void set_id(int _id) {
this._id = _id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
ContentProvider 앱과 얼핏 비슷하지만 Memo를 한개 클릭했을 때 보여질 작성날짜는 ContentProvider에 레코드 한 개 요청하는 실습을 하기위해 뺐습니다!
2. item_memo_adapter.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="15dp"
android:paddingRight="15dp" >
<TextView
android:id="@+id/tvItemMemoAdapter_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:text="id"
android:textSize="15dp" />
<TextView
android:id="@+id/tvItemMemoAdapter_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:layout_weight="1"
android:text="sample content.."
android:textSize="15dp"/>
</LinearLayout>
RecyclerView 안에 들어갈 아이템 한개의 View를 정의합니다.
3. MemoAdapter.java
package com.smparkworld.contentresolverexample;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class MemoAdapter extends RecyclerView.Adapter<MemoAdapter.MemoViewHolder> {
private Context mContext;
private List<Memo> mMemoList;
@NonNull
@Override
public MemoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
mContext = parent.getContext().getApplicationContext();
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_memo_adapter, parent, false);
return new MemoViewHolder(v);
}
@Override
public void onBindViewHolder(@NonNull MemoViewHolder holder, int position) {
Memo m = mMemoList.get(position);
holder.tvId.setText(Integer.toString(m.get_id()));
holder.tvContent.setText(m.getContent());
}
@Override
public int getItemCount() {
return (mMemoList == null) ? 0 : mMemoList.size();
}
public void setMemoList(List<Memo> memoList) {
mMemoList = memoList;
notifyDataSetChanged();
}
class MemoViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
TextView tvId;
TextView tvContent;
public MemoViewHolder(View v) {
super(v);
tvId = v.findViewById(R.id.tvItemMemoAdapter_id);
tvContent = v.findViewById(R.id.tvItemMemoAdapter_content);
v.setOnClickListener(this);
}
@Override
public void onClick(View view) {
int position = getAdapterPosition();
int id = mMemoList.get(position).get_id();
Cursor c = mContext.getContentResolver().query(
Uri.parse("content://" + MainActivity.AUTHORITY + "/" + MainActivity.TABLE + "/" + id),
null,
null,
null,
null
);
if (c != null && c.moveToNext()) {
String dateCreated = c.getString(c.getColumnIndex("dateCreated"));
Toast.makeText(mContext, "작성시간: " + dateCreated, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(mContext, "데이터를 가져오기 실패했습니다.", Toast.LENGTH_SHORT).show();
}
}
}
}
RecyclerView의 아이템 한개를 클릭하면 클릭한 Memo의 PrimaryKey 값을 Uri에 같이 전송해서 작성날짜를 가져오고 Toast 메세지로 띄웁니다.
4. activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp"
android:gravity="center"
android:orientation="vertical" >
<ImageView
android:id="@+id/ivActivityMain_currentImg"
android:layout_width="200dp"
android:layout_height="200dp"
android:scaleType="centerCrop"
android:background="@android:color/black" />
<Button
android:id="@+id/btnActivityMain_getCurrentImg"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="최근 사진 가져오기" />
<Button
android:id="@+id/btnActivityMain_getMemoData"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="제공자로부터 가져오기" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvActivityMain_memoList"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
5. MainActivity.java
package com.smparkworld.contentresolverexample;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.Manifest;
import android.content.ContentUris;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.view.View;
import android.widget.ImageView;
import android.widget.Toast;
import java.util.ArrayList;
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
public static final String AUTHORITY = "com.smparkworld.contentproviderexample.MemoProvider";
public static final String TABLE = "tb_memo";
private ImageView ivCurrentImg;
private MemoAdapter mMemoAdapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ivCurrentImg = findViewById(R.id.ivActivityMain_currentImg);
mMemoAdapter = new MemoAdapter();
RecyclerView rvMemoList = findViewById(R.id.rvActivityMain_memoList);
rvMemoList.setLayoutManager(new LinearLayoutManager(this));
rvMemoList.setAdapter(mMemoAdapter);
findViewById(R.id.btnActivityMain_getCurrentImg).setOnClickListener(this);
findViewById(R.id.btnActivityMain_getMemoData).setOnClickListener(this);
ActivityCompat.requestPermissions(this, new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, 100);
}
@Override
public void onClick(View view) {
switch(view.getId()) {
case R.id.btnActivityMain_getCurrentImg:
Cursor cursorImg = getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[]{ MediaStore.Images.Media._ID },
null,
null,
MediaStore.Images.Media.DATE_TAKEN + " DESC"
);
if (cursorImg.moveToFirst()) {
long id = cursorImg.getLong(cursorImg.getColumnIndex(MediaStore.Images.Media._ID));
Uri path = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
ivCurrentImg.setImageURI(path);
String type = getContentResolver().getType(path);
Toast.makeText(this, type, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "갤러리로부터 사진을 가져오기 실패했습니다.", Toast.LENGTH_SHORT).show();
}
break;
case R.id.btnActivityMain_getMemoData:
Cursor cursorMemo = getContentResolver().query(
Uri.parse("content://" + AUTHORITY + "/" + TABLE),
null,
null,
null,
null
);
if (cursorMemo == null) {
Toast.makeText(this, "제공자로부터 데이터를 가져오기 실패했습니다.", Toast.LENGTH_SHORT).show();
return;
}
if (cursorMemo.moveToFirst()) {
ArrayList<Memo> memoList = new ArrayList<>();
do {
memoList.add(new Memo(
(int)cursorMemo.getLong(cursorMemo.getColumnIndex("_id")),
cursorMemo.getString(cursorMemo.getColumnIndex("content"))
));
} while(cursorMemo.moveToNext());
mMemoAdapter.setMemoList(memoList);
String type = getContentResolver().getType(Uri.parse("content://" + AUTHORITY + "/" + TABLE));
Toast.makeText(this, type, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "데이터가 없습니다.", Toast.LENGTH_SHORT).show();
}
break;
}
}
}
아래의 AndroidManifest.xml에서도 권한 사용을 선언하지만 44번과 같이 권한 사용 여부를 사용자에게 묻는 이유는 권한별로 단계가 있기 때문입니다. 위험권한을 사용하려면 앱이 구동되는 런타임에 사용자에게 권한 요청을 해야합니다. 원래는 이미 권한을 거부한 여부가 있는지를 확인하는 메서드 등과 같이 여러 절차가 있지만 본 예제 앱에서는 간단하게 구현하기 위해 권한 요청하는 메서드만 사용했습니다. 그래서 혹여 권한 거부를 클릭하면 앱을 지웠다가 다시 빌드하거나 앱 설정에서 권한 사용 여부를 켜면 됩니다.
6. AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.smparkworld.contentresolverexample">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.smparkworld.contentproviderexample.READ_DATA_STORAGE" />
<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/Theme.ContentResolverExample">
<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>
5번째 줄은 갤러리에 접근할 수 있도록 읽기 권한을 추가한 것이고 6번째 줄은 직접 구현한 ContentProvider에서 요구하는 권한을 지정한 것입니다.
이상 ContentProvider에 대한 포스팅을 마치겠습니다!
궁금하시거나 부족한 부분에 대해서 댓글로 남겨주시면 감사드리겠습니다 : )