Xamarin.FormsでAndroid向けTwitterアプリケーションを開発したときに困ったこと ~メディア編~

N.Mです．

[こちらの記事](https://qiita.com/HexagramNM/items/ccbaf1345c4109e2e626)の後編です．ここでは，`Xamarin.Forms`でメディア周りの処理で困ったことと解決策をまとめました．


# Twitterへのメディア投稿

参考：[https://github.com/kwmt/WebViewInputSample](https://github.com/kwmt/WebViewInputSample)

デフォルトの状態でも、Twitterのメディアの投稿自体はブラウザと同様にできるのですが、JPEG画像はできても、PNGやGIF、動画については投稿できませんでした。デフォルトだと、HTMLの`<input>`タグの`accept`オプションで複数種類のメディアをサポートしていても、1番先頭にあるものしか認識されないみたいです。

投稿するメディアの選択ダイアログを開く時には、[前回](https://qiita.com/HexagramNM/items/ccbaf1345c4109e2e626)にも出てきた`FormsWebChromeClient`クラスの`OnShowFileChooser`メソッドが呼ばれるようです。ここを書き換えて、複数種類のメディアを選択できるダイアログの`Intent`を起動します。

```C#
//FormsWebChromeClientクラス内

//mainActivityはXamarin.AndroidでのMainActivity。FormsWebChromeClientにstatic変数として持たせて、
//MainActivityのOnCreateでそのstatic変数にMainActivity自身を渡す。

//REQUEST_IMAGE_CODEにはFormsWebChromeClient内で適当な値に設定しています。

public override bool OnShowFileChooser(Android.Webkit.WebView webView, IValueCallback filePathCallback, FileChooserParams fileChooserParams)
{
    Intent intent = new Intent(Intent.ActionOpenDocument);
    intent.AddCategory(Intent.CategoryOpenable);
    intent.SetType("*/*");
    intent.PutExtra(Intent.ExtraMimeTypes, fileChooserParams.GetAcceptTypes());
    mainActivity.intentCallback = filePathCallback;
    mainActivity.StartActivityForResult(intent, REQUEST_IMAGE_CODE);

    return true;
}
```

複数種類メディアを選択できるようにするのに`intent.PutExtra(Intent.ExtraMimeTypes, fileChooserParams.GetAcceptTypes());`が必要です。また、複数回メディア投稿ボタンを押しても機能するように、MainActivityでIntentのからの結果を処理する際に`filePathCallback.onReceiveValue`メソッドを呼ぶ必要があります。

`MainActivity`に変数`intentCallback`をもたせて、`mainActivity.intentCallback = filePathCallback;`で登録することで、`MainActivity`内で呼べるようにしてあります。

MainActivityでは上記で起動した`Intent`からの結果を処理するように、下記のように`OnActivityResult`メソッドをオーバーライドします。

```C#
//MainActivity内

//intentCallback
//REQUEST_IMAGE_CODEにはFormsWebChromeClientでの値と同じにする。

public IValueCallback intentCallback;

protected override void OnActivityResult(int requestCode, Result resultCode, Intent resultData)
{
    if (requestCode == REQUEST_IMAGE_CODE)
    {
        if (resultCode == Result.Ok)
        {
            intentCallback.OnReceiveValue(new Android.Net.Uri[] { resultData.Data });
            intentCallback = null;
        }
        else if (resultCode == Result.Canceled)
        {
            intentCallback.OnReceiveValue(null);
            intentCallback = null;
        }
    }
}
```

これで、起動した`Intent`に対し、ファイルが選ばれた場合も、キャンセルされた場合も処理されるようになっております。`FormsWebChromeClient`と`MainActivity`を修正することで、JPEG以外のPNGなどの画像や、動画も選択できるようになり、投稿できるようになります。


# ストレージ参照の許可

参考：[https://docs.microsoft.com/ja-jp/xamarin/android/app-fundamentals/permissions?tabs=windows](https://docs.microsoft.com/ja-jp/xamarin/android/app-fundamentals/permissions?tabs=windows)

画像の保存をできるようにするためには、アプリケーションに権限を付与する必要があり、これも`Xamarin.Forms`だけではできず、Android側での処理が必要になります。

まず、Androidマニフェストでこのアプリがどの権限を使うかを指定する必要があります。Visual Studioの場合はAndroidプロジェクトのプロパティから指定できます。保存の場合はここで`WRITE_EXTERNAL_STORAGE`を指定します。

これだけだと、アプリのユーザが設定で権限を有効にしない限りは、アプリに権限が付与されないので、起動時に権限がなければユーザに権限を付与する許可を得るためのダイアログを開くようにする必要があります。Androidプロジェクト側の`MainActivity`の`OnCreate`メソッドで以下を呼び出します。

```C#
//yourCodeは26である必要はなく、適当な数字で大丈夫ですが、後述のOnRequestPermissionResultでのものと一致させる必要はあります。

const int yourCode = 26;
if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.WriteExternalStorage) != (int)Permission.Granted)
{
    ActivityCompat.RequestPermissions(this, new String[] { Manifest.Permission.WriteExternalStorage }, yourCode);
}
```

許可を得られたかどうかの結果を確認するために、`MainActivity`の`OnRequestPermissionResult`メソッドをオーバーライドします。

```C#
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
{
    const int yourCode = 26;
    if (requestCode == yourCode)
    {
        for (int i = 0; i < grantResults.Length; i++)
        {
            if (grantResults[i] != Permission.Granted)
            {
                Android.OS.Process.KillProcess(Android.OS.Process.MyPid());
            }
        }
    }
    else
    {
        base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}
```

上記の実装では許可が得られなかったら、`if (grantResults[i] != Permission.Granted)`の中で正常に機能しないとしてアプリケーションを落としていますが、保存処理だけできないようにフラグを立てるなどというように修正すれば、一部機能制限に変更することもできます。


# UrlからメディアのByte情報を取得

参考：[https://stackoverflow.com/questions/41337487/how-to-download-image-from-url-and-save-it-to-a-local-sqlite-database](https://stackoverflow.com/questions/41337487/how-to-download-image-from-url-and-save-it-to-a-local-sqlite-database)

Xamarin.FormsのWebViewだと、画像長押しによる画像の保存ができなかったので、動画ダウンロードもできるようにするために保存処理を自前で作ることにしました。URL自体は画像の場合はHTMLから、動画の場合はTwitter REST APIから取得できましたが、このURLからAndroidに保存するデータ（Byte情報）の取得で少しつまづき、調べました。

どうやら、`System.Net.Http`の`HttpClient`を利用すれば、実現できるようです。これを使うことで、UrlのところにHttpでデータを要求し、返ってきたレスポンスから画像や動画などのByte情報を取得できます。

```C#
//Timeoutの時間も設定できます。
HttpClient httpClient = new HttpClient{Timeout = TimeSpan.FromSeconds(15)};

//取得した画像や動画のByte列を格納する変数
byte[] imageData;

//downloadUrlは画像や動画のUrl
using (HttpResponseMessage httpResponse = await httpClient.GetAsync(downloadUrl))
{
    if (httpResponse.StatusCode == System.Net.HttpStatusCode.OK)
    {
        //正常に取得できたというレスポンス（System.Net.HttpStatusCode.OK）ならデータを取得
        imageData = await httpResponse.Content.ReadAsByteArrayAsync();
    }
}

```

# DCIMフォルダへのメディアの保存

参考：[https://www.c-sharpcorner.com/article/local-file-storage-using-xamarin-form/（PCLStorageについて）](https://www.c-sharpcorner.com/article/local-file-storage-using-xamarin-form/)
[https://forums.xamarin.com/discussion/175085/i-need-to-save-ad-in-image-in-dcim（DCIMのパスについて）](https://forums.xamarin.com/discussion/175085/i-need-to-save-ad-in-image-in-dcim)

今回はDCIMフォルダにダウンロードしてきた画像や動画を保存することにしました。

ファイルシステムはプラットフォームごとに全然違います。前に`Xamarin.Android`のみで画像のロードや、ロードした先に追加保存などをやろうとしたときは、画像ロードのIntentから取得できるものがUrlなので、このUrlをMesiaStoreに投げて、パスに変換するなど結構大変でした。少し身構えていましたが、DCIMフォルダに新規に画像を保存するだけなら、そんなに大変ではないみたいです。

また`PCLStorage`を使えば、`Xamarin.Forms`のプロジェクトで、各プラットフォーム共通の処理として、データ保存処理を書けるようです。（パスの取得は各プラットフォームごとに処理を書く必要がありますが）

`PCLStorage`では`Xamarin.Forms`プロジェクト内で以下のようにフォルダ作成や保存処理を書くことができます。

```C#
//DCIMフォルダの取得（DCIMPathの取得は後述）
IFolder DCIMFolder = await FileSystem.Current.GetFolderFromPathAsync(DCIMPath);

//DCIM内に別につくる保存用フォルダ
IFolder saveFolder;

//保存用フォルダがすでにあれば取得、なければ新規作成
ExistenceCheckResult exist = await DCIMFolder.CheckExistsAsync(saveFolderName);
if (exist == ExistenceCheckResult.FolderExists)
{
    saveFolder = await DCIMFolder.GetFolderAsync(saveFolderName);
}
else
{
    saveFolder = await DCIMFolder.CreateFolderAsync(saveFolderName, CreationCollisionOption.ReplaceExisting);
}

//保存するファイルを新規作成
IFile file = await saveFolder.CreateFileAsync(saveFileName, CreationCollisionOption.ReplaceExisting);

//ファイルに画像や動画のByte情報書き込み
using (System.IO.Stream stream = await file.OpenAsync(PCLStorage.FileAccess.ReadAndWrite))
{
    stream.Write(imageData, 0, imageData.Length);
}
```

DCIMパスの取得は`Xamarin.Android`側で行う必要があります。前回も触れた`WebViewRenderer`といったカスタムレンダラでパスを取得し、連携した`Xamarin.Forms`側のクラス（`WebView`）に渡せば、大丈夫です。

```C#
//Xamarin.AndroidでのDCIMパスの取得
string path = Path.Combine(Android.OS.Environment.ExternalStorageDirectory.AbsolutePath, "DCIM");
```


# 保存後のアルバムへの反映

（なんかのサイトを調べて、知ったはずなのですが、どのサイトか忘れてしまいました...）

ただ保存しただけだと、メディアに保存した画像が表示されません。保存した画像をメディアに通知しないと、Androidを再起動するまではメディアでは表示されません。

この通知は`Xamarin.Android`のカスタムレンダラ（`WebViewRenderer`など）のコンストラクタで渡される`Context`から行うことができます。

`Xamarin.Forms`で呼び出すならば、以下を呼び出す`Action`を`WebViewRenderer`で作っておき、`WebViewRenderer`と連携している`WebView`に作った`Action`を渡すようにしましょう。

```C#
//_contextというメンバ変数をWebViewRendererに用意しておき、コンストラクタで引数のContextを代入しておく。

//imagePathは、「DCIMフォルダへのメディアの保存」のプログラムにあるIFile型のfileからfile.Pathで取得できる。

_context.SendBroadcast(new Intent(Intent.ActionMediaScannerScanFile, Android.Net.Uri.Parse("file://" + imagePath)));
```

#まとめ

`PCLStorage`を使えば、ファイル保存処理は共通処理として`Xamarin.Forms`に書けますが、メディア周りに関しても、少し複雑なことをしようとすると、すぐ`Xamarin.Android`などで各プラットフォームごとに処理を書かないといけないみたいです。