この記事に関して
OSアップデートしたら、自分が開発して自分で使ってるAndroidアプリにおいて、画像回りの動作がおかしくなってしまいました。動作を要約すると、他の画像を読み込んで、決まった加工を施して別画像に保存するアプリです。
※GooglePlayで公開はしているものの、ほぼ自分用のアプリ
その対応に四苦八苦したので、他の皆さんの参考になればと思いポイントをまとめたものになります。
Android Virtual Device で Android 11(API30)起動する時のトラブル
エラー確認の為には当然エミュレーターで動作確認しますが、起動すると The emulator process for AVD 5.1_WVGA_API_30 was killed.
とかのエラーが出てしまってました。
散々再インストールとかしたが改善せず。結局以下の投稿を参考にして、EmulatorをSoftwareにしたら起動できるようになりました。
Android API 30 (Android 11.0 / Android R) keeps/always getting killed
他には、必要なモジュールが無かったり、ディスク容量足りなかったりするケースもある模様です。詳細は以下動画で。こちらはAPI28以下でも発生する内容だと思うので、その症状だった場合にはこちらを参考にするといいかも。
Android API 30 (Android 11.0 / Android R) keeps/always getting killed
ファイルパスで画像を取得しようとしていた時のエラー
E/BitmapFactory: Unable to decode stream: java.io.FileNotFoundException: /storage/emulated/0/Pictures/IMG_20210424_030736.jpg: open failed: EACCES (Permission denied)
java.lang.NullPointerException: Attempt to invoke virtual method 'int android.graphics.Bitmap.getWidth()' on a null object reference
at android.graphics.Bitmap.createScaledBitmap(Bitmap.java:800)
/storage/emulated/0/Pictures/IMG_20210424_030736.jpg
というのは、画像選択してから、uri.getPath().toString();
で取得した情報を加工して取得したファイルシステム上パスです。
bmp=BitmapFactory.decodeFile(baseImgfilePath ,options);
公式ページ - DATAカラムに関する記述 を見ると、API29からアプリケーションは物理パスを使っての直接アクセスは許可されなくなったという事ですね。
This constant was deprecated in API level 29.
Apps may not have filesystem permissions to directly access this path. Instead of trying to open this path directly, apps should use ContentResolver#openFileDescriptor(Uri, String) to gain access.
だからPermissionDeniedになってしまった様です。ContentResolver#openFileDescriptor(Uri, String)を使って取得すべきという事ですね。
Uriは都度変わってしまう
ファイルシステム上パスが使えないならUriを保存しておいてそれを使えばよいと考えましたが、そうではない様です。ギャラリーなどで情報を取得すると思いますが、その結果として取得できるUriは実行の度に変更されてしまいます。一回取得したUriは、自分の想像ですが、Activityが変わったりすると使用不可能になる様です。イメージ的にはWebページで表示した時に埋め込まれるトークンで二重実行とかの不正を防いでいる感じでしょうか。
// ・・中略・・
// 選択処理開始
public void setBasePicPath(View v) {
Intent intent = new Intent(Intent.ACTION_PICK);
intent.setType("image/*");
startActivityForResult(intent, ACTIVITY_BASEPICSEL);
// ・・中略・・
// 選択後の処理
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if(resultCode == RESULT_OK){
switch(requestCode){
case ACTIVITY_BASEPICSEL:
System.out.println(data.getData().toString());
break;
// ・・中略・・
content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Fimages%2Fmedia%2F15/ORIGINAL/NONE/image%2Fjpeg/983691334
content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Fimages%2Fmedia%2F15/ORIGINAL/NONE/image%2Fjpeg/1264091695
保存先は固定して、ファイル名で情報を管理
公式ページ - データ ストレージとファイル ストレージの概要 によると画像はメディアに属すると思います。アプリ固有のファイルとする事も出来そうですが、特にこだわりないので扱いやすそうなメディアファイルとして扱う事にしました。
公式ページ - ファイルの場所に関するヒントを提供する を読むと、特定のフォルダ以下の相対フォルダ内に画像を出するという事は出来るという事で、自分のアプリ用のフォルダを作成してそこで管理するのがよさそうです。
実際のアクセスにはUriが必要だけど、UriはDBなどに保存して使いまわせる情報ではない(と思う)ので、ファイル名で判断する様な感じです。IDは今の所不変っぽいですが、怖いので今のうちにファイル名判断にします。
自分はこんな感じで、ファイル名から情報取得する関数を作っておきました。ImageInfoStruct は自作クラスです。
public static ImageInfoStruct getImageInfo(ContentResolver contentResolver, String displayName, String relativePath) {
ImageInfoStruct retInfo = new ImageInfoStruct();
String[] selectionArgs = new String[]{displayName, relativePath};
try (Cursor cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
IMAGE_INFO_COLUMNS,
MediaStore.Images.Media.DISPLAY_NAME + "=? and " + MediaStore.Images.Media.RELATIVE_PATH + " =?",
selectionArgs,
null)) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME);
if (cursor.moveToNext()) {
retInfo.id = cursor.getInt(idColumn);
retInfo.displayName = cursor.getString(nameColumn);
retInfo.uri = ContentUris.withAppendedId(targetUri, retInfo.id);;
return retInfo;
} else {
return null;
}
}
}
※同名ファイルがあると問題になるはずです。ただ、保存時同名ファイルがあると(1)とか自動で付くっぽいので実際には問題にならないのかも?Uriをいじくれば前述RelativePathで絞れるのかもしれないけど、今のところやり方を見つける事が出来ませんでした。変に上書きするのが怖いので、今の所はアプリストアに公開はせずに、後にチェック処理追加する予定です。
※2021-05-22更新:単に、RELATIVE_PATHカラムでも絞り込みすればよかっただけでした。上の関数変更しました。Query時のUriは大元だけ指定するという事ですね。
メディアファイルの更新フロー
自分がアプリを作成した時(Android4.1の頃)は、画像保存した後に他のアプリで認識できるようにするためにはメディアファイル情報をリフレッシュする必要があったと思います(当時の知識不足だけかも)。スレッド作成してその中で処理したりして結構面倒でした。今は、自分でその情報を更新する必要がありますが、反対に不要なリフレッシュは要らなくなり、結果処理が早くなったようです。具体的に言うと、以下の感じです。
- MediaStore.Images.Media.IS_PENDING ⁼ true のContentValuesを作成する
- そのContentValuesを使って、contentResolver.insert にてメディア情報用のレコードを作成し、Uriを取得。
- その時のメディアファイルの保存先はシステムに依存する。MediaStore.Images.Media.EXTERNAL_CONTENT_URIを使用。
- contentResolver.openFileDescriptor で FileDescriptorを取得。
- FileOutputStream(FileInputStream) を取得してファイル処理
- 処理が終わったら、MediaStore.Images.Media.IS_PENDING ⁼ false のContentValuesを作成する
- そのContentValuesを使って、contentResolver.update にてメディア情報用のレコードを更新する。
ContentValues newImageDetails = new ContentValues();
newImageDetails.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/BaseImage/");
newImageDetails.put(MediaStore.Images.Media.DISPLAY_NAME, srcFileName);
newImageDetails.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
newImageDetails.put(MediaStore.Images.Media.TITLE, title);
newImageDetails.put(MediaStore.Images.Media.IS_PENDING, true);
Uri destImageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, newImageDetails);
try (
ParcelFileDescriptor destPfd = contentResolver.openFileDescriptor(destImageUri, "w");
FileOutputStream destFos = new FileOutputStream(destPfd.getFileDescriptor());
) {
// destFosに対する処理を行う部分
} catch (IOException ex) {
ex.printStackTrace();
} finally {
ContentValues updImageDetails = new ContentValues();
updImageDetails.put(MediaStore.Images.Media.IS_PENDING, false);
contentResolver.update(destImageUri, updImageDetails, null, null);
}
おまけ:サムネイル対応
自分がアプリを作成した時、リスト表示用のサムネイルは自分で加工していましたが、今はその機能が標準で準備されている様子です(当時の知識不足だけかも)。
公式ページ - ファイルのサムネイルを読み込む
おまけ:DBのアップグレード
今回、ファイル名で情報を管理する事にしたので、そのカラム追加です。
新規インストールではcreate文側で新カラムが生成されるけど、既に使ってる場合にはonCreate処理が実行されないので、onUpgradeで処理を追加する必要があります。
※Create文にはカラム追加せず、onCreateで新カラム追加処理をする方向性もアリだと思うけど今回はこちらを使用。
DBのバージョンを指定
public class StickyDbHelper extends SQLiteOpenHelper {
// ・・中略・・
private static final int VERSION = 2;
// ・・中略・・
public StickyDbHelper(Context context) {
super(context, DBNAME, FACTORY, VERSION);
}
// ・・後略・・
Upgrade処理追加
古いバージョンと新しいバージョンの境目で処理されるようにしています。単一バージョン指定だと、バージョン飛びでアップグレードするケースに対応できないです。
public class HogehogeDbHelper extends SQLiteOpenHelper {
// ・・中略・・
private static final String SQL_UPGRADE_1_2_BASE_IMAGE_URI = "ALTER TABLE " + STICKY_LIST_TABLE + " ADD COLUMN " + COLUMN_BASE_IMAGE_URI + " text";
private static final String SQL_UPGRADE_1_2_OUT_IMAGE_DEST_TYPE = "ALTER TABLE " + STICKY_LIST_TABLE + " ADD COLUMN " + COLUMN_OUT_IMAGE_DEST_TYPE + " text";
private static final String SQL_UPGRADE_1_2_OUT_IMAGE_RELATIVE_PATH = "ALTER TABLE " + STICKY_LIST_TABLE + " ADD COLUMN " + COLUMN_OUT_IMAGE_RELATIVE_PATH + " text";
// ・・中略・・
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion <= 1 && newVersion >= 2){
db.beginTransaction();
try {
// add COLUMN_BASE_IMAGE_URI, COLUMN_OUT_IMAGE_DEST_TYPE, COLUMN_OUT_IMAGE_RELATIVE_PATH
db.execSQL(SQL_UPGRADE_1_2_BASE_IMAGE_URI);
db.execSQL(SQL_UPGRADE_1_2_OUT_IMAGE_DEST_TYPE);
db.execSQL(SQL_UPGRADE_1_2_OUT_IMAGE_RELATIVE_PATH);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
}
// ・・後略・・
そのままだとアップグレードのテストは1回しかできないので、古い状況でエミュレーターのバックアップとか取っておいた方が良いと思います。
参考にさせて頂いたサイト
Android 公式
アプリの権限をリクエストする
データ ストレージとファイル ストレージの概要
共有ストレージからメディア ファイルにアクセスする
ファイルのサムネイルを読み込む
stack overflow
Android API 30 (Android 11.0 / Android R) keeps/always getting killed
How to Fix Exception ‘open failed: EACCES (Permission denied)’ on Android
YouTube
The Emulator process for AVD was killed Android Studio : 4 solutions
個人ブログ
[Android] Storage Access Framework でフォトアプリから画像を取り出す
Android Qからの画像保存
アンドロイド - MediaStoreにメディアファイルを保存する方法