2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

TL;DR

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

DCIMPicture/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経由で保存するようにするため,必要なくなりました.

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を立てておく)

// @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にファイルを書き込み

// @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を読み込み/編集/書き込み

// @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を落とす

// @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が見えるようになります.

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を立てておく)

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から書き込み

// 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を落とす

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でだいぶ抽象化がすすんだので,これでしばらくは安定してくれるかなあ.

---///

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?