Unityで画像をアルバムに保存してみた

対象はAndroidとiOS。

## 必要なライブラリ
iOSでは`Photos.framework`を使う。出力されたXcodeプロジェクトのGeneralタブの`Linked Frameworks and Libraries`に足す。
<img width="308" alt="スクリーンショット 2018-06-02 12.42.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/9104/49849e58-fb4a-0471-cf9b-782304e3b0ab.png">

iOS 7以前もケアするなら`AssetsLibrary.framework`も必要だが、AppleもiOS 9以降しかサポートしていないし今更要らないだろうと思う。

[OnPostProcessBuild](https://docs.unity3d.com/ScriptReference/Build.IPostprocessBuildWithReport.OnPostprocessBuild.html)を使って設定するようにしておくと楽。例えばこんな感じ。

```csharp
var pbxProject = new PBXProject();
var targetGUID = pbxProject.TargetGuidByName(PBXProject.GetUnityTargetName());
pbxProject.AddFrameworkToProject(targetGUID, "Photos.framework", false);
```

## 必要な権限
### iOS
`PBXProject`の`NSPhotoLibraryAddUsageDescription`に何か書く。
出力されたXcodeプロジェクトのInfoタブの`Custom iOS Target Properties`に足す。
プロジェクト上の表示名は`Privacy - Photo Library Additions Usage Description`になっている。
<img width="482" alt="スクリーンショット 2018-06-02 12.53.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/9104/a5affd32-cf27-d7c3-b8c9-fe3a2eeee28a.png">
ただの説明書きだが、無いとアプリが強制終了する。
あとこの説明は確認ダイアログに表示される（つまりユーザーの目に触れる）。

これも[OnPostProcessBuild](https://docs.unity3d.com/ScriptReference/Build.IPostprocessBuildWithReport.OnPostprocessBuild.html)を使って設定するようにしておくと楽。

```csharp
PlistDocument plist = new PlistDocument();
plist.ReadFromString(File.ReadAllText(plistPath));
PlistElementDict rootDict = plist.root;
rootDict.SetString("NSPhotoLibraryUsageDescription", "ほげふがぴよぴよ");
File.WriteAllText(plistPath, plist.WriteToString());
```

### Android
`WRITE_EXTERNAL_STORAGE`。
`Assets/Plugins/Android/AndroidManifest.xml`に`<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />`を書き足す。

## ユーザーへの権限問合せ
保存タイミングではなく事前に権限を確認する。

### iOS
iOSでは初回に一度だけ下記2つのボタンがある権限確認ダイアログが表示される。

- 許可（OK）
- 拒否（許可しない）

拒否した場合は、有効にするには設定アプリから許可してもらうしかない。
「設定」アプリ→プライバシー→ストレージ→対象アプリ名 をONにする。

`[PHPhotoLibrary authorizationStatus]`で状況を確認し、未確認の場合は`[PHPhotoLibrary requestAuthorization:]`で問い合わせる（ダイアログが表示される）。

```objc
#import <Photos/Photos.h>

@interface PhotoExtention:NSObject
+ (bool)requestPermission;
@end

@implementation PhotoExtention
+ (BOOL)requestPermission {
  PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];

  if (status == PHAuthorizationStatusAuthorized) {
    return YES;
  } else if (status == PHAuthorizationStatusNotDetermined) {
    __block BOOL isAuthorized = NO;
    
    dispatch_semaphore_t sema = dispatch_semaphore_create(0);
    [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
      isAuthorized = (status == PHAuthorizationStatusAuthorized);
      dispatch_semaphore_signal(sema);
    }];
    dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
    
    return isAuthorized;
  } else {
    return NO;
  }
}

extern "C" bool _PhotoExtention_RequestPermission() {
  return [PhotoExtention requestPermission];
}
```

```csharp
[System.Runtime.InteropServices.DllImport( "__Internal" )]
private static extern bool _PhotoExtention_RequestPermission();
if (_PhotoExtention_RequestPermission())
{
    // 保存処理
}
else
{
    // 拒否された時の処理
}
```


### Android
許可するか永続拒否するまでは何度でも確認ダイアログが表示される。

1回目は「許可」/「許可しない」ボタン。
2回目以降「許可」/「許可しない」ボタン+「今後表示しない」チェックボックス。

なのでパターンとしては以下の通り。

- 許可
- 一時拒否（許可しない）
- 永続拒否（許可しない＋今後表示しない）

基本的に一時拒否になるので、再度アクセスしてもらって許可してもらう事ができる。
ちなみに永続拒否した場合、「設定」アプリ→アプリと通知→対象アプリ名 →権限→ストレージをONにする（おそらくベンダーやOSによって場所が異なる）。

多くのアプリでは、アプリ起動時に`AndroidManifest.xml`に記載しておいて、起動時にすべて許可しないとアプリを終了といった設計になっている事が多いが、稀に権限付与のタイミングを遅らせたい時がある。

その場合はAndroidのAPIで`requestPermissions`する。
戻りは`UnityPlayerActivity.onRequestPermissionsResult`で受け取って、`UnityPlayer.UnitySendMessage`でUnity側に戻す。

という諸々を対応してくれるUnityプラグインがあるので、基本的にはそれを使う。
https://github.com/sanukin39/UniAndroidPermission

## 保存処理

### iOS
フォトライブラリーに保存する専用メソッドが準備されているので、それを使う。
ネイティブメソッドの設計上、即座に戻ってこないので、コールバック先のオブジェクトを確保しておく必要がある。
`[PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:]`と`[[PHPhotoLibrary sharedPhotoLibrary] performChanges:]`を利用して「写真」に保存し、戻りを`UnitySendMessage`でどこかにコールバックする。

```objc
#import <Photos/Photos.h>

@interface PhotoExtention:NSObject
+ (void)saveImage:(NSString *)path albumName:(NSString *)album;
@end

@implementation PhotoExtention

+ (void)saveImage:(NSString *)path albumName:(NSString *)album {
  PHFetchOptions *fetchOptions = [[PHFetchOptions alloc] init];
  fetchOptions.predicate = [NSPredicate predicateWithFormat:@"localizedTitle = %@", album];
  PHFetchResult *fetchResult = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum subtype:PHAssetCollectionSubtypeAny options:fetchOptions];
  [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
    PHAssetChangeRequest *assetChangeRequest;
    assetChangeRequest = [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:[NSURL fileURLWithPath:path]];
    
    PHAssetCollectionChangeRequest *assetCollectionChangeRequest = [PHAssetCollectionChangeRequest changeRequestForAssetCollection:fetchResult.firstObject];
    [assetCollectionChangeRequest addAssets:@[[assetChangeRequest placeholderForCreatedAsset]]];

  } completionHandler:^(BOOL success, NSError *error) {
    [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
    
    if (success)
      UnitySendMessage("HogeObject", "OnSaveImageCompleted", "");
    else {
      UnitySendMessage("HogeObject", "OnSaveImageFailed", [self getCString:[error localizedDescription]]);
    } 
  }];
}

@end

extern "C" void _PhotoExtention_WriteImageToAlbum(const char* path, const char* album) {
  [PhotoExtention saveImage:[NSString stringWithUTF8String:path] albumName:[NSString stringWithUTF8String:album]];
}
```

```csharp
// 呼び出し側
using System.Runtime.InteropServices;
[DllImport( "__Internal" )]
private static extern void _PhotoExtention_WriteImageToAlbum(string path, string album);
_NativeGallery_ImageWriteToAlbum(path, album);

// 戻り側
public class HogeObject : MonoBehaviour
{
    public void OnSaveImageCompleted(string message)
    {
        if (callback != null)
        {
            callback(null);
        }
    }

    public void OnSaveImageFailed(string error)
    {
        if (callback != null)
        {
            callback(error);
        }
    }
}
```

### Android
保存するパスを`DIRECTORY_DCIM`から取得し、そこに適当なファイル名で書き出す。

```java
public class PhotoExtension
{
    public static String getDcimPath()
    {
        return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath();
    }
}
```

```csharp
var fileFrom = "/path/to/save";
var photoExtention = new AndroidJavaClass("com.example.PhotoExtension");
var saveDir = photoExtention.CallStatic<string>("getDcimPath");
var fileTo = Path.Combine(saveDir, "ファイル名");
File.Copy(fileFrom, fileTo, true);
```

保存したファイルを見えるようにするため、`scanFile`してインデックス情報に登録する必要がある。

```java
public class PhotoExtension
{
    public static String scanFile(String fileName)
    {
        Context context = UnityPlayer.currentActivity.getApplicationContext();
        MediaScannerConnection.scanFile(context, new String[] { path }, null, null);
    }
}
```

```csharp
var photoExtention = new AndroidJavaClass("com.example.PhotoExtension");
photoExtention.CallStatic<string>("scanFile", fileTo);
```