Storage Access Framework

스토리지 액세스 프레임웍을 통해 파일 마스터가 되자!

marojun
marojun’s Android

--

안드로이드 4.4 (API 레벨 19) 에서는 새로운 Storage Access Framework (SAF)가 제공된다. 이제 유저들은 SAF를 통해 문서, 이미지 등의 파일들을 원하는 문서 제공자에서 쉽게 브라우징하고 사용할 수 있게 되었다. 즉, 쉽게 사용할 수 있도록 만들어진 표준화된 UI를 통해 모든 앱과 프로그램에서 일관된 방식으로 파일을 브라우징하고, 최신 파일에 접근할 수 있도록 된 것이다.

또한 클라우드와 로컬 스토리지 관련 서비스들은 본인들의 서비스를 캡슐화한 DocumentsProvider를 구현함으로써 스토리지 통합을 통한 에코시스템의 한 부분으로서의 역할을 할 수 있게되었다. 이러한 서비스들이 제공하는 문서를 사용하는 클라이언트 앱들은 몇 줄의 코드만으로 손쉽게 SAF를 통한 연동이 가능하다.

SAF는 다음 사항들을 포함한다:

  • 문서 제공자 — 구글 드라이브와 같은 스토리지 서비스가 관리하는 파일을 오픈 할 수 있도록하는 콘텐츠 공급자를 말한다. 문서 공급자는 DocumentsPriovider 클래스를 상속받아 구현되며 문서 공급자의 스키마는 기존에 사용하던 파일 계층(hierarchy) 구조를 기반으로 이루어진다. 이와 별개로 데이터를 저장하고 싶다면 정해진 방식이 있는것이 아니기 때문에 본인이 원하는 방식대로 구현하면 된다. 안드로이드 플랫폼은 다운로드, 이미지, 동영상 등의 정의된 문서 공급자를 포함한다.
  • 클라이언트 앱 — ACTION_OPEN_DOCUMENT 및/또는 ACTION_CREATE_DOCUMENT 인텐트를 호출하고 이에 반환 된 파일을 수신하는 커스텀 앱이다.
  • 피커 — 유저들이 모든 문서 공급자에서 문서를 확인할 수 있도록 하는 시스템 UI. 클라이언트 앱이 요청한 검색결과를 보여준다.

SAF에서 제공되는 몇가지 기능들은 다음과 같다:

  • 특정 앱에서만 컨텐츠에 대한 결과가 검색되는 것이 아니라 문서 공급자와 통합된 모든 앱들에 대한 검색 결과를 볼 수 있다.
  • 문서 공급자가 소유하고 있는 문서들에 대한 장기적인 액세스를 가질 수 있도록 한다. (장기 액세스에 대한 자세한 내용은 추후설명)이 엑세스를 통해 유저들은 공급자에서 파일을 추가, 편집, 저장, 및 삭제할 수 있다.
  • USB 저장장치와 같이 드라이브가 연결되어 있는 경우 나타나는 장치 및 다수의 사용자 계정을 지원한다.

Overview

SAF는 DocumentProvider 클래스를 상속받아 사용하는 클래스를 통해 구현되는 콘텐츠 제공자(쉽게 말해 파일을 제공하는 앱을 말한다.) 역할에 중점을 두고있으며 문서 공급자 내의 데이터는 기존 파일 계층 구조로 구성되어있다.

Figure 1. Document provider data model. A Root points to a single Document, which then starts the fan-out of the entire tree.

다음 사항을 유의하자:

  • 각 문서 공급자는 문서간의 관계를 정의하며 최상위 계층을 의미하는 루트를 하나 또는 그 이상 가지고 있다. 각 루트는 고유 COLUMN_ROOT_ID를 가지고 있으며, 콘텐츠를 대표하는 문서 (디렉토리)를 가리킨다. 루트는 다수의 사용자 계정, 외부 저장 장치, 유저의 로그인/로그아웃 등과 같은 사용 케이스를 지원하기 위한 동적인 설계 시스템이다.
  • 각 루트 안에는 하나의 문서가 있다. 해당 문서는 1에서 N개의 문서를 가리키며, 또 각각의 문서는 차례로 1에서 N 문서를 가리키게 된다.
  • 각 스토리지 백엔드는 고유의 COLUMN_DOCUMENT_ID를 통해 개별적으로 파일과 디렉토리를 참조한다. 고유의 문서 ID는 장치 재부팅 후에 지속적으로 URI 부여에 사용되기 때문에, 한 번 발행된 후 변경될 수 없다.
  • 문서는 (특정 MIME 타입의) 열 수 있는 파일이거나 (MIME_TYPE_DIR MIME 타입의) 추가적인 문서를 포함하는 디렉토리일 수 있다.
  • 각 문서는 COLUMN_FLAGS의 설명에 따라 다른 기능을 가질 수 있다. 예를 들어, 플래그는 FLAG_SUPPORTS_WRITE, FLAG_SUPPORTS_DELETE, FLAG_SUPPORTS_THUMBNAIL 등이 있다. 또한 같은 COLUMN_DOCUMENT_ID 는 여러 개의 디렉토리에 포함될 수 있다.

Control Flow

위에 설명된 바와 같이, 문서 공급자 데이터 모델은 기존의 파일 계층 구조에 베이스를 두고있지만 DocumentsProvider의 API를 통해 데이터를 원하는 방식대로 저장할 수 있다. 다시말해 데이터를 클라우드 스토리지를 사용하여 저장할 수 있다는 말이다.

Figure2는 사진 앱이 저장된 데이터에 접근하기 위해 SAF를 사용하는 예를 보여준다.

Figure 2. Storage Access Framework Flow

다음 사항을 유의한다:

  • SAF에서 공급자와 클라이언트는 직접적인 인터렉션을 하지 않으며, 클라이언트는 읽기, 편집, 생성, 삭제 처럼 파일과 인터렉션을 할 수 있는 권한을 요청한다.
  • 인터렉션은 ACTION_OPEN_DOCUMENT 또는 ACTION_CREATE_DOCUMENT 인텐트를 호출하여 시작할 수 있다 (해당 예에서는, 사진 앱). 인텐트는 카테고리를 더 세분화하기 위한 필터를 포함할 수 있다. 예를 들어, “image”처럼 해당 MIME 타입의 오픈 가능한 모든 파일을 제공 할 수 있다.
  • 인텐트가 발생되면 시스템 피커는 각각의 등록된 공급자로 이동하여 매칭되는 콘텐츠 루트를 유저에게 보여준다.
  • 문서의 공급자가 달라도, 피커는 유저들에게 공통 인터페이스를 제공하여 문서를 보여준다. 예를 들어, 그림 2는 구글 드라이브, USB, 그리고 클라우드 공급자를 보여주고 있다.

그림 3은 이미지를 검색하는 유저가 구글 드라이브 계정을 선택했을 때의 피커를 보여준다.

Figure 3. Picker

유저가 구글 드라이브를 선택하면 그림 4와 같이 이미지가 보여진다. 그 때부터 유저는 공급자와 클라이언트 앱에서 지원되는 방법대로 인터렉션을 할 수 있다.

Figure 4. Images

Writing a Client App

안드로이드 4.3 이하의 버전의 경우, 파일 검색을 원한다면 ACTION_PICK 또는 ACTION_GET_CONTENT 인텐트를 호출해야 한다. 유저가 Intent chooser를 통해 파일을 핸들링할 앱을 고르고, 선택된 앱은 유저가 원하는 파일타입 중 가능한 것들을 검색하여 고를 수 있도록 유저 인터페이스를 제공해야 한다.

그러나 안드로이드 4.4 이상에서는, 유저가 모든 파일을 검색할 수 있도록 하는 시스템의 피커 UI를 보여주는 ACTION_OPEN_DOCUMENT 인텐트를 사용하는 옵션이 있다. 이 UI에서는 지원되는 아무 앱에서 임의의 파일을 선택할 수 있다.

ACTION_OPEN_DOCUMENT는 ACTION_GET_CONTENT을 대체하기위한 것이 아니다. 각각의 특성이 다르니 아래를 참고하여 앱의 요구사항에 따라 선택하여 사용해야 한다.

  • 앱이 단순히 데이터를 읽고 가져오기를 원한다면 ACTION_GET_CONTENT를 사용한다. 이를 통해 앱은 이미지 파일 등의 데이터 복사본을 가져온다.
  • 이 공급자에 있는 문서에 오랫동안 액세스 하기를 바란다면 ACTION_OPEN_DOCUMENT를 사용한다. 예를 들자면 문서 공급자에 저장된 이미지를 편집하는 포토에디트 앱이 이에 해당 될 것이다.

액세스에 대한 자세한 내용은 이후에 설명할 Persist permissions항목을 참조.

Search for documents

다음 코드는 이미지 파일을 포함하는 문서 공급자를 검색하기 위해 ACTION_OPEN_DOCUMENT를 사용하는 케이스이다.

참고 — Storage Access Framework을 통한 이미지 가져오기

private static final int READ_REQUEST_CODE = 42;

/**
* Fires an intent to spin up the “file chooser” UI and select an image.
*/
public void performFileSearch() {
// ACTION_OPEN_DOCUMENT is the intent to choose a file via the system’s file
// browser.
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
// Filter to only show results that can be “opened”, such as a
// file (as opposed to a list of contacts or timezones)
intent.addCategory(Intent.CATEGORY_OPENABLE);
// Filter to show only images, using the image MIME data type.
// If one wanted to search for ogg vorbis files, the type would be “audio/ogg”.
// To search for all documents available via installed storage providers,
// it would be “*/*”.
intent.setType(“image/*”);
startActivityForResult(intent, READ_REQUEST_CODE);
}

다음 사항을 유의한다:

  • 앱이 ACTION_OPEN_DOCUMENT 인텐트를 실행하면, 일치하는 모든 문서 공급자를 보여주는 피커를 실행한다.
  • 인텐트에 CATEGORY_OPENABLE 카테고리를 추가하면, 이미지 파일과 같이 열 수 있는 문서만 표시하도록 필터링한다.
  • intent.setType(“image/*”) 는 이미지 MIME 데이터 타입이 있는 문서만 표시하도록 필터링한다.

Process Results

유저가 피커에서 문서를 선택하면, onActivityResult() 가 호출된다. 선택 된 문서를 가리티는 URI는 resultData 파라메터에 포함되어 있다. getData() 를 이용하여 URI를 추출하면, 유저가 원하는 문서를 검색하는 데 사용할 수 있다. 예를 들어 다음코드를 보자.

@Override
public void onActivityResult(int requestCode, int resultCode,
Intent resultData) {
// The ACTION_OPEN_DOCUMENT intent was sent with the request code
// READ_REQUEST_CODE. If the request code seen here doesn’t match, it’s the
// response to some other intent, and the code below shouldn’t run at all.
if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
// The document selected by the user won’t be returned in the intent.
// Instead, a URI to that document will be contained in the return intent
// provided to this method as a parameter.
// Pull that URI using resultData.getData().
Uri uri = null;
if (resultData != null) {
uri = resultData.getData();
Log.i(TAG, “Uri: “ + uri.toString());
showImage(uri);
}
}
}

Examine document metadata

문서에 대한 URI를 받으면 메타 데이터에 액세스 할 수 있다. 아래 코드에서는 URI로 지정된 문서에 대한 메타 데이터를 가져와 로그로 보여준다.

public void dumpImageMetaData(Uri uri) { // The query, since it only applies to a single document, will only return
// one row. There’s no need to filter, sort, or select fields, since we want
// all fields for one document.
Cursor cursor = getActivity().getContentResolver()
.query(uri, null, null, null, null, null);
try {
// moveToFirst() returns false if the cursor has 0 rows. Very handy for
// “if there’s anything to look at, look at it” conditionals.
if (cursor != null && cursor.moveToFirst()) {
// Note it’s called “Display Name”. This is
// provider-specific, and might not necessarily be the file name.
String displayName = cursor.getString(
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
Log.i(TAG, “Display Name: “ + displayName);
int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
// If the size is unknown, the value stored is null. But since an
// int can’t be null in Java, the behavior is implementation-specific,
// which is just a fancy term for “unpredictable”. So as
// a rule, check if it’s null before assigning to an int. This will
// happen often: The storage API allows for remote files, whose
// size might not be locally known.
String size = null;
if (!cursor.isNull(sizeIndex)) {
// Technically the column stores an int, but cursor.getString()
// will do the conversion automatically.
size = cursor.getString(sizeIndex);
} else {
size = “Unknown”;
}
Log.i(TAG, “Size: “ + size);
}
} finally {
cursor.close();
}
}

Open a document

문서에 대한 URI를 받으면 이를 통해 파일을 오픈하거나 이외의 다른 작업들을 할 수 있다.

Bitmap

다음은 비트맵을 열 수 있는 하나의 예시이다:

private Bitmap getBitmapFromUri(Uri uri) throws IOException {
ParcelFileDescriptor parcelFileDescriptor =
getContentResolver().openFileDescriptor(uri, “r”);
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
return image;
}

주의할 점은, UI thread에서 작업하면 안된다는 것이다. AsyncTask를 이용하여 백그라운드에서 작업해야 한다. 비트맵을 열면, ImageView를 통해 해당 이미지를 보여줄 수 있다.

Get an InputStream

다음은 URI에서 InputStream을 가져올 수 있는 예시이다. 여기에서는 파일의 라인을 string으로 읽을 수 있다.

private String readTextFromUri(Uri uri) throws IOException { InputStream inputStream = getContentResolver().openInputStream(uri);
BufferedReader reader = new BufferedReader(new InputStreamReader(
inputStream));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
stringBuilder.append(line);
}
fileInputStream.close();
parcelFileDescriptor.close();
return stringBuilder.toString();
}

Create a new document

ACTION_CREATE_DOCUMENT 인텐트를 사용하여 문서 공급자에서 새로운 문서를 작성할 수 있다. 파일을 생성하기 위해 인텐트에 MIME type과 파일명을 지정하고, 고유 request 코드를 사용한다. 그럼 이후에 작업에 대해서는 알아서 처리된다.

// Here are some examples of how you might call this method.
// The first parameter is the MIME type, and the second parameter is the name
// of the file you are creating:
//
// createFile(“text/plain”, “foobar.txt”);
// createFile(“image/png”, “mypicture.png”);
// Unique request code.
private static final int WRITE_REQUEST_CODE = 43;

private void createFile(String mimeType, String fileName) {
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
// Filter to only show results that can be “opened”, such as
// a file (as opposed to a list of contacts or timezones).
intent.addCategory(Intent.CATEGORY_OPENABLE);
// Create a file with the requested MIME type.
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, fileName);
startActivityForResult(intent, WRITE_REQUEST_CODE);
}

새 문서를 작성한 후, ActivityResult()에서 URI를 가져오면 문서를 계속 사용 할 수 있다.

Delete a document

문서에 대한 URI를 가지고 있으며, 문서의 Document.COLUMN_FLAGS가 SUPPORTS_DELETE를 포함하는 경우, 문서를 삭제 할 수 있다.

DocumentsContract.deleteDocument(getContentResolver(), uri);

Edit a document

텍스트 문서를 편집하기 위해 SAF를 사용할 수 있다. 이를 위해서 먼저 ACTION_OPEN_DOCUMENT를 실행하고 CATEGORY_OPENABLE를 사용하여 열 수 있는 문서만을 보여준다. 그리고 추가적으로 텍스트 파일만을 보여주도록 필터링하고 있다.

private static final int EDIT_REQUEST_CODE = 44;
/**
* Open a file for writing and append some text to it.
*/
private void editDocument() {
// ACTION_OPEN_DOCUMENT is the intent to choose a file via the system’s
// file browser.
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
// Filter to only show results that can be “opened”, such as a
// file (as opposed to a list of contacts or timezones).
intent.addCategory(Intent.CATEGORY_OPENABLE);
// Filter to show only text files.
intent.setType(“text/plain”);
startActivityForResult(intent, EDIT_REQUEST_CODE);
}

다음으로, onActivityResult()에서 전달받은 (편집하고하자는 파일) uri를 통해 편집을 수행하는 코드를 호출한다. 이 코드는 ContentResolver 에서 FileOutputStream를 가져온다. 기본적으로는 “쓰기” 모드를 사용한다. 효율적인 프로그래밍은 가장 최소의 권한을 요청해서 사용하는 것이므로 ‘쓰기’만 필요할 때에 ‘읽기/쓰기’를 요청하지 않도록 한다.

private void alterDocument(Uri uri) {
try {
ParcelFileDescriptor pfd = getActivity().getContentResolver().
openFileDescriptor(uri, “w”);
FileOutputStream fileOutputStream =
new FileOutputStream(pfd.getFileDescriptor());
fileOutputStream.write((“Overwritten by MyCloud at “ +
System.currentTimeMillis() + “\n”).getBytes());
// Let the document provider know you’re done by closing the stream.
fileOutputStream.close();
pfd.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

Persist permissions

앱에서 읽기나 쓰기위해 문서를 열 때, 시스템은 앱에 파일에 대한 URI 권한을 부여하며, 그 권한은 단말이 재시작 될 때 까지 지속된다. 하지만 이 이미지 편집앱일 경우 유저가 마지막으로 편집한 5개의 이미지를 앱에서 계속 직접 액세스 할 수 있기를 바란다고 가정하자. 그러나 권한을 부여받지 못한다면 단말이 재부팅될 경우, 유저는 다시 시스템 피커를 통해 편집할 파일을 찾아야 할 것이다.

이러한 일이 발생하는 것을 막기 위해 시스템이 앱에 주는 권한을 유지시키는 방법이 있다. 앱이 시스템이 제공하는 지속적 URI 권한을 “받을” 수 있도록 하는것이다. 이렇게 하면 단말을 재부팅 한 후에서 지속적으로 앱에서 파일 액세스를 할 수 있게 된다.

final int takeFlags = intent.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Check for the freshest data.
getContentResolver().takePersistableUriPermission(uri, takeFlags);

마지막으로 파일에 대한 URI를 저장했지만, 더이상 유효하지 않은 경우가 생길 수 있다 — 다른 앱이 문서를 삭제하거나 수정하는 경우, 가장 최신 데이터를 확인하기 위해 getContentResolver().takePersistableUriPermission()를 호출해야 한다.

Writing a Custom Document Provider

파일 저장 서비스를 제공하는 앱을 만들고 싶다면 (예: 클라우드 저장 서비스), 커스텀 문서 공급자를 만들어 SAF를 통해 파일을 사용할 수 있도록한다.

Manifest

커스텀 문서 공급자를 구현하기 위해 애플리케이션 매니페스트에 다음을 추가한다

  • API 레벨 19 이상의 타겟
  • 커스텀 공급자를 표시하는 <provider> 요소
  • 제공자의 이름(패키지명을 포함한 클래스명). 예를 들어, com.example.android.storageprovider.MyCloudProvider.
  • 권한명 (패키지명 — 이 예시의 경우, com.example.android.storageprovider)과 컨텐츠 공급자 유형 (문서). 예를 들어, com.example.android.storageprovider.documents
  • android:exported 속성이 “true”로 설정. 다른 앱에서 보일 수 있도록 공급자를 내보내도록 해야한다.
  • android:grantUriPermissions 속성이 “true”로 설정. 이 설정은 시스템이 앱에 있는 콘텐츠를 다른 앱에서 액세스 할 수 있는 권한을 부여한다. 특정 문서에 대한 권한을 지속적으로 부여하는 방법을 알고싶다면, Persist permissions 를 참고하라.
  • MANAGE_DOCUMENTS 권한. 기본적으로 공급자는 모든 사람들이 사용할 수 있다. 이 권한을 추가하면 시스템에 공급자를 제한한다. 이 제한은 보안을 위해 매우 중요하다.
  • android:enabled 속성을 리소스 파일에서 정의된 boolean 값으로 설정. 이것은 안드로이드 4.3 이하 단말에서 공급자가 실행되는 것을 막는다. 예를 들어, android:enabled=”@bool/atLeastKitKat
    매니페스트에 이 속성을 포함하는 것과 더불어, 다음 작업을 해야한다.
In your bool.xml resources file under res/values/, add this line:
<bool name=”atLeastKitKat”>false</bool>
In your bool.xml resources file under res/values-v19/, add this line:
<bool name=”atLeastKitKat”>true</bool>
  • android.content.action.DOCUMENTS_PROVIDER 액션을 포함하는 인텐트 필터. 시스템이 공급자를 검색할 때 피커에 나타나도록 한다.

공급자를 포함하는 샘플 매니페스트에서 발췌한 내용은 다음과 같다:

<manifest… >

<uses-sdk
android:minSdkVersion=”19"
android:targetSdkVersion=”19" />
….
<provider
android:name=”com.example.android.storageprovider.MyCloudProvider”
android:authorities=”com.example.android.storageprovider.documents”
android:grantUriPermissions=”true”
android:exported=”true”
android:permission=”android.permission.MANAGE_DOCUMENTS”
android:enabled=”@bool/atLeastKitKat”>
<intent-filter>
<action android:name=”android.content.action.DOCUMENTS_PROVIDER” />
</intent-filter>
</provider>
</application>
</manifest>

Supporting devices running Android 4.3 and lower

ACTION_OPEN_DOCUMENT 인텐트는 안드로이드 4.4 이상 단말에서만 지원된다. 이에 안드로이드 4.3 이하의 단말에서도 서비스를 제공하도록 ACTION_OPEN_DOCUMENT 이외에 ACTION_GET_CONTENT을 정의하였다면 안드로이드 4.4 이상 단말에서는 매니페스트에서 ACTION_GET_CONTENT 인텐트 필터를 사용하지 않도록 설정해야 한다. 만약 두가지 방법을 동시에 지원한다면 4.4에서는 시스템 피커 UI에서 앱이 프로바이더로서 중복되어 나타나, 저장된 데이터를 액세스 하는 경로를 두 가지로 나누어버릴 것이다.

다음은 안드로이드 4.4 이상 단말에서 ACTION_GET_CONTENT 인텐트 필터를 쓰지 않도록 설정하는 방법이다.

In your bool.xml resources file under res/values/, add this line:
<bool name=”atMostJellyBeanMR2">true</bool>
In your bool.xml resources file under res/values-v19/, add this line:
<bool name=”atMostJellyBeanMR2">false</bool>
Add an activity alias to disable the ACTION_GET_CONTENT intent filter for versions 4.4 (API level 19) and higher. For example:<!— This activity alias is added so that GET_CONTENT intent-filter can be disabled for builds on API level 19 and higher. →
<activity-alias android:name=”com.android.example.app.MyPicker”
android:targetActivity=”com.android.example.app.MyActivity”

android:enabled=”@bool/atMostJellyBeanMR2">
<intent-filter>
<action android:name=”android.intent.action.GET_CONTENT” />
<category android:name=”android.intent.category.OPENABLE” />
<category android:name=”android.intent.category.DEFAULT” />
<data android:mimeType=”image/*” />
<data android:mimeType=”video/*” />
</intent-filter>
</activity-alias>

Contracts

보통 커스텀 콘텐츠 공급자를 만들 경우 필요한 작업 중 하나는 contract 클래스를 구현하는 것이다. (Content Providers 가이드에도 명시되어 있다). contract 클래스란, URI, column 명, MIME 유형, 그리고 그 외에 공급자와 관련된 메타데이터에 대한 정의를 포함한 public final 클래스 이다. SAF는 이 contract 클래스를 제공하기 때문에, 개발자가 이를 직접 만들 필요가 없다:

예를 들어, 아래는 문서 공급자가 문서나 root에 대한 쿼리를 받았을 때 커서에 받을 수 있는 column들이다.

private static final String[] DEFAULT_ROOT_PROJECTION =
new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES,
Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY, Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_AVAILABLE_BYTES,};
private static final String[] DEFAULT_DOCUMENT_PROJECTION = new
String[]{Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE,
Document.COLUMN_DISPLAY_NAME, Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_FLAGS, Document.COLUMN_SIZE,};

Subclass DocumentsProvider

커스텀 문서 공급자를 작성하는 다음 단계는 DocumentsProvider를 상속받는 것이다. 이를 통해 최소한 아래의 메서드를 구현해야 한다:

이것들은 구현을 위해 필수적으로 필요한 것들이지만, 이외에도 더 필요한 기능들이 있다면 DocumentsProvider를 참고하도록 한다.

Implement queryRoots

queryRoots()를 통해 커스텀 문서 제공자의 모든 루트 디렉토리에 대한 Cursor 를 가져온다.

아래 코드를 보면 프로젝션 파라미터는 호출자가 다시 얻을하고자하는 특정 필드를 나타내며 새로운 커서를 생성하고 하나의 행을 추가한다.다운로드나 이미지와 같은 하나의 루트, 최상위 디렉토리에

대부분의 제공자는 하나의 루트를 가지고 있다. 만약 하나 이상의 루트를 가지고 있다면 (멀티 계정등의 이유로) 2행에 대한 커서를 생성해야 할 것이다.

@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
// 요청된 필드 또는 기본값에 대한 커서를 생성한다.
// projection의 값이 “projection”일 경우 null이다.
final MatrixCursor result =
new MatrixCursor(resolveRootProjection(projection));
// 유저가 로그인 되어 있지 않다면 비어있는 루트 커서를 반환하다.
// 이는 전체 리스트에서 내 공급자를 제거한다는 것을 의미한다.
if (!isUserLoggedIn()) {
return result;
}
// 다양한 루트를 가지고 있는 것이 가능하다. (예를들어 한 앱에 멀티계정으로 로그인 되어있는 경우를 생각해보면 된다.)
// “MyCloud”라 불리우는 루트에 대해 한 행을 생성한다.
final MatrixCursor.RowBuilder row = result.newRow();
row.add(Root.COLUMN_ROOT_ID, ROOT);
row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.root_summary));
// FLAG_SUPPORTS_CREATE는 루트하의 적어도 한개의 디렉토리에 대한 문서 작성을 지원을 의미한다.
// FLAG_SUPPORTS_RECENTS는 앱이 가장 최근에 사용한 문서가 “Recents”카테고리에 표시된다.
// FLAG_SUPPORTS_SEARCH를 통해 유저는 앱에서 공유하는 모든 문서를 검색할 수 있다.

row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE |
Root.FLAG_SUPPORTS_RECENTS |
Root.FLAG_SUPPORTS_SEARCH);
// COLUMN_TITLE는 루트의 타이틀이다.(e.g. Gallery, Drive).
row.add(Root.COLUMN_TITLE, getContext().getString(R.string.title));
// 공유후 document id는 변경할 수 없다.
row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(mBaseDir));
// child MIME types은 루트와 어딘가에 자신의 파일 계층 구조에서 원하는 유형을 포함하는 사용자의 루트에서만 필터링하는데 사용된다. row.add(Root.COLUMN_MIME_TYPES, getChildMimeTypes(mBaseDir));
row.add(Root.COLUMN_AVAILABLE_BYTES, mBaseDir.getFreeSpace());
row.add(Root.COLUMN_ICON, R.drawable.ic_launcher);
return result;
}

Implement queryChildDocuments

queryChildDocuments()를 통해 특정 디렉토리의 모든파일에 대한 커서를 가지고 올 수 있다.

이 메서드는 피커 UI를 통해 루트가 선택되었을때 호출된다. 이것을 통해 루트가 가지고 있는 디렉토리들의 하위 문서들을 가지고 올 수 있으며 루트를 제외한 파일계층 중에서 어떠한 레벨의 계층도 호출할 수 있다.

아래코드는 새로운 커서를 통해 columns을 요청하고 커서에 상위 디렉토리의 자식에 대한 정보를 추가한다.

@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection,
String sortOrder) throws FileNotFoundException {
final MatrixCursor result = new
MatrixCursor(resolveDocumentProjection(projection));
final File parent = getFileForDocId(parentDocumentId);
for (File file : parent.listFiles()) {
// Adds the file’s display name, MIME type, size, and so on.
includeFile(result, null, file);
}
return result;
}

Implement queryDocument

queryDocument()를 통해 특정 파일의 커서를 가지고 올 수 있다.

queryDocument()이 전달받는 값은 queryChildDocuments() 와 같지만 특정 파일에 대한 것이라는 점은 다르다.

@Override
public Cursor queryDocument(String documentId, String[] projection) throws
FileNotFoundException {
// Create a cursor with the requested projection, or the default projection.
final MatrixCursor result = new
MatrixCursor(resolveDocumentProjection(projection));
includeFile(result, documentId, null);
return result;
}

Implement openDocument

openDocument()를 통해 특정 파일을 나타내는 ParcelFileDescriptor을 전달받는다. 이 ParcelFileDescriptor를 통해 다른 앱들은 데이터를 스트리밍 할 수 있게된다. 시스템이 이를 호출하는 시점은 사용자가 파일을 선택하거나 클라이언트 앱에서 접근에 대한 요청을 하면 openFileDescriptor()가 호출 되어진다.

@Override
public ParcelFileDescriptor openDocument(final String documentId,
final String mode,
CancellationSignal signal) throws
FileNotFoundException {
Log.v(TAG, “openDocument, mode: “ + mode);
// It’s OK to do network operations in this method to download the document,
// as long as you periodically check the CancellationSignal. If you have an
// extremely large file to transfer from the network, a better solution may
// be pipes or sockets (see ParcelFileDescriptor for helper methods).
final File file = getFileForDocId(documentId); final boolean isWrite = (mode.indexOf(‘w’) != -1);
if(isWrite) {
// Attach a close listener if the document is opened in write mode.
try {
Handler handler = new Handler(getContext().getMainLooper());
return ParcelFileDescriptor.open(file, accessMode, handler,
new ParcelFileDescriptor.OnCloseListener() {
@Override
public void onClose(IOException e) {
// Update the file with the cloud server. The client is done
// writing.
Log.i(TAG, “A file with id “ +
documentId + “ has been closed!
Time to “ +
“update the server.”);
}
});
} catch (IOException e) {
throw new FileNotFoundException(“Failed to open document with id “
+ documentId + “ and mode “ + mode);
}
} else {
return ParcelFileDescriptor.open(file, accessMode);
}
}

Security

내 문서 공급앱이 비밀번호를 통해 보안되는 클라우드 저장 서비스이며 유저의 문서를 공유하기 전에 로그인 여부를 체크하고 싶다라고 가정해보자. 로그인이 안 되어있는 경우 어떻게 해야할까? 방법은 queryRoots() 에 zero root를 보내주는 것이다. 이는 비어있는 루트 커서를 의미한다.

public Cursor queryRoots(String[] projection) throws FileNotFoundException {

// If user is not logged in, return an empty root cursor. This removes our
// provider from the list entirely.
if (!isUserLoggedIn()) {
return result;
}

또 다른 스텝은 getContentResolver().notifyChange()를 호출하는 것이다. DocumentsContract를 기억하는가? notifyChange()는 이 URI를 생성하기 위해 사용한다. 다음 코드는 유저의 로그인 상태가 변경될 때마다 시스템이 문서 공급자의 root를 확인(쿼리)하도록 한다. 만약 유저가 로그인 되어있지 않다면, 위에서 보여준 바와 같이, queryRoots() 호출은 empty cursor를 보내준다. 이를 통해 유저가 공급자에 로그인이 되어있는 경우에만 공급자의 문서에 접근이 가능하도록 해준다.

private void onLoginButtonClick() {
loginOrLogout();
getContentResolver().notifyChange(DocumentsContract
.buildRootsUri(AUTHORITY), null);
}

--

--

marojun
marojun’s Android

전슬마로. KTH, SK Planet, NCSOFT 에서 iOS와 Android를 개발하고 있다. — 안드로이드 개발 그룹 https://www.facebook.com/groups/junsle/