Android MediaStoreにおける画像保存エラーとユニークファイル名の制限

Androidアプリケーション開発において、カメラで撮影した画像をデバイスのギャラリーに保存する機能は一般的です。しかし、特定の条件下でjava.lang.IllegalStateExceptionが発生し、画像の保存に失敗するケースが報告されています。本記事では、この問題の原因と、MediaStoreへのファイル保存時にユニークなファイル名を確保するための対策について解説します。

問題の発生

アプリケーションが画像を撮影し、そのデータをシステムギャラリーに登録するためにMediaStore.Images.Media.insertImage()メソッドを利用していた際に、突然画像保存が失敗するようになりました。ログには以下のようなIllegalStateExceptionが記録されていました。

E DatabaseUtils: Writing exception to parcel
E DatabaseUtils: java.lang.IllegalStateException: Failed to build unique file: /storage/emulated/0/Pictures/Image.jpg bucket_display_name=Pictures volume_name=external_primary date_modified=null date_expires=null _display_name=Image.jpg mime_type=image/jpeg _data=/storage/emulated/0/Pictures/Image.jpg _size=null is_trashed=0 is_pending=0 bucket_id=-1617409521 relative_path=Pictures/
E DatabaseUtils: 	at com.android.providers.media.MediaProvider.ensureFileColumns(MediaProvider.java:2624)
E DatabaseUtils: 	at com.android.providers.media.MediaProvider.ensureUniqueFileColumns(MediaProvider.java:2368)
...
W MediaStore: Failed to insert image
W MediaStore: java.lang.IllegalStateException: Failed to build unique file: /storage/emulated/0/Pictures/Image.jpg bucket_display_name=Pictures volume_name=external_primary date_modified=null date_expires=null _display_name=Image.jpg mime_type=image/jpeg _data=/storage/emulated/0/Pictures/Image.jpg _size=null is_trashed=0 is_pending=0 bucket_id=-1617409521 relative_path=Pictures/
W MediaStore: 	at android.os.Parcel.createExceptionOrNull(Parcel.java:2381)
W MediaStore: 	at android.os.Parcel.createException(Parcel.java:2357)
...
W MediaStore: 	at android.provider.MediaStore$Images$Media.insertImage(MediaStore.java:2097)

このログから、エラーがMediaStore.Images.Media.insertImage()の内部で発生しており、「Failed to build unique file」というメッセージが鍵であることがわかります。

問題の原因分析

java.lang.IllegalStateException: Failed to build unique fileというメッセージは、システムが新しいファイルを保存しようとした際に、一意のファイル名を生成できなかったことを示しています。

MediaStore.Images.Media.insertImage()メソッドは、画像のビットマップやパスを基にシステムギャラリーに画像を登録する簡易的なヘルパーです。このメソッドの引数にファイル名(display name)をnullとして渡すと、システムはデフォルトで「Image.jpg」のような汎用的なファイル名を自動的に割り当てようとします。もし同じディレクトリに同名のファイルが存在する場合、システムは自動的にサフィックスを追加してユニークなファイル名を生成します。例えば、「Image.jpg」があれば次に「Image(1).jpg」、「Image(2).jpg」というように連番を振ります。

しかし、この自動的なユニーク名生成メカニズムには隠れた上限が存在します。Androidの内部実装、特にMediaProviderが利用するファイルユーティリティ(FileUtils.buildUniqueFileWithExtension()のようなメソッド)では、この括弧付きの連番サフィックス(例:(32))の生成数に上限が設けられています。一般的に、この上限は32個(つまり、Image.jpgからImage(32).jpgまで、合計33個のファイル)とされており、これを超えるユニークなファイル名が必要になった場合に、上記のエラーIllegalStateExceptionがスローされます。

つまり、アプリケーションがファイル名を指定せずにinsertImage()を繰り返し呼び出し、同一ベース名の画像が大量に作成された結果、システムのユニーク名生成能力が限界に達したことが、エラーの根本原因です。

解決策:ユニークなファイル名の明示的な指定

この問題を解決するには、MediaStoreに画像を保存する際に、アプリケーション側で明示的にユニークなファイル名を指定することが不可欠です。MediaStore.Images.Media.insertImage()はシンプルですが、より詳細な制御と将来の互換性を考慮すると、ContentResolverContentValuesを使用する方法が推奨されます。

以下に、現在のタイムスタンプを基にユニークなファイル名を生成し、ContentValuesを利用してMediaStoreに画像を挿入する例を示します。これにより、システムの自動命名機能による上限に依存することなく、確実にユニークなファイルを保存できます。

import android.content.ContentResolver;
import android.content.ContentValues;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import java.io.IOException;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

public class MediaStoreFileSaver {

    /**
     * Bitmap画像をMediaStoreに保存し、ギャラリーに表示されるようにします。
     * 明示的にユニークなファイル名を指定することで、ファイル生成エラーを回避します。
     *
     * @param resolver ContentResolverインスタンス
     * @param imageBitmap 保存するBitmap画像
     * @param subFolder アプリ固有のサブフォルダ名(例: "MyAppImages")。nullまたは空の場合はPictures直下。
     * @return 保存された画像のUri、または失敗した場合はnull
     */
    public static Uri saveBitmapToGallery(ContentResolver resolver, Bitmap imageBitmap, String subFolder) {
        // タイムスタンプを基にユニークなファイル名を生成
        String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date());
        String uniqueFileName = "IMG_" + timestamp + ".jpg";
        String mimeType = "image/jpeg";

        ContentValues mediaDetails = new ContentValues();
        mediaDetails.put(MediaStore.Images.Media.DISPLAY_NAME, uniqueFileName);
        mediaDetails.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
        mediaDetails.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000);
        mediaDetails.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);

        Uri imageCollection;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Android 10 (API 29)以降のScoped Storage対応
            String relativePath = Environment.DIRECTORY_PICTURES;
            if (subFolder != null && !subFolder.isEmpty()) {
                relativePath += "/" + subFolder;
            }
            mediaDetails.put(MediaStore.Images.Media.RELATIVE_PATH, relativePath);
            mediaDetails.put(MediaStore.Images.Media.IS_PENDING, 1); // 書き込み中は保留状態にする
            imageCollection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
        } else {
            // API 28以下の従来のパス指定
            // この方式はAPI 29以降で非推奨または動作しない可能性があります。
            @SuppressWarnings("deprecation")
            String picturesDirPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toString();
            if (subFolder != null && !subFolder.isEmpty()) {
                picturesDirPath += java.io.File.separator + subFolder;
            }
            java.io.File directory = new java.io.File(picturesDirPath);
            if (!directory.exists() && !directory.mkdirs()) {
                // ディレクトリ作成失敗をハンドリング
                return null;
            }
            java.io.File targetFile = new java.io.File(directory, uniqueFileName);
            mediaDetails.put(MediaStore.Images.Media.DATA, targetFile.getAbsolutePath());
            imageCollection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
        }

        Uri finalImageUri = null;
        OutputStream outputStream = null;

        try {
            finalImageUri = resolver.insert(imageCollection, mediaDetails);
            if (finalImageUri == null) {
                // MediaStoreへのエントリ挿入に失敗
                return null;
            }

            outputStream = resolver.openOutputStream(finalImageUri);
            if (outputStream == null) {
                // OutputStreamの取得に失敗。作成したエントリを削除してクリーンアップ。
                resolver.delete(finalImageUri, null, null);
                return null;
            }

            // BitmapをOutputStreamに圧縮・書き込み
            imageBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream);
            outputStream.flush();

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                // Scoped Storage: 書き込み完了後、保留状態を解除
                mediaDetails.clear();
                mediaDetails.put(MediaStore.Images.Media.IS_PENDING, 0);
                resolver.update(finalImageUri, mediaDetails, null, null);
            }
            return finalImageUri; // 成功したUriを返す
        } catch (IOException e) {
            e.printStackTrace();
            // エラー発生時、作成したMediaStoreエントリがあれば削除してクリーンアップ
            if (finalImageUri != null) {
                resolver.delete(finalImageUri, null, null);
            }
            return null;
        } finally {
            try {
                if (outputStream != null) {
                    outputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

このアプローチにより、開発者はシステムに依存せず、常にユニークなファイル名を確保できるため、IllegalStateException: Failed to build unique fileの発生を効果的に防ぐことができます。

タグ: Android MediaStore IllegalStateException Scoped Storage ContentResolver

6月22日 17:42 投稿