2014/02/22

Exifのサムネイル

カメラで写真を撮ると自動的にExifのメタデータが画像ファイルに記録される。というのは言うまでもないが、その一つとしてサムネイル画像も埋め込まれることはあまり意識されてないと思う。

この埋め込みサムネイル画像が実際どこまで利用されているかは知らないが、論理的には、これを利用すればサムネイル表示をする度に画像全体を読み込んでサムネイル画像を作成するより素早く表示することができる(Windowsでは一度作成したサムネイルデータをフォルダーごとに保存しているので、表示する度に作成しているわけではないが)。

ではアプリでサムネイル表示をするときにどれぐらい差があるか、確かめてみた。

1. コーディング

言語はC#で、以下のようなサムネイル表示機能を持つ.NET Framework 4.5のWPFアプリを作成した。

1.1. System.Drawingでサムネイル画像を読み出す

まずSystem.Drawing.Imageを使って画像ファイルからサムネイル画像だけ読み出す例があったので、これを参考にさせていただく。
サムネイルデータのプロパティIDは0x501B(Property Item DescriptionsのPropertyTagThumbnailData)なので、このプロパティの値を読み出してBitmapImageに出力する。画像ファイルのパスをlocalPathで指定。
private const int ThumbnailDataId = 0x501B; // Property ID for PropertyTagThumbnailData

public static BitmapImage ReadThumbnailFromExifByDrawing(string localPath)
{
    if (!File.Exists(localPath))
        return null;

    using (var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        using (var drawingImage = Image.FromStream(fs, false, false)) // System.Drawing.Image
        {
            if (!drawingImage.PropertyIdList.Any(propertyId => propertyId == ThumbnailDataId))
                return null;

            var property = drawingImage.GetPropertyItem(ThumbnailDataId);

            using (var ms = new MemoryStream(property.Value))
            {
                var image = new BitmapImage();
                image.BeginInit();
                image.CacheOption = BitmapCacheOption.OnLoad;
                image.StreamSource = ms;
                image.EndInit();

                return image;
            }
        }
    }
}
1.2. System.Windows.Media.Imagingでサムネイル画像を読み出す

WPF本来のSystem.Windows.Media.Imagingを使う方法としては、BitmapFrameのThumbnailプロパティから読み出す方法がある。
public static BitmapImage ReadThumbnailFromExifByImaging(string localPath)
{
    if (!File.Exists(localPath))
        return null;

    using (var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        var frame = BitmapFrame.Create(fs, BitmapCreateOptions.DelayCreation, BitmapCacheOption.OnDemand);
        var source = frame.Thumbnail;

        if (source == null)
            return null;

        using (var ms = new MemoryStream())
        {
            var encoder = new JpegBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(source));
            encoder.Save(ms);
            ms.Seek(0, SeekOrigin.Begin);

            var image = new BitmapImage();
            image.BeginInit();
            image.CacheOption = BitmapCacheOption.OnLoad;                    
            image.StreamSource = ms;
            image.EndInit();

            return image;
        }
    }
}
少し難しいのは、
  • サムネイル画像だけ読み出そうとすれば、FileStreamからまずBitmapFrameのCreateメソッドに流し、そのThumbnailプロパティの値だけ通す必要がある。
  • 非同期に読もうとすれば、FileStreamからまずMemoryStreamに非同期にコピーする必要がある(BitmapFrameのCreateメソッドに非同期版はないので)。
という二つが両立しないことで、前者の方を取った形。ただ、BitmapFrameのCreateメソッドでBitmapCreateOptions.DelayCreationを指定すれば、すぐには読み込まず必要になったときに非同期で読み込むっぽいので、これでいいのかもしれないが、確証はない。

1.3. 画像ファイル全体を読んでサムネイル画像を作成する

比較のために画像ファイル全体を読んでサムネイル画像を作成する方法。サイズは埋め込みサムネイル画像のサイズが160×120pixelなので、これに合わせる。
public static async Task<BitmapImage> CreateThumbnailFromImageAsync(string localPath)
{
    if (!File.Exists(localPath))
        return null;

    using (var fs = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        using (var ms = new MemoryStream())
        {
            await fs.CopyToAsync(ms);
            ms.Seek(0, SeekOrigin.Begin);

            var image = new BitmapImage();
            image.BeginInit();
            image.CacheOption = BitmapCacheOption.OnLoad;
            image.StreamSource = ms;
            image.DecodePixelWidth = 160; // Width of Exif thumbnail
            image.DecodePixelHeight = 120; // Height of Exif thumbnail
            image.EndInit();

            return image;
        }
    }
}
こちらは普通の非同期処理。

2. テスト

これらの機能を使ったときにファイルをどれだけリードしたかをProcess Monitorで計測した。対象は以下のJPGファイルで、ファイルサイズは2,617,678Bytes=2.49MiB。
Wonder Festival Signboard in Snow

まずSystem.Drawingでサムネイル画像を読み出した場合。

サムネイル画像のサイズは7,048Bytesで、これはカメラによるのだろうが、圧縮率が高いらしく劣化が一目で分かる。このときのリードは以下のように4KBの繰り返し(最後の方だけ端切れで違う)。

合計した結果、リード量は410,956Bytesで、これはファイル全体の16%に当たる。が、リードの開始位置(Offset)に着目すると、ごく細切れに何度も重複する位置を読みに行っていて、実は先頭から8KBの範囲を繰り返し4KB単位で読むというかなり効率の悪いことをやっていた。まあキャッシュがあるから、これがそのままストレージへのアクセスになっているとは限らないが……。

次にSystem.Windows.Media.Imagingでサムネイル画像を読み出した場合。

サムネイル画像のサイズは7,043Bytesで、同じデータを読み出しているはずなのに微妙に違ってたりする。このときのリードも4KBの繰り返しだが、回数は圧倒的に少ない。

合計した結果、リード量は24,609Bytesで、ファイル全体の1%以下。System.Drawingより効率よく読んでいることが分かる。

最後に画像ファイル全体を読んでサムネイル画像を作成した場合。

サムネイルデータのサイズは8,317Bytesで、劣化はほとんど目立たない。このときのリードは以下のように80KBの繰り返し(同上)。

合計した結果、リード量は2,699,598Bytesで、ファイル全体と同じ量に加えて1回余分に読んでこの数字になっていた。

3. まとめ

埋め込みサムネイル画像を利用した方がリード量が少なくて済むことが確認できた。とくにSystem.Windows.Media.Imagingを使った場合はファイル全体の1%以下で、これならファイルアクセス時間もまず問題にならないと思う。

2014/02/14

非.NET4.5でもトースト通知(補足)

トースト通知のテストアプリではトーストの文字色(=ショートカットのタイルの文字色)を背景色に応じて自動選択する形にしましたが、このアルゴリズムが色によっては今一つだったので、変えてみました。

1. アルゴリズム

MSDNの「デスクトップアプリのスタート画面のタイルをカスタマイズする方法」では、トーストの文字色と背景色の輝度の比率を4.5以上にすべしとしています。これは以下のW3Cのアクセシビリティガイドラインに従ったものです。
このガイドラインに色の輝度(relative luminance)とコントラスト比率(contrast ratio)の計算方法が示されているので、これを利用します。

まず色の輝度について、RGBの各色を以下のように数値化します。
private static double CalcColorChannel(byte channel)
{
    var buff = (double)channel / 255d;

    if (buff <= 0.03928)
        return (buff / 12.92);
    else
        return Math.Pow((buff + 0.055) / 1.055, 2.4);
}
これを元に各色に加重を掛けて輝度を計算します。どうせなのでColorの拡張メソッドにしてみました。
public static double GetLuminance(this Color source)
{
    var r = CalcColorChannel(source.R);
    var g = CalcColorChannel(source.G);
    var b = CalcColorChannel(source.B);

    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
この輝度の範囲は0(最も暗い場合)~1(最も明るい場合)になります。

次に2つの輝度からコントラスト比率を計算するには以下のようにします。明るい輝度の方が常に分子。
private double GetContrastRatio(double luminance1, double luminance2)
{
    if (luminance1 > luminance2)
        return (luminance1 + 0.05) / (luminance2 + 0.05);
    else
        return (luminance2 + 0.05) / (luminance1 + 0.05);
}
これらを使って、文字色のlight(白)とdark(黒)のそれぞれと背景色(background)とのコントラスト比率を計算し、より高い方を選択するようにします。戻り値は定義済みのForegroundColorType列挙型にしました。
internal ForegroundColorType GetHigherContrastColor(Color background)
{
    var luminanceLight = 1d; // = Colors.White.GetLuminance()
    var luminanceDark = 0d;  // = Colors.Black.GetLuminance()
    var luminanceBackground = background.GetLuminance();

    var ratioLight = GetContrastRatio(luminanceLight, luminanceBackground);
    var ratioDark = GetContrastRatio(luminanceDark, luminanceBackground);

    return (ratioLight >= ratioDark) ? ForegroundColorType.light : ForegroundColorType.dark;
}
2. テスト

前の方法ではとくに青系の色が苦手で、暗い色でもdark(黒)が選択されていました。

これがlight(白)が選択されるようになります。

他の色でも大体問題なさそうだったので、テストアプリを差し替えました。
実行ファイル
ソースコード

3. 輝度

参考までに、既定の16色の輝度を同じアルゴリズムで計算すると以下のようになります。
  •  black : 0 
  •  silver : 0.527 
  •  gray : 0.216 
  •  white : 1 
  •  maroon : 0.046 
  •  red : 0.213 
  •  purple : 0.061 
  •  fuchsia : 0.285 
  •  green : 0.154 
  •  lime : 0.715 
  •  olive : 0.200 
  •  yellow : 0.928 
  •  navy : 0.016 
  •  blue : 0.072 
  •  teal : 0.167 
  •  aqua : 0.787 
感覚的に合うような合わないような……色は難しいです。

2014/02/07

非.NET4.5でもトースト通知

Windows 8以降のトースト通知を.NET Framework 4.5以降でないアプリから使う方法について。特別新しい話はないです。

1. アウトライン

OSやアプリからユーザーに何か通知するとき、従来はよくバルーンメッセージが使われていましたが、Windows 8以降はトースト通知が使われるようになりました。このトースト通知はデスクトップアプリからでも出せますが、Windowsストアアプリ用のAPIであるWindows Runtimeを使うので、必然的に.NET Framework 4.5以降のアプリである必要があります。

ここで.NET Framework 4.5以降でアプリを作ってしまえば話は終わりですが、そうも行かないときにどうしようかな、ということで方法を考えたので、それを説明してみます。

先に、作成したテストアプリを示しておきます。このアプリ自体は.NET Framework 4.0で作成しています。

この"Headline"と"Body"の部分に入力して、"Background color"を適当に調整して、"Show a toast"を押すと、以下のようにトースト通知が出ます。

ここでトーストをクリックすると、もしこのアプリが最小化されていたり他のウィンドウの下にあっても最前面に表示し直されます。

これは割とチープな仕掛けで、
  1. トースト通知を指示するアプリ(この表示されているアプリ。.NET 4.0のWPFアプリ)
  2. トースト通知を実際に出すアプリ(.NET 4.5のコンソールアプリ)
を用意しておき、このWPFアプリ(親アプリ)からコンソールアプリ(子アプリ)を外部実行し、標準入力でトースト通知の内容を送り、それに従ってコンソールアプリはトースト通知を出し、トーストがクリックされたという反応が返ってくると、それを標準出力でWPFアプリに返し、それを受けてWPFアプリが自らを最前面に出す、という流れになっています。なぜライブラリでやらないかというと、.NETのバージョンが新しいものを古いものの方から参照して使えない(逆は可能)という問題があるからです。

こういうトースト用アプリを介するという発想は別に珍しくなくて、Lenovoのユーティティでも使っています。

テストアプリは共にC#でVisual Studio 2013により作成しています。
実行ファイル
ソースコード

2. 具体的方法

トースト通知についてはMSDNにしっかりした説明があるので、ポイントだけ触れます。

2.1 ガイドライン

初めにガイドラインを確認しておくと、出たばかりのWindows 8.1 UXガイドラインの日本語訳からトースト通知のガイドライン(内容的にはトースト通知のガイドライン (Windowsストアアプリ)と同じ)を見ると、
ユーザーがトーストをタップしたときに、アプリの適切な移動先に移動します。通知は厳密な情報更新ではなく、コンテキストの切り替えをユーザーに促すものと考えてください。

とあって、トーストは出しっ放しではなく、クリックされたとき(Activatedイベント)適切な情報に誘導すべきということが分かります。また、
バルーン通知のシナリオをトーストに自動的に移行しないでください。ユーザーが全画面表示のエクスペリエンス (デスクトップスタイルアプリのみ) に没入していないときには、バルーン通知の方が適している場合もあることを考慮します。

ともあってギクッとします。デスクトップにしか関係ないものはわざわざトーストにしなくていい場合があると。

その他さほど細々した指示はなくて、大意をまとめれば「トーストを濫発されると全体の迷惑だから、つまらない通知に使うな、なるべく絞れ」というプラットフォーム側としては当然の要請だと思います。

2.2 トーストテンプレート

トースト通知の出し方を簡単にいえば、専用書式のXMLに設定を乗せてAPIに渡す、それだけです。そのXMLのベースとなるテンプレートは8種類用意されていて、新規作成はできません。というより、このXMLからトーストを表示するためのCSSというかスタイル設定がこれら既定のテンプレートに対応したものだけで、自由に追加できないといった方が正しいかも。

このテンプレートはMSDNのトーストテンプレートカタログにあるとおりですが、実際のところ、たいして選択肢はありません。
  • 画像を入れるか否か。
  • 3行の文字スペースを件名(Headline)と本文(Body)にどう割り当てるか。
だけです(スタートメニューに置いたショートカットのアイコンは常に入る)。なお、色はショートカットのタイルのものが利用されるので、トーストの設定にはありません。

具体的には、
  1. ToastText01(文字のみ、本文のみ)
  2. ToastText02(文字のみ、件名1行、本文2行)
  3. ToastText03(文字のみ、件名2行、本文1行)
  4. ToastText04(文字のみ、件名1行、本文1行×2)
  5. ToastImageAndText01(画像入り、本文のみ)
  6. ToastImageAndText02(画像入り、件名1行、本文2行)
  7. ToastImageAndText03(画像入り、件名2行、本文1行)
  8. ToastImageAndText04(画像入り、件名1行、本文1行×2)
の通りですが、汎用性が高いのはToastText02だと思うので、以後これで進めます(テストアプリもこれを使用)。

まずToastNotificationManagerからテンプレートを取得します。型はWindows RuntimeのXMLであるWindows.Data.Xml.Dom.XmlDocumentです。
var document = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastText02);
このXMLの内容はカタログにあるとおり以下のようなものです(改行を入れて整形したもの)。
<toast>
    <visual>
        <binding template="ToastText02">
            <text id="1"></text>
            <text id="2"></text>
        </binding>
    </visual>
</toast>
後はこれをこねこねすればいいわけです。書式はMSDNのToast schemaに説明があります。といってもデスクトップアプリから出すトーストの場合、使えるのは一部だけで、toastaudioぐらいです。

以下では最初に件名(headline)と本文(body)を入れた後、トーストの表示時間を長くし、それに合わせてオーディオはループするよう設定しています。
// Fill in text elements.
var textElements = document.GetElementsByTagName("text");
if (textElements.Length == 2)
{
    textElements[0].AppendChild(document.CreateTextNode(headline));
    textElements[1].AppendChild(document.CreateTextNode(body));
}

// Set duration attribute.
document.DocumentElement.SetAttribute("duration", "long");

// Add audio element.
var audioElement = document.CreateElement("audio");
audioElement.SetAttribute("src", "ms-winsoundevent:Notification.Looping.Alarm");
audioElement.SetAttribute("loop", "true");
document.DocumentElement.AppendChild(audioElement);
これでXMLは例えば以下のようになります(整形したもの。先にテストアプリで出したトーストと同じ内容)。この内容が毎回同じなら、これをテキストリソースとして持っておいてXMLを生成した方が早いですね。
<toast duration="long">
    <visual>
        <binding template="ToastText02">
            <text id="1">トースト通知のテスト</text>
            <text id="2">トーストの本気を見るのです!</text>
       </binding>
    </visual>
    <audio src="ms-winsoundevent:Notification.Looping.Alarm" loop="true"/>
</toast>
このXMLでToastNotificationオブジェクトを生成し、AppUserModelIDと合わせてToastNotificationManagerに託せば終わりです。
// Create a toast.
var toast = new ToastNotification(document);

// Show a toast.
ToastNotificationManager.CreateToastNotifier(AppId).Show(toast);

2.3 トーストイベント

トーストを出した結果はイベントで取得できます。というより、トーストがクリックされたかどうか知る必要があるので、トーストを出す前にイベントハンドラーを登録しておきます。イベントは3種類で、うち1つは理由が3つに分かれるので、計5種類の結果があります。
  • ToastNotification.Activated(トーストを出すのに成功し、ユーザーがクリックした)
  • ToastNotification.Dismissed
    • ToastDismissalReason.ApplicationHidden(アプリでトーストが抑止されていた)
    • ToastDismissalReason.UserCanceled(トーストを出すのに成功したが、ユーザーがクローズボタンで閉じた=積極的に無視した)
    • ToastDismissalReason.TimedOut(トーストを出すのに成功したが、クリックされないまま時間切れになった=ユーザーが気づかなかったか、消極的に無視した)
  • ToastNotification.Failed(トーストを出すのに失敗した)
このうちアプリがアクションを起こす必要があるのはActivatedイベントの場合だけなので、それだけで十分かもしれません。ちなみに、このイベントはサブスレッドで起きるので、UIを操作するにはUIスレッドに戻す必要がありますが、コンソールアプリでは関係ないです。

なお、コンソールアプリでイベントを受け取るにはイベントが返ってくるまで(表示時間を長くした場合、時間切れまで25秒)アプリが終了しないようにする必要があるので、テストアプリではトーストを出した後、System.Threading.Thread.Sleepメソッドを500ミリ秒ずつ60回ループさせて計30秒ほどUIスレッドをブロックするようにし、イベントが返ってきたらループを出るようにしています。

2.4 アプリのショートカット

上でも出てきましたが、トースト通知を出すにはそのアプリのAppUserModelIDの入ったショートカットがスタートメニューにある必要があります。このAppUserModelID入りのショートカットを作る機能は.NET Frameworkでは提供されていません。IShellLinkにもありません。Windows Script HostのWshShortcutにもありません。

となるとWin32APIとCOMでやるしかないわけですが、Windows API Code Packのライブラリにこれが使えるものあります。どうせならとこれとIShellLinkの機能をまとめたラッパークラスを作るというのを以前やりました。

このShellLinkクラスの機能を実用レベルに上げたものをテストアプリでは使っています。したがって一般的な説明にはならないのですが、こんな感じでショートカットを作成しています。
using (var shortcut = new ShellLink()
{
    TargetPath = Assembly.GetExecutingAssembly().Location;
    Arguments = StartmenuArgument,
    AppUserModelID = AppId,
    IconPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), ExecutableFile),
    IconIndex = 0,
    WindowStyle = ShellLink.SW.SW_SHOWMINNOACTIVE,
})
{
    shortcut.Save(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", ShortcutFile));
}
各プロパティの設定内容は、
  • TargetPathはショートカットが示す実行ファイルのパスで、このコンソールアプリ(子アプリ)自身のパスを入れています。
  • Argumentsはショートカットの引数で、ショートカットから起動されたことを判別するための定数を入れています(意図は後述)。
  • AppUserModelIDはその名のとおり。
  • IconPathはショートカットのアイコンに使うアイコンで、ここにはWPFアプリ(親アプリ)のアイコンを利用するため、同じフォルダーにあるという前提で、そのパスを入れています(ExecutableFileは親アプリのファイル名)。
  • IconIndexはIconPathで指定されたファイルに含まれるアイコンのうちどれを使うかという指定ですが、1つだけなので0です。
  • WindowStyleはショートカットから起動されたときにウィンドウの状態をどうするかという指定ですが、最小化を指定しています(意図は後述)。
このShellLinkオブジェクトをSaveメソッドでスタートメニューに保存しています(ShortcutFileはショートカットのファイル名)。なお、ショートカットを置く場所の細かな違いはトーストには関係ないっぽいです。

ちなみに、AppUserModelIDには以下の命名ルールがあります。

CompanyName.ProductName.SubProduct.VersionInformation

が、実際はあまり真面目に付けられてなかったりします。例えばLenovoのユーティリティのショートカットをShellLinkクラスで見ると、いかにもサンプルのままでしたし、他のソフトもMicrosoft自身のものを含めて結構自由です。Visual Studio 2013はこんな感じ。

あまり気にしない方がいいようです。

2.5 アプリのショートカットのタイル

前述のとおりトーストの色(背景色と文字色)にはショートカットのタイルのものが利用されます。これを.VisualElementsManifest.xml拡張子を付与したファイルでカスタマイズする方法をぐらばく先生が説明されています。
このファイル(以下、マニフェストファイル)は予め作成しておいてもいいのですが、テストアプリでは色を変えたかったので実行時に作成するようにしています。で、親アプリから指示するのは背景色だけにして、文字色は子アプリの方で背景色に応じて自動選択するようにしています。

まず文字色(foregroundColor)を列挙型で定義。
private enum ForegroundColorType
{
    light,
    dark,
}

private ForegroundColorType foregroundColor;
これを背景色(backgroundColor)に応じて切り替えます。
foregroundColor = ((Color)ColorConverter.ConvertFromString(backgroundColor)).GetBrightness() < 0.5
    ? ForegroundColorType.light : ForegroundColorType.dark;
この背景色はHTMLカラーの文字列で、これをSystem.Windows.Media.Colorに変換した後、明るさをSystem.Drawing.Color.GetBrightnessメソッドに倣って作成したGetBrightness拡張メソッドで取得し、暗ければ文字色をlightに、明るければdarkにします。

ただ、実際に試してみるととくに青系の暗い色のときの結果が今一つで、アルゴリズムは改善の余地がある感じです。たぶん以下が参考になると思います。
文字色を決めた後、マニフェストファイルのXMLを組み立てて保存します。型はSystem.Xml.XmlDocumentを使っていますが、深い意味はありません。一応整形するようにしていますが、動作には関係なかったりします。
var document = new XmlDocument();

// Add Application element and set its attribute.
var applicationElement = (XmlElement)document.AppendChild(document.CreateElement("Application"));
applicationElement.SetAttribute("xmlns:xsi", @"http://www.w3.org/2001/XMLSchema-instance");

// Add VisualElements element and set its attributes.
var visualElementsElement = (XmlElement)applicationElement.AppendChild(document.CreateElement("VisualElements"));
visualElementsElement.SetAttribute("BackgroundColor", backgroundColor);
visualElementsElement.SetAttribute("ShowNameOnSquare150x150Logo", "on"); // on or off
visualElementsElement.SetAttribute("ForegroundText", foregroundColor.ToString());

// Create a manifest file (overwrite).
var targetPath = Assembly.GetExecutingAssembly().Location;
var manifestPath = Path.Combine(Path.GetDirectoryName(targetPath),
     String.Format("{0}.VisualElementsManifest.xml", Path.GetFileNameWithoutExtension(targetPath)));

using (var sw = new StreamWriter(manifestPath, false, Encoding.UTF8))
{
    var settings = new XmlWriterSettings()
    {
        OmitXmlDeclaration = true,
        Indent = true,
        NewLineOnAttributes = true,
    };

    using (var xw = XmlWriter.Create(sw, settings))
    {
        document.Save(xw);
        xw.Flush();
    }
}
また、色を変えるにはマニフェストファイルを書き換える必要がありますが、既に同じ内容のマニフェストファイルがあるか否かの判別は以下のようにしています。型はSystem.Xml.Linq.XDocumentで、またXMLの型が違いますが、こういうチェックはLINQ to XMLが楽なので。
XDocument document;

// Check and read a manifest file.
var targetPath = Assembly.GetExecutingAssembly().Location;
var manifestPath = Path.Combine(Path.GetDirectoryName(targetPath),
     String.Format("{0}.VisualElementsManifest.xml", Path.GetFileNameWithoutExtension(targetPath)));

if (!File.Exists(manifestPath))
    return false;

using (var sr = new StreamReader(manifestPath, Encoding.UTF8))
{
    document = XDocument.Parse(sr.ReadToEnd());
}

// Check Application element.
var applicatonElement = document.Elements()
    .FirstOrDefault(x => x.Name == "Application");
if (applicatonElement == null)
    return false;

// Check VisualElements elements and its attributes.
var visualElementsElement = applicatonElement.Elements()
    .FirstOrDefault(x => x.Name == "VisualElements");
if (visualElementsElement == null)
    return false;

if (!visualElementsElement.Attributes()
    .Any(x => (x.Name == "BackgroundColor") && (x.Value == backgroundColor)))
    return false;

if (!visualElementsElement.Attributes()
    .Any(x => (x.Name == "ForegroundText") && (x.Value == foregroundColor.ToString())))
    return false;

return true;
これでトーストの度に色を変えることもできるわけですが、色をトーストの要素として活用することの是非についてトースト通知のガイドラインには何も書いてないんですよね。まあ想定外なんでしょうけど。

2.6 待ち時間

アプリのインストール時や初回起動時などにショートカットを作成する場合は問題になりませんが、トースト通知を出す直前にショートカットを作成する場合、一定の時間を置かないとトーストに失敗するようです(Failedイベントが返ってくる)。この時間は環境依存かもしれませんが、自分の試した範囲では3秒が必要でした。

また、マニフェストファイルを書き換えた場合、それをショートカットのタイルに反映するにはショートカットを上書きする必要がありますが(ショートカットの設定内容はそのままでも)、この場合も新しいタイルの色がトーストにも反映されるには同じ待ち時間が必要なようです。

テストアプリでは既定を3秒として、調整できるようにしています。

2.7 トーストの設定内容と結果の伝達

親アプリからトーストの設定内容を子アプリに伝えるには、通常のコマンドのように引数オプションを使う方法でもいいですが、やや面倒なので共通の伝達用クラスを作って、これで送るようにし、結果も同じクラスで返すようにしています。

伝達用クラスはこんな感じで、各プロパティを設定後、送り側はDataContractSerializerでシリアライズして標準出力で送り、受け側は標準入力で入ってきたものをデシリアライズして使う、という流れです。
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text;

[DataContract()]
public class ToastPacket
{
    #region Property

    /// <summary>
    /// A toast's headline
    /// </summary>
    [DataMember()]
    public string Headline { get; set; }

    /// <summary>
    /// A toast's body
    /// </summary>
    [DataMember()]
    public string Body { get; set; }

    /// <summary>
    /// A toast's background color in HTML color string
    /// </summary>
    [DataMember()]
    public string BackgroundColor { get; set; }

    /// <summary>
    /// Whether a toast's duration is long
    /// </summary>
    [DataMember()]
    public bool IsLong { get; set; }

    /// <summary>
    /// Waiting time (sec) before showing a toast
    /// </summary>
    /// <remarks>This waiting time is for the case that the shortcut to child application is 
    /// newly installed or changed so that a waiting time before showing a toast is required. 
    /// The length of this waiting time seems to be a few sec in general.</remarks>
    [DataMember()]
    public int WaitTime { get; set; }

    /// <summary>
    /// Result of a toast
    /// </summary>
    [DataMember()]
    public ToastResult Result { get; set; }

    /// <summary>
    /// Note when showing a toast
    /// </summary>
    [DataMember()]
    public string Note { get; set; }

    #endregion

    #region Method

    /// <summary>
    /// Serialize this instance.
    /// </summary>
    /// <returns>Outcome string</returns>
    public string Serialize()
    {
        var serializer = new DataContractSerializer(typeof(ToastPacket));

        using (var ms = new MemoryStream())
        {
            serializer.WriteObject(ms, this);

            return Encoding.UTF8.GetString(ms.ToArray());
        }
    }

    /// <summary>
    /// Deserialize and copy to this instance.
    /// </summary>
    /// <param name="source">Source string</param>
    public void Deserialize(string source)
    {
        if (string.IsNullOrEmpty(source))
            throw new ArgumentNullException("source");

        var serializer = new DataContractSerializer(typeof(ToastPacket));

        using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(source)))
        {
            this.CopyFrom((ToastPacket)serializer.ReadObject(ms));
        }
    }

    /// <summary>
    /// Copy property values from other instance.
    /// </summary>
    /// <param name="other">Other instance</param>
    public void CopyFrom(ToastPacket other)
    {
        if (other == null)
            throw new ArgumentNullException("other");

        var properties = typeof(ToastPacket).GetProperties(BindingFlags.Public | 
                                                           BindingFlags.Instance | 
                                                           BindingFlags.Static | 
                                                           BindingFlags.DeclaredOnly);

        foreach (var p in properties)
        {
            p.SetValue(this, p.GetValue(other, null), null);
        }
    }

    #endregion
}
結果を示す列挙型はイベントに合わせたものを定義しています。
public enum ToastResult
{
    /// <summary>
    /// The user activated the toast.
    /// </summary>
    Activated,

    /// <summary>
    /// The application hid the toast using ToastNotifier.hide method.
    /// </summary>
    ApplicationHidden,

    /// <summary>
    /// The user dismissed the toast.
    /// </summary>
    UserCanceled,

    /// <summary>
    /// The toast has timed out.
    /// </summary>
    TimedOut,

    /// <summary>
    /// The toast encountered an error.
    /// </summary>
    Failed,
}
この他、ユーザーが子アプリを直接、引数オプションを付けて起動できるようにもしています。ただし、伝達用クラスは親アプリにあるものを子アプリが参照して使うようになっているので、親アプリの実行ファイルがないと子アプリ単独では動かなかったりしますが、使わなければ削ればいい話です。

2.8 親アプリの起動

トースト通知のためにスタートメニューにショートカットを置くわけですが、そういういわばアリバイのような話とは無関係に、ユーザーとしてはスタートメニューにある以上、そこから意味のあるものが起動することを期待するわけで、クリックしても適切な反応を返さないショートカットはよろしくない気がします。

というか、Windows 8以降だとスタートメニューにつまらないショートカットを作られるのは全くもって迷惑なので、存在するならせめて意味のある方がいい、さりとてトースト用アプリが起動しても嬉しくも何ともないので、テストアプリでは本体の親アプリを起動するようにしています。

ショートカットの作成時に設定したArgumentsの定数とWindowStyleの最小化はこのためのもので、Argumentsによって親アプリから起動されたものか、ユーザーから直接コマンドで起動されたものか、ショートカットから起動されたものかを判別し、ショートカットの場合はサイレントに親アプリを起動して子アプリは終了します。

ただ、そうするなら初めからショートカットのTargetPathを親アプリにしてしまうという手も考えられますが、どちらが正しいのかはよく分かりません。

3. まとめ

以上のように、.NET 4.5以降のアプリでなくてもトースト通知を使うことはできます。と言いつつ、自分が元々これを考えたのは.NET 4.0のアプリを開発していて、この対応環境からXPを外さないためには.NET 4.5に上げられなかったという理由があるのですが、気づいてみればXPのサポート期限切れまで後わずかですね。

ううむ、微妙……。

4. 最後に残った疑問

トースト通知の「トースト」の語源が分からなくて、画像検索したらこんがり焼けたトーストばかりだったので洒落のつもりでトーストのアイコンにしてみたのですが、もしかしてこれが語源……? トースターから焼き上がったトーストがポンっと出てくるのに引っ掛けた? いやいやいや……まさか、ね。

2014/02/05

直角なLANケーブル(補完)

LANコネクタが側面にあるノートPCの場合にLANケーブルをどう取り回すかという課題で、直角に曲がったLANケーブルを使うことを考えたことがあるが、その補完。

先日、LANコネクタが右側面にあるThinkPad X230を買ったので、この課題が現実のものとなった。で、一応考えてみたが、やっぱりこうなった。
ThinkPad X230 & Flexible LAN Cable

MCO(ミヨシ)のカテゴリー6フラットLANケーブルで、コネクタの張り出しは極小で済む。
ThinkPad X230 & Flexible LAN Cable

張り出し幅はLogicoolのUnifyingレシーバー(旧型)より小さく、ケーブルを真下に曲げるように取り回せばケーブルを含めても同等の幅しか取らない。こうなるとオーディオケーブルの方が張り出しは大きいぐらいで、当然右手でマウスを使うときも邪魔にはならない。

こういうケーブルだと品質が気になるが、1Gbpsでリンクしているので、とりあえず実用上の問題はない、と思う。
ThinkPad X230 & Flexible LAN Cable (Link Speed)

ということで、側面にLANコネクタがあっても別に問題なかったね、というオチでした。