Android QのScoped StorageでJPEG/MPEG撮影→外部Storageに保存→EXIF編集

# TL;DR

Android QからScoped StorageのFeatureによってCameraで撮影したJPEG/MPEGを外部ストレージ直下の任意Directoryに保存することができなくなりました．

`DCIM`や`Picture`/`Video`のような汎用Directoryに保存することになりますが，それでもAndroid PまでのようにExternal StorageのPathに直接ファイルを保存することはできません．

そのため，`MediaStore` (`ContentProvider`/`ContentResolver`)を使ってExternal Storageの汎用Directoryにファイルを保存するようにします．

撮影したJPEGのEXIFを`ExifInterface`で自力で編集している場合も直接External StorageのPathからは読み書きできないので，`MediaStore`経由で読み書きするようにします．



# Permissionについて

Android PまではExternal Storageへの保存のために，↓ の宣言が必要でしたが，`MediaStore`経由で保存するようにするため，必要なくなりました．

```xml:AndroidManifest.xml
<!-- Android Q では無視される．SettingsのApp権限画面にも出てこない -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
```


# JPEG保存 + EXIF編集

ざっくりの流れは ↓ のような感じです．

```
1. MediaStoreに保存するJPEGファイルのURIを作成 (作成中Flagを立てておく)
   ↓
2. URIのOutputStreamにファイルを書き込み
   ↓
3. ExifInterfaceからURIのFileDescriptorを読み込み/編集/書き込み
   ↓
4. URIの作成中Flagを落とす
   ↓
5. MediaStore経由で他App(Album等)からJPEGが見えるようになる
```

### 1. 保存するJPEGファイルのURIを作成 (作成中Flagを立てておく)

```kotlin
// @param context ApplicationのContext
// @param fileName 保存するFile名 (拡張子なし)
// @return 書き込み可能なURI
fun openJpegUri(context: Context, fileName: String): Uri? {
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, "$fileName.jpg")
        put(MediaStore.Images.Media.TITLE, fileName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM) // 保存先Directory Pathの指定
        put(MediaStore.Images.Media.IS_PENDING, 1) // 保存中Flag
    }

    val storageUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
    val contentUri: Uri? = context.contentResolver.insert(storageUri, values)

    return contentUri
}
```
`MediaStore.Images.Media.RELATIVE_PATH`の値でSub Directoryを作成するか，など制御できます．

`MediaStore`以下のどのclassの`getContentUri()`を使うかで，`RELATIVE_PATH`に指定するtop directoryに何を使えるか，があらかじめ決まっているようです．
(たとえば，`MediaStore.Images.Media.getContentUri()`だと`picture/**`か`DCIM/**`のどちらかしか使えない，使うと例外発生)


### 2. URIのOutputStreamにファイルを書き込み

```kotlin
// @param context ApplicationのContext
// @param jpeg 撮影したJPEGファイルのbyte配列
// @return 書き込み成否
fun storeJpeg(context: Context, uri: Uri, jpeg: ByteArray): Boolean {
    val os = context.contentResolver.openOutputStream(uri)

    if (os != null) {
        os.write(jpeg)
        os.flush()
        os.close()
    } else {
        return false
    }

    return true
}
```


### 3. ExifInterfaceからURIのFileDescriptorを読み込み/編集/書き込み

```kotlin
// @param context ApplicationのContext
// @param uri 保存したJPEGのURI
fun modJpegExif(context: Context, uri: Uri) {
    val fd = context.contentResolver.openFileDescriptor(uri, "rw")?.fileDescriptor

    if (fd != null) {
        val exif = ExifInterface(fd)

        // setGpfInfo()などでEXIF情報を編集

        exif.saveAttributes()
    }
}
```

`ExifInterface`の中で，
　FDからJPEG読込 → appのcache directoryに保存/EXIF編集 → FDに編集後のJPEG書込
という処理が行われているので，”rw”モードでopenしなければ書込時に例外発生で失敗します．

URIに`IS_PENDING == 1`の保存中Flagが立っていないと，他のApp/ServiceかMediaScannerがFileを開いてしまうのか，
`openFileDescriptor()`で返ってきたFDが書き込み時に`EBADFD`の例外発生で失敗してしまうことがあります．

> 詳しく追ってないですが，`IS_PENDING`の値を true/false にしたときも`EBADFD`のエラーが発生していました．boolean値が入りそうな名前ですが，この例のとおり 1/0 でないとダメかもしれません，
> 公式Docのサンプルでは 1/0 になっています．


### 4. URIの作成中Flagを落とす

```kotlin
// @param context ApplicationのContext
// @param uri 保存したJPEGのURI
fun closeJpegUri(context: Context, uri: Uri) {
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.IS_PENDING, 0)
    }
    context.contentResolver.update(uri, values, null, null)
}
```


### 5. MediaStore経由で他App(Album等)からJPEGが見えるようになる

`IS_PENDING == 0`をセットして以降，↓ のようなコードで`DCIM/`以下に保存したJPEGが見えるようになります．

```kotlin
val targetUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

val projection: Array<String> = arrayOf(
        MediaStore.Images.Media.DISPLAY_NAME,
        MediaStore.Images.Media.RELATIVE_PATH,
)

val cursor = context.contentResolver.query(
        targetUri,
        projection,
        "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?",
        arrayOf("${Environment.DIRECTORY_DCIM}/%"),
        null)

if (cursor != null) {
    val displayNameIndex = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
    val relPathIndex = cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)

    if (cursor.count > 0) {
        cursor.moveToFirst()

        while (!cursor.isAfterLast) {
            val name = cursor.getString(displayNameIndex)
            val relPath = cursor.getString(relPathIndex)

            // $relpath$name == "DCIM/xxxx.jpg" 

            cursor.moveToNext()
        }
    }

    cursor.close()
}
```


# MPEG保存

MPEGの場合もJPEGとやることはほぼ同じです．

```
1. MediaStoreに保存するMPEGファイルのURIを作成 (作成中Flagを立てておく)
   ↓
2. URIのFileDescriptorにMediaRecorder/MediaMuxerから書き込み
   ↓
3. URIの作成中Flagを落とす
```

### 1. MediaStoreに保存するMPEGファイルのURIを作成 (作成中Flagを立てておく)

```kotlin
fun openMpegUri(context: Context, fileName: String): Uri? {
    val values = ContentValues().apply {
        put(MediaStore.Video.Media.DISPLAY_NAME, "$fileName.mp4")
        put(MediaStore.Video.Media.TITLE, fileName)
        put(MediaStore.Video.Media.MIME_TYPE, "video/avc")
        put(MediaStore.Video.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
        put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM) // 保存先Directory Pathの指定
        put(MediaStore.Video.Media.IS_PENDING, 1) // 保存中Flag
    }

    val storageUri = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
    val contentUri: Uri? = context.contentResolver.insert(storageUri, values)

    return contentUri
}
```


### 2. URIのFileDescriptorにMediaRecorder/MediaMuxerから書き込み

```kotlin
// MediaRecorderの場合
val fd = context.contentResolver.openFileDescriptor(mpegUri, "rw")?.fileDescriptor
if (fd != null) {
    val recorder = MediaRecorder()
    recorder.setOutputFormat()
    recorder.setOutputFile(fd)
    recorder.prepare()
}

// MediaMuxerの場合
val fd = context.contentResolver.openFileDescriptor(mpegUri, "rw")?.fileDescriptor
if (fd != null) {
    val mpegMuxer = MediaMuxer(
            fd,
            MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
}
```


### 3. URIの作成中Flagを落とす

```kotlin
fun closeMpegUri(context: Context, uri: Uri) {
    val values = ContentValues().apply {
        put(MediaStore.Video.Media.IS_PENDING, 0)
    }
    context.contentResolver.update(uri, values, null, null)
}
```


# おわり

JPEG/MPEGの撮影/保存をAndroid Q Scoped Storageに対応させました．
Android QではまだLegacy Supportで回避できるようですが，Android Rでは完全対応必須という話もあるようなので，このあたりで完全対応しておく必要がありそうです．

AndroidはOS UpdateのたびにStorageまわりのAPIの挙動も仕様もガラガラ変わって大変でしたが，Scoped Storageでだいぶ抽象化がすすんだので，これでしばらくは安定してくれるかなあ．

---///