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
経由で保存するようにするため,必要なくなりました.
<!-- 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でだいぶ抽象化がすすんだので,これでしばらくは安定してくれるかなあ.
---///