Flutterで画像をアルバムに保存する

# about

Flutterで画像を保存するときアルバムに保存したい

モバイル向け
- Androidではフォルダの中に保存
- iOSではアルバムに保存

# 環境
- macOS Ventura 13.5 （22G74）
- Dart SDK version: 3.1.3
- Flutter 3.13.6
- Android Studio Giraffe 2022.3.1
- Xcode 14.3.1



# How to
ライブラリは[photo_manager](https://pub.dev/packages/photo_manager)を利用します。
[wechat_assets_picker](https://pub.dev/packages/wechat_assets_picker)を利用している場合、wechat_assets_pickerがphoto_managerに依存しているので追加インポートは不要です。

要点は以下
- `PhotoManager.editor.saveImageWithPath()`を利用して画像を保存
- iOSでは`PhotoManager.editor.copyAssetToPath()`を利用してアルバムに紐付け

プラットフォームごとに処理の分岐が必要なので以下で説明します。

## Android

`/storage/emulated/0/Pictures/`以下にフォルダを作成して保存します。
LINE等、多くのアプリと同様の実装です。


#### Pictursのパスを取得

`/storage/emulated/0/Pictures`
大抵上記なのですが、内部パーティションの切り方によっては1のときもあるかも知れないので
[external_path](https://pub.dev/packages/external_path)というライブラリを使用してPicturesのパスを取得しました。

```
final picturesPath = await ExternalPath.getExternalStoragePublicDirectory(
  ExternalPath.DIRECTORY_PICTURES,
);
```

#### フォルダの作成
`Directory.create()`でフォルダを作成します。

```
final albumPath = '$picturesPath/$albumName';
dir = await Directory(albumPath).create(recursive: true);
```
`recursive: true`は無ければ作成というオプションです。
作成後は戻り値でDirectoryを取得しておきます。

#### 画像を用意する
今回はWeb上からサンプルJPEGをダウンロードしました。
PhotoManagerのsaveImageWithPath()にFileを渡す必要があるのでFileにします。
```
final path = '${dir.path}/$fileName';
final file = File(path)..writeAsBytesSync(response.bodyBytes);
```

#### 画像の保存
画像を保存します。任意のフォルダがパスになっているので保存するだけでOKです。
先程の`File`を`PhotoManager.editor.saveImageWithPath()`に渡します。
```
await PhotoManager.editor.saveImageWithPath(
  file.path,
  title: fileName,
  relativePath: albumName,
);
```
Androidでは以上で完了です。
ファイルアプリでディレクトリ構造を確認できます。
GoogleフォトではMediaStoreの同期がとれていないのか表示されない場合がありました。

## iOS

一旦TemporaryDirectoryに保存してからアルバムに紐づけます。

#### TemporaryDirectoryのパスを取得

`/data/Containers/Data/Application/{ID}/Library/Caches`を取得します。
ここではpath_providerを使いました。

```
dir = await getTemporaryDirectory();
```

iOSでは画像を先に用意する必要があるので、アルバムは後程処理します。

#### 画像を用意する
Androidと共通です。

#### 画像の保存
Androidと共通です。

#### アセットの一覧を取得
```
final paths = await PhotoManager.getAssetPathList();
```

#### アルバム名でPathEntityを取得
```
var assetPathEntity = paths.firstWhereOrNull((e) => e.name == albumName);
```
`firstWhereOrNull`は[collection](https://pub.dev/packages/collection)のオペレータです。
見つからないときに安全にnullを返してくれます。

#### アルバムを作成
アルバム≒Assetを作成します。
「無ければ作成」というメソッドではない為、上記でassetPathEntityがnullの場合にのみ作成します。
```
assetPathEntity ??= await PhotoManager.editor.darwin.createAlbum(albumName);
```

#### アルバムに紐付け
```
await PhotoManager.editor.copyAssetToPath(
  asset: assetEntity!,
  pathEntity: assetPathEntity!,
);
```
iOSでは以上で完了です。
写真.appでアルバムに保存されていることが確認できます。


# その他
以下でパーミッションの設定画面を開くことができます。
メディアアクセスが無い場合にダイアログなどで誘導してあげると親切です。
```
PhotoManager.openSetting();
```

# トラブルシューティング

- iOSでパーミッションが設定されていない
  - ios/Runner/Info.plistに以下を追加します
    - NSPhotoLibraryUsageDescription （iOS10以降）
    - NSPhotoLibraryAddUsageDescription （iOS11以降）

- photo_manager / wechat_assets_pickerをインポートしたらAndroidアプリが起動しない
  - app/build.gradleでminSdkVersionを21以上にする必要があります

- Androidでパーミッションが設定されていない
  - [photo_managerのexample](https://github.com/fluttercandies/flutter_photo_manager/blob/b3f27d3ba3093c6852d3d7e4b01392b7c6ce09d7/example/android/app/src/main/AndroidManifest.xml#L2-L11)を参考にAndroidManifestへパーミッションを追加します
  - 特に、Android 10 ではプライバシー ポリシーの問題により、位置情報と EXIF メタデータを含む元のデータを取得するには、位置情報のアクセス許可を付与する必要があります。
      - ACCESS_MEDIA_LOCATIONの追加も忘れないようにして下さい
```
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission
  android:name="android.permission.WRITE_EXTERNAL_STORAGE"
  android:maxSdkVersion="29"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
```

- AndroidでrelativePathを指定すると保存できない (またはパスを指定できない)
    - Androidでは`Pictures`以下の相対パスで指定する必要があります。そうしないと`picturesPath`が無視され仮で保存したパスに保存されます (実プロダクトへの組み込みで気付きました)
    - 間接的に以下のissueで分かりましたが難しいですね
https://github.com/fluttercandies/flutter_photo_manager/issues/865
    - `PhotoManager.editor.saveImageWithPath()`の戻り地がnullになっていると失敗なので確認して下さい

# Sample
全体を通して見ないと分かりづらいと思うので、GitHubにサンプルアプリを作成しました
https://github.com/kanari3/save_image_to_album_flutter

# refs
photo_manager
https://pub.dev/packages/photo_manager

wechat_assets_picker
https://pub.dev/packages/wechat_assets_picker

path_provider
https://pub.dev/packages/path_provider

external_path
https://pub.dev/packages/external_path

collection
https://pub.dev/packages/collection

# おわり
photo_managerの使い方、なかなか難しいですね。
iOSでは同じ名前のアルバムを複数作成することができるので、名前でフィルターするのは厳密には良くないです。
AssetPathEntityのIDで一致させれば良いのですが、その実現にはIDをどこかに保存しておく必要があります。

今回は使い方が分かりやすいよう1ファイルに流れで記載しましたが、プロダクトではAndroid/iOS共通で呼び出せるラッパーを作成するのが良いと思います。