遥かへのスピードランナー

シリコンバレーでAndroidアプリの開発してます。コンピュータービジョン・3D・アルゴリズム界隈にもたまに出現します。

Androidアプリで使える便利なUIライブラリ

Androidアプリと言えばUI命!、ということでギークな方々が作られている便利なUIライブラリを見つけられる限り、スクリーンショット付きでまとめてみます。
皆様いずれもソースと一部サンプルアプリを公開されているのですぐにでも試してみることができます。
(作者の方々、載せることに問題があるようでしたらお手数ですがご一報くださいませ)

Quick Action

  • 公式Twitterアプリ風にタッチした箇所に吹き出しを表示できる
  • レイアウトもカスタマイズ可能

Y.A.M の 雑記帳: Android Quick Action の Android ライブラリプロジェクトを作ってみた

Drag and Drop ListView

ユーザがソート可能なListViewをすこしリッチにしてみた - 明日の鍵

Calendar

  • ビュー上で祝日を判定・表示可能なカレンダービュー
  • 上下左右のフリックで月を切り替えることもできるので、フリック操作を実現したい人はその部分だけでも参考になるかも

CalendarView 公開しました - Kazzzの日記

3D ListView

  • 3Dで回転しながらスクロールするリストビュー
  • 個人的には大好きなんですが、使い所が思いつかないw

Android Tutorial: Making your own 3D list – Part 3 (final part) | Developer World

CoverFlow

  • iPod風カバーフロー
  • シンプルで使いやすいです

Interfuser: Android Coverflow Widget V2

ChartLibrary

  • チャートライブラリ
  • JFreeChartAndroid用に拡張したもの
  • 棒グラフ・折れ線グラフ・円グラフなど必要そうなものは大体完備しており、かなり高機能
  • LGPLライセンス

afreechart - Project Hosting on Google Code



Zoomable ImageView

  • 長押しすると、タッチでズームイン・ズームアウトできるようになるImageView
  • 画像が画面より大きくなると表示箇所を移動できるようになる
  • 移動時に淵までいくとバウンドするアニメーション付き

Android one finger zoom tutorial – Part 4 | Developer World

Drag and Drop ImageView

Androidでドラッグ・アンド・ドロップ - hidecheckの日記

NumberPicker

  • インクリメント・デクリメントの間隔を調整可能な数値ピッカー

カスタムNumberPickerの作成 - にゃんだふる日記

Color Picker

  • カラーピッカー
  • こちらは矩形

Androidでカラーピッカーを作ろう - 明日の鍵

Color Picker(11/30追記)

Y.A.M の 雑記帳: Android ColorPickerDialog を作った

他にも

こんな便利なのあるよ!っていうのがあったら教えてください!

Android MockとRoboGuiceでTDD

Androidアプリ開発(に限った話ではないですが)でTDDしたいと思ったときに、テスト対象クラスのフィールドをモックで差し替えたい、と思うことがしばしばあります。依存するクラスの振る舞いを固定化することで、テスト対象オブジェクトの振る舞いだけに着目したテストケースを書くことができるからです。

そんな時に、DIコンテナ上でコードを書いていると便利です。以前、少しだけSeasar2+EasyMockでテストを書いていたことがあったのですが、作成したモックオブジェクトの差し替えを、ほぼ全てSeasar2がやってくれたのでものすごく便利でした。

Android開発でもSeasar2+EasyMockくらい簡単にテストを書きたい!
ということで、

  • Android Mockでモックオブジェクトとその振る舞いを定義
  • RoboGuiceでモックオブジェクトをテスト対象クラスにインジェクト

ということをやってみました。

準備

RoboGuice導入

roboguice - Project Hosting on Google Code
RoboGuiceは、Android Framework上でDIを実現するためのフレームワークです。Google GuiceというDIライブラリを拡張してAndroidに対応させています。

導入方法については

と、それを受けた

が詳しいのでそっちを参照で。

Android Mock導入

android-mock - Project Hosting on Google Code
Android Mockは、Androidで使えるEasyMockです。
導入方法については、Androdテスト部の方が翻訳されたドキュメント

が詳しいのでそちらを参照してください。

テスト対象コード

さて準備については丸投げしたところで(おい)、いよいよDIを活用したテストコードを書いてみます。
今回テストするのは以下のようなActivityです。

TopActivity
public class TopActivity extends GuiceActivity {
	@InjectView(R.id.text)
	private TextView textView;

	@Inject
	private Person person;

	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.main);
		if (person.getName().equals("droid")) {
			person.setColor("green");
		} else {
			person.setColor("white");
		}
		textView.setText(person.getName());
	}
}

機能は単純で、onCreate()で、personフィールドにセットされたオブジェクトのnameが"droid"だったらsetColor("green")を呼び出し、そうでなければsetColor("white")を呼び出す、というものです。

@Injectが指定されているpersonフィールドへの代入は、Roboguiceが行ってくれていて、「Personインターフェースのinject要求に対しては、Droidクラスを差し込め」ということをApplicationクラス(とそこから呼び出されるModuleクラス)で指定しています。

MyApplication.java
public class MyApplication extends GuiceApplication {
	@Override
	protected void addApplicationModules(List<Module> modules) {
		modules.add(new MyModule());
	}
}
MyModule.java
public class MyModule extends AbstractAndroidModule {
	@Override
	protected void configure() {
		bind(Person.class).to(Droid.class);
	}
}

さて、先ほどのActivityのテストを書こうとしたときに

  • personフィールドがdroidのときと、そうでないときの両ケースをテストしたい
  • setColorに対して期待値通りの引数が指定されて呼び出されていることを確認したい

ということが実現できると、テストケースとしてはバッチリです。
これをAndroid MockとRoboGuiceで実現します。

テストコード

public class TestCase extends ActivityUnitTestCase<TopActivity> {

	final class MyMockModule extends AbstractAndroidModule {
		private Person mockPerson;

		public void setMock(Person person) {
			this.mockPerson = person;
		}

		@Override
		protected void configure() {
			bind(Person.class).toInstance(mockPerson);
		}
	}

	final class MyMockApplication extends GuiceApplication {
		private Module myModule;

		public void setMyModule(Module myModule) {
			this.myModule = myModule;
		}

		@Override
		protected void addApplicationModules(List<Module> modules) {
			if (myModule == null) {
				throw new IllegalArgumentException("Please call setMyModule before running the tests!");
			}
			modules.add(myModule);
		}

		MyMockApplication(Context context) {
			super();
			attachBaseContext(context);
		}
	}

	public TestCase() {
		super(TopActivity.class);
	}

	@Override
	protected void setUp() throws Exception {
		super.setUp();

	}

	@MediumTest
	@UsesMocks(Droid.class)
	public void testMockDroid1() {

		// create a mock and learn it's behavior
		Droid mockDroid = AndroidMock.createMock(Droid.class);
		AndroidMock.expect(mockDroid.getName()).andStubReturn("mock");
		mockDroid.setColor("white");
		AndroidMock.replay(mockDroid);

		// set up mock application object
		Context context = getInstrumentation().getTargetContext();
		MyMockApplication application = new MyMockApplication(context);
		MyMockModule myMockModule = new MyMockModule();
		myMockModule.setMock(mockDroid);
		application.setMyModule(myMockModule);
		setApplication(application);

		// start activity
		Intent intent = new Intent(context, TopActivity.class);
		TopActivity activity = startActivity(intent, null, null);
		TextView textView = (TextView) activity
		        .findViewById(com.polysfactory.roboguice_and_mock.R.id.text);

		// verify
		assertEquals("mock", textView.getText());
		AndroidMock.verify(mockDroid);
	}
	
	@MediumTest
	@UsesMocks(Droid.class)
	public void testMockDroid2() {

		// create a mock and learn it's behavior
		Droid mockDroid = AndroidMock.createMock(Droid.class);
		AndroidMock.expect(mockDroid.getName()).andStubReturn("droid");
		mockDroid.setColor("green");
		AndroidMock.replay(mockDroid);

		// set up mock application object
		Context context = getInstrumentation().getTargetContext();
		MyMockApplication application = new MyMockApplication(context);
		MyMockModule myMockModule = new MyMockModule();
		myMockModule.setMock(mockDroid);
		application.setMyModule(myMockModule);
		setApplication(application);

		// start activity
		Intent intent = new Intent(context, TopActivity.class);
		TopActivity activity = startActivity(intent, null, null);
		TextView textView = (TextView) activity
		        .findViewById(com.polysfactory.roboguice_and_mock.R.id.text);

		// verify
		assertEquals("droid", textView.getText());
		AndroidMock.verify(mockDroid);
	}

}

肝となるのは、

	final class MyMockModule extends AbstractAndroidModule {
                ...
		@Override
		protected void configure() {
			bind(Person.class).toInstance(mockPerson);
		}
	}

の部分です。このbindによって、Personクラスの@Inject要求に対しては、モックオブジェクトがセットされるようになります。

モックの振る舞いは、各テストメソッドの

		Droid mockDroid = AndroidMock.createMock(Droid.class);
		AndroidMock.expect(mockDroid.getName()).andStubReturn("droid");
		mockDroid.setColor("green");
		AndroidMock.replay(mockDroid);
                ...
                AndroidMock.verify(mockDroid);

の部分で指定しており、モックに対する動作を検証することもできます。

仮にDI(RoboGuice)を使わずに、personフィールドをonCreateの中でnewしていたら、このように簡単にテストを書くことはできません。personフィールドに何がセットされるかについては、newしたクラスの中身が完全に把握していないと分からないからです。

課題

@InjectViewや@InjectExtraのフィールドにモックインジェクションが出来ない

今回のテストでは、@Injectを指定したフィールドに対してモックをセットしています。
@Injectによるインジェクションは、実体に対するマッピングを自分でカスタマイズすることが出来るので、モックの差し替えが容易でしたが、@InjectViewや@InjectExtraなどのRoboguiceが提供するその他のインジェクションタイプに対しては、モックの差し替えはできなそうでした。(Roboguice自体に、モックと差し替えられるような拡張ポイントを用意してあげる必要があると思います。)
実際の開発では、@InjectViewや@InjectExtraもかなり便利なので、是非ともモックに差し替えてテストを書いてみたいところです。

RoboGuiceの性能

上記の方法は元々のアプリケーションがRoboGuiceで作られていることが前提となっています。
RoboGuiceの性能に関しては、Androidで動かすことを前提に、かなり気を使ったコードを書いているように見えますが、DIの宿命としてリフレクションを多用していますし、性能は気になります。
ただこれも、「推測するな、計測せよ」の原則に則ってきちんと計測すべきですね^^;

上記について詳しい方、いらっしゃいましたらアドバイス下さい!!

まとめ

繰り返しになりますが、

  • Android Mockを使うことでモックの振る舞いを固定化し、テスト中のモックの動作を検証する
  • RoboGuiceを使うことでモックをテスト対象クラスにインジェクション可能にする

これだけで格段にテストしやすいコードになると思います。

androidのテストメソッドはまだ未成熟で、色々なコミュニティが色々なライブラリを出しているのでこれが最善、とは一概には言えないのがもどかしいんですが、今まで試した方法の中では割と使える部類のテスト方法だと思ってます。

AndroidでContentProviderのモックを使ったテストを行う

ContentProviderからデータを取得しているアプリのテストコードを書くときに、テストデータとして端末内のデータを使わずにモックのデータを使いたいということは多いと思います。
今回は端末内の画像を一覧表示するアプリケーションを例にして、このようなテストコードの書き方を説明します。

テスト対象のActivity

以下のようなActivityをテストすることを考えます。

package com.polysfactory.mocktest;

import android.app.ListActivity;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Images.ImageColumns;
import android.widget.ArrayAdapter;

public class MyActivity extends ListActivity {
	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		Cursor cursor = getContentResolver().query(Images.Media.EXTERNAL_CONTENT_URI,
		        new String[] { ImageColumns.TITLE }, null, null, null);
		if (cursor != null) {
			String[] names = new String[cursor.getCount()];
			int i = 0;
			while (cursor.moveToNext()) {
				names[i++] = cursor.getString(0);
			}
			cursor.close();
			setListAdapter(new ArrayAdapter<String>(this,
			        android.R.layout.simple_expandable_list_item_1, names));
		}
	}
}

見ての通り、Adnroid標準のContentProviderであるMediaStoreから画像コンテンツの情報を全て取得して、タイトルをListViewの表示させるだけのActivityです。
このActivityを起動すると以下のような画面が表示されます。

こいつのテストをするとなると、Listの1件目にxxxxという画像名称が表示されていることを確認したい、というケースが出てきます。

当然、端末ごとに保存されている画像は異なりますが、極力端末に依存させないよう、Activityからはダミーのデータにアクセスさせて、それが表示されているかをテストする必要があるわけです。

テストプロジェクトの概要

今回のテストでは、MediaStoreのダミーとなるContentProviderを自作します。そしてMediaStoreのContent URIを指定された場合にそのダミーを見にいくようなContentResolverとContextのモックを作成し、Activityにインジェクトします。
そうすることでActivity側のコードを変えることなく、端末のデータベースを参照せずに、モックとして用意したデータを使ってテストを行うことが出来るようになるのです。

超適当クラス図にするとこんな感じです。

以下それぞれのクラスについて説明していきます。

ContentProviderのモックを準備する

いきなり肝ですが、MediaStoreのダミーとして動作するContentProviderのモックを用意してあげます。これはContentProviderを継承したクラスを自作します。
ここでのポイントはqueryメソッドをオーバーラードするときに、データベースから取得したCursorを返さずに、MatrixCursorクラスを使って仮想のカーソルを返してあげるところです。

public class MockImagesProvider extends ContentProvider {
    ....
	@Override
	public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
	        String sortOrder) {
		MatrixCursor result = new MatrixCursor(projection);
		String[] values = new String[projection.length];
		for (int i = 0; i < projection.length; i++) {
                    ...
                    values[i] = "hogehoge";
                    ...
		}
		result.addRow(values);
		return result;
	}
    ...
}

これによって、テストデータをデータベースから読まこませず、ファイルから読み込んだ値などを返すことができます。
今回はサンプルのため、テストデータをソース中に書き込みます。以下、このクラスの全体ソースです。

public class MockImagesProvider extends ContentProvider {

	private static final String[] IMAGE_COLUMNS = { ImageColumns._ID, ImageColumns._COUNT,
	        ImageColumns.DATA, ImageColumns.DATE_ADDED, ImageColumns.DATE_MODIFIED,
	        ImageColumns.DISPLAY_NAME, ImageColumns.SIZE, ImageColumns.TITLE };

	private static final String[] VALUE1 = { "1", "1", "/sdcard/hoge.jpg", "1000", "1000",
	        "display_name_test", "100", "title_test" };

	public static final int IMAGE = 1;

	public static final int IMAGE_ID = 2;

	@Override
	public boolean onCreate() {
		return true;
	}

	@Override
	public Uri insert(Uri uri, ContentValues values) {
		return null;
	}

	@Override
	public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
	        String sortOrder) {
		MatrixCursor result = new MatrixCursor(projection);
		String[] values = new String[projection.length];
		for (int i = 0; i < projection.length; i++) {
			for (int j = 0; j < IMAGE_COLUMNS.length; j++) {
				if (IMAGE_COLUMNS[j].equals(projection[i])) {
					values[i] = VALUE1[j];
				}
			}
		}
		result.addRow(values);
		return result;

	}

	@Override
	public int delete(Uri uri, String selection, String[] selectionArgs) {
		return 0;
	}

	@Override
	public String getType(Uri uri) {
		return null;
	}

	@Override
	public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
		return 0;
	}
}

CotentResolverのモックを作成する

第2の肝です。MediaStoreから画像の情報を取得するときのURIはcontent://media/external/imagesというURIになります。
ContentResolverのモックを作成して、content://mediaのURIに対しては、先ほど作成したMockImagesProviderにルーティングするように実装します。

public class MyMockContentResolver extends MockContentResolver {

	public MyMockContentResolver(Context context) {
		ContentProvider provider = new MockImagesProvider();
		ProviderInfo providerInfo = new ProviderInfo();
		providerInfo.authority = "media";
		providerInfo.enabled = true;
		providerInfo.isSyncable = false;
		providerInfo.packageName = MockImagesProvider.class.getPackage().getName();
		provider.attachInfo(context, providerInfo);
		super.addProvider("media", provider);
	}
}

Contextのモックを作成する

ActivityにセットするためのContextのモックを作成します。
ContextWrapperを継承して、getContentResolver()だけをオーバーライドしてあげることで、ContentResolverを取得する部分だけをモックの動作に置き換えることができます。

public class MyMockContext extends ContextWrapper {

	private ContentResolver contentResolver;

	public MyMockContext(Context context) {
		super(context);
		contentResolver = new MyMockContentResolver(context);
	}


	@Override
	public ContentResolver getContentResolver() {
		return contentResolver;
	}

}

テストケース

最後にテストを実行する部分のコードです。ActivityのテストはActivityInstrumentationTestCase2が使われることが多いですが、ActivityInstrumentationTestCase2はテスト対象のActivityに対するContextのインジェクションができないという制約があるため、代わりにActivityUnitTestCaseを使います。
ActivityUnitTestCase.setActivityContextメソッドを使うことで、先ほど作ったMockContextをActivityにセットすることが出来ます。

public class MockTestSample extends ActivityUnitTestCase<MyActivity> {

	public MockTestSample() {
		super(MyActivity.class);
	}

	@Smoke
	public void testSample() {
		Context mockContext = new MyMockContext(getInstrumentation().getTargetContext());
		setActivityContext(mockContext);
		startActivity(new Intent(), null, null);
		final MyActivity activity = getActivity();
		ListView listView = activity.getListView();
		int count = listView.getCount();
		String item0 = (String)listView.getAdapter().getItem(0);

		assertNotNull(activity);
                //表示されているデータが1件であることを確認する
		assertEquals(1, count);
                //表示されている画像名がtitle_testであることを確認する
		assertEquals("title_test", item0);
	}
}

テスト実行

これでテストを実行してみます。

見事、成功です。端末のデータベースではなくモックのデータを読み込んでテストを実行してくれました。

ソースコード

GitHubに上げてるので適当に持っていってください。

まとめ

ContentResolver、ContentResolver、Contextという長い道のりを経て、モックのデータでActivityのテストを行う方法について書きました。
これで何が嬉しいかといういうと、端末ごとのデータに依存せずにテストを実行できることです。一度テストデータとコードを書いてしまえば、どんな機種でも実行できるようにようになるので、品質を上げるにに必ず役立つと思ってます。

RobotiumでAndroidアプリのシナリオテストを自動化する

Androidアプリのテスト自動化について色々調査していたら、Robotiumというテストツールを見つけました。このツール、便利なんですが、国内ではまだあまり知られてないみたいなので紹介してみます。

Robotiumとは

RobotiumとはAndroidアプリケーションのブラックボックスレベルのテストを自動化するためのTest Frameworkです。
AndroidSeleniumというのが謳い文句のようです。
Android SDKが提供しているActivityInstrumentationTestCase2では複数のActivityにまたがるようなテストが難しいことで知られていますが、RobotiumはActivityをまたがるテストを簡単に自動化することができます。また、ユーザー操作をエミュレートする関数が豊富に用意されていて、従来は複雑になりがちだったテストコードを簡単に記述することができます。

以下、Robotiumを利用したテストコードの例です。

	 public void testAddNote() throws Exception {
		 //メニューボタンから"Add note"を選択
		 solo.clickOnMenuItem("Add note");
		 //NoteEditorアクティビティが起動していることを確認
		 solo.assertCurrentActivity("Expected NoteEditor activity", "NoteEditor"); 
		 //0番目のテキストフィールドに"Note 1"と入力する
		 solo.enterText(0, "Note 1");
		 //"NotesList"アクティビティに戻る
		 solo.goBackToActivity("NotesList");
		 //現在の画面上にから"Note 1"というテキストを探す
		 boolean actual = solo.searchText("Note 1");
		 //テキストが存在すればテストOK
		 assertEquals("Note 1 is not found", expected, actual);
	 }

このような形でユーザーの操作をもとに、直感的にテストコードを書くことができます。

さっそく試してみる

1. サンプルプロジェクトのダウンロード

RobotiumのサイトからExampleTestProject_v8.zip (2010/10/19現在の最新)を落とします。そのままEclipseでプロジェクトをインポートします。

2. NotePadプロジェクトの作成

ExampleTestProject はAndroidSDKに付属する、NotePadプロジェクトのテストプログラムとして動作するため、NotePadのソースコードを、Eclipseの新規プロジェクト作成からインポートします。
(最新のSDKですと、android-sdk-mac_86/samples/android-7/NotePad/ に存在するようです。)

3. テスト実行

1でインポートしたNotePadTestプロジェクトをAndroid Junit Testで実行します。もちろん実機上で実行することも可能です。
下記のように自動で画面が遷移していく様子を 観察することができます。


感想

ユーザー操作に関するテストコードをかなりの部分ラップしてくれるので、その点使い勝手は非常に良いと思います。Listのxx個目をクリック→メニューボタンのxxxxをクリック→xxxxxのテキストが表示されているか確認、といったようなテストケースをほぼ一瞬で書くことができます。

ネックなのは信頼性で、内部の実装を見るとリフレクションを使ってWindowManagerからビューを取得したりと、かなりエグいことをやっているので、将来的にOSがバージョンアップされたいったときの動作や、機種別の動作は不安が残ります。また最新のバージョンでも、僕が試した限りでは Solo.goBack() が動作しなかったため、Solo.goBackToActivity("NotesList") に書き直したりしました。

というわけで、まだ信頼性に不安があるので、Robotiumだけでテストを書くというわけにはいかないとのが僕の感想です。自動テストのメインはAndroidSDK標準のテストフレームワークで作った単体テストで行い、サブ的に主要なシナリオテストをRobotiumで作成する、というのはアリかと思います。

数値キーでCursorJoinerを使う

android.databaseパッケージにCursorJoinerというクラスがあります。これは何かというと2つの異なるCursorのデータをJoinっぽく扱うことができるクラスで、たとえばContent Provider同士のデータをJoinさせてあげるときに使うことができます。

使い方は、リファレンスにある通り、

 CursorJoiner joiner = new CursorJoiner(cursorA, keyColumnsofA, cursorB, keyColumnsofB);
 for (CursorJointer.Result joinerResult : joiner) {
     switch (joinerResult) {
         case LEFT:
             // handle case where a row in cursorA is unique
             break;
         case RIGHT:
             // handle case where a row in cursorB is unique
             break;
         case BOTH:
             // handle case where a row with the same key is in both cursors
             break;
     }
 }

みたいな感じで使うわけですが、実際こいつが何をやってるかというと、2つのカーソルのJoin対象のキーカラムを比較して、小さい方のカーソルを順番に進めていって、イテレーションのループの中で両方のカーソルに存在するデータなのか、どちらか一方のカーソルにしか存在しないデータなのかの情報を提供してあげてるだけです。

使いどころは結構あって、AndroidのOSが持っているデータベースは、基本的には直接データベースをSQLで触ることができずContent Provider経由で触ることになるので、自前のDBとOSのDB(あるいは他アプリのDB)をJoinさせたいときは、このCursorJoinerを使って擬似的にJoinしてあげるのが、ベターなのかなあと思ってます。

既存のCursorJoinerの問題点

こんな感じで割と便利なCursorJoinerですが、一つ問題があります。
エンジニア脳: android CursorJoinerでも触れられていますが、両カーソルのキー値を比較するときに、キーが数値型であっても、文字列比較してしまっているという点です。
これはどういうことかというと、たとえばCursorAのキーが1,5,10と並んでいて、CursorBのキーが1,10,20と並んでいるときに、キー=1のデータはResult.BOTH(両方のカーソルにあるデータ)として取得できるのですが、両Cursorを順番に進めていく過程で、CursorA=5,CursorB=10の時に5>10と判定してしまって、小さい(と判断された)CursorBを20に進めてしまうのです。
そのため、CursorA=10,CursorB=10となるタイミングがなくなって、キー=10はResult.BOTHで取得することができません。

キー値は大体Long型であることが多いのでこれは困ります。
というわけで、数値型のキーを扱えるようにCursorJoinerを修正してみます。
(不幸なことにCursorJoinerはfinalなクラスで、継承できないので、CursorJoinerをマルっとコピーして必要な部分だけ修正します。)

CursorJoiner修正版

import java.util.Iterator;

import android.database.Cursor;

/**
 * CursorJoinerクラスがもつ、Int型のキー同士でJoinさせる際の問題点を修正したクラス
 */
public final class CursorJoinerWithIntKey implements Iterator<CursorJoinerWithIntKey.Result>, Iterable<CursorJoinerWithIntKey.Result> {

    private Cursor mCursorLeft;

    private Cursor mCursorRight;

    private boolean mCompareResultIsValid;

    private Result mCompareResult;

    private int[] mColumnsLeft;

    private int[] mColumnsRight;

    private int[] mValues;

    public enum Result {
        RIGHT,
        LEFT,
        BOTH
    }

    public CursorJoinerWithIntKey(Cursor cursorLeft, String[] columnNamesLeft, Cursor cursorRight, String[] columnNamesRight) {
        if (columnNamesLeft.length != columnNamesRight.length) {
            throw new IllegalArgumentException("you must have the same number of columns on the left and right, " + columnNamesLeft.length + " != " + columnNamesRight.length);
        }

        mCursorLeft = cursorLeft;
        mCursorRight = cursorRight;

        mCursorLeft.moveToFirst();
        mCursorRight.moveToFirst();

        mCompareResultIsValid = false;

        mColumnsLeft = buildColumnIndiciesArray(cursorLeft, columnNamesLeft);
        mColumnsRight = buildColumnIndiciesArray(cursorRight, columnNamesRight);

        mValues = new int[mColumnsLeft.length * 2];
    }

    public Iterator<Result> iterator() {
        return this;
    }

    private int[] buildColumnIndiciesArray(Cursor cursor, String[] columnNames) {
        int[] columns = new int[columnNames.length];
        for (int i = 0; i < columnNames.length; i++) {
            columns[i] = cursor.getColumnIndexOrThrow(columnNames[i]);
        }
        return columns;
    }

    public boolean hasNext() {
        if (mCompareResultIsValid) {
            switch (mCompareResult) {
            case BOTH:
                return !mCursorLeft.isLast() || !mCursorRight.isLast();

            case LEFT:
                return !mCursorLeft.isLast() || !mCursorRight.isAfterLast();

            case RIGHT:
                return !mCursorLeft.isAfterLast() || !mCursorRight.isLast();

            default:
                throw new IllegalStateException("bad value for mCompareResult, " + mCompareResult);
            }
        } else {
            return !mCursorLeft.isAfterLast() || !mCursorRight.isAfterLast();
        }
    }

    public Result next() {
        if (!hasNext()) {
            throw new IllegalStateException("you must only call next() when hasNext() is true");
        }
        incrementCursors();
        assert hasNext();

        boolean hasLeft = !mCursorLeft.isAfterLast();
        boolean hasRight = !mCursorRight.isAfterLast();

        if (hasLeft && hasRight) {
            populateValues(mValues, mCursorLeft, mColumnsLeft, 0 /* start filling at index 0 */);
            populateValues(mValues, mCursorRight, mColumnsRight, 1 /* start filling at index 1 */);
            switch (compareInts(mValues)) {
            case -1:
                mCompareResult = Result.LEFT;
                break;
            case 0:
                mCompareResult = Result.BOTH;
                break;
            case 1:
                mCompareResult = Result.RIGHT;
                break;
            }
        } else if (hasLeft) {
            mCompareResult = Result.LEFT;
        } else {
            assert hasRight;
            mCompareResult = Result.RIGHT;
        }
        mCompareResultIsValid = true;
        return mCompareResult;
    }

    public void remove() {
        throw new UnsupportedOperationException("not implemented");
    }

    private static void populateValues(int[] values, Cursor cursor, int[] columnIndicies, int startingIndex) {
        assert startingIndex == 0 || startingIndex == 1;
        for (int i = 0; i < columnIndicies.length; i++) {
            values[startingIndex + i * 2] = cursor.getInt(columnIndicies[i]);
        }
    }

    private void incrementCursors() {
        if (mCompareResultIsValid) {
            switch (mCompareResult) {
            case LEFT:
                mCursorLeft.moveToNext();
                break;
            case RIGHT:
                mCursorRight.moveToNext();
                break;
            case BOTH:
                mCursorLeft.moveToNext();
                mCursorRight.moveToNext();
                break;
            }
            mCompareResultIsValid = false;
        }
    }

    private static int compareInts(int... values) {
        if ((values.length % 2) != 0) {
            throw new IllegalArgumentException("you must specify an even number of values");
        }

        for (int index = 0; index < values.length; index += 2) {
            int comp = values[index] - values[index + 1];
            if (comp != 0) {
                return comp < 0 ? -1 : 1;
            }
        }

        return 0;
    }
}

これで、数値型のキーでCursorをJoinさせることができるようになります。
(ただこいつを使ってしまうと、今度は逆に文字列キーのJoinができなくなってしまうので、注意する必要がありますが)

おまけ MatrixCursor

CursorJoinerを使って、両カーソルに存在するデータだけを新たなカーソルとして取得したいときは、僕はMatrixCursorを使ってます。
他で使っているCursorAdapterを使い回したいときとかに便利です。

 MatrixCursor result = new MatrixCursor(COLUMNS_OF_A);
 CursorJoinerWithIntKey joiner = new CursorJoinerWithIntKey(cursorA, keyColumnsofA, cursorB, keyColumnsofB);
 for (CursorJointerWithIntKey.Result joinerResult : joiner) {
     switch (joinerResult) {
         case BOTH:
             String[] values = cursor2values(cursorA); // 注:cursor2valuesはCursorをString[]に変換する独自のメソッドです。
             result.addRow(values);
             break;
     }
 }
 return result;

まとめ

  • ContetProvider同士のデータをJoinさせたいときはCursorJoinerが使える。
  • CursorJoinerはそのままでは数値型のキーをJoinして使えないが、今回の修正版を使うことでそれができるようになる。
  • CursorJoinerを使った結果をCursorで取得したいときはMatrixCursorが使える。

という感じでした。
データをJoinして扱いたい場合、パフォーマンスに拘った方法でやろうとすると、現状では上記が精一杯の対応なのではないかと思います。
実際にSQLでJoinされている訳ではないので、大量のデータを扱いたい場合はまだまだな感がありますが、現行のAndroid OSではとりあえず我慢、でしょうか。。。

AndroidアプリでSMS受信を偽装する方法

今日のGoogle Developer Day 2010のセッションで、IMoNiの作者@t_eggさんが、「IMoNiはSMSのContentProviderにデータを突っ込むことで、iモード.netメールの受信通知をSMSとして(実際に受信している訳ではなく、仮想的に)受信させている」ということを説明されていました。

これについて、興味があったので自分でも実装してみました。
また、ContentProviderに突っ込む以外でも、SMSの受信サービスを直接呼び出すことでSMSを仮想的に受信させる方法を見つけたので、それについても記しておきます。

SMSのContentProviderに突っ込む方法

「SMSのContentProviderに突っ込む」と一言でいっても、SMSのContentProviderはSDKandroid.jarには存在しないので、詳細はOSのソースを追っていく必要があります。
URIとかカラム名の詳細は、
android.provider.Telephony
クラスを参照します。

これによると、insertすべきSMS受信BOXのContent URIは "content://sms/inbox" で、"address","date","read","subject","body"あたりのカラムにデータを突っ込めば良さそうということが分かります。

	ContentValues values = new ContentValues();
	values.put("address", "090xxxxxxxx");
	values.put("date", System.currentTimeMillis());
	values.put("read", false);
	values.put("subject", "test subject");
	values.put("body", "test body");
	getContentResolver().insert(Uri.parse("content://sms/inbox"), values);

この書き込みをするためにはAndroidManifest.xmlに以下のパーミッション設定が必要です。

	<uses-permission android:name="android.permission.WRITE_SMS" />
	<uses-permission android:name="android.permission.READ_SMS" />

このソースを実機で実行してみると、以下のような感じでちゃんと受信できていることが分かります。

受信元は数字じゃなくてもちゃんと表示されてますね。

もう一つの方法 - 直接SMS受信用のServiceを呼ぶ

さて上記のContentProviderに突っ込む方法でSMSの一覧に表示させることはできるのですが、一つ問題があって、ステータスバーへの通知が表示されません。

もちろん、自力で別途Notificationを発行すれば出ますが、出来ればこの辺の処理も本来行うべきクラスに行わせたいところです。
そこでもう一つの方法ですが、SMSの受信サービスに直接Intentを発行する、ということが出来ます。

呼び出す対象は「メッセージ」アプリのサービスクラスである、
com.android.mms.transaction.SmsReceiverService
クラスです。

このSmsReceiverServiceに発行するIntentには、SMS受信を表すActionである「android.provider.Telephony.SMS_RECEIVED」を指定します。
さらに、ここで肝となるのは、受信したSMSを表すバイト列を、"pdus"というキー名のextraにセットしてあげる必要がある点です。僕が調べた限り、このデータを一発で組み立ててくれるような便利なメソッドとかは存在しないので、SMSのフォーマットを調べた上で、自力でバイト列を作成してあげる必要があります。

以下のコードは、SMS and the PDU formatなどを参考にして、SMSのPDUバイト列を組み立てて受信サービスに渡してあげるサンプルソースです。
処理中で、GsmAlphabetというクラスを使ってますが、これはAndroid OSのcom.android.internal.telephonyパッケージに存在する内部ユーテリティクラスです。同パッケージ中のEncodeExceptionクラスと一緒に自分のプロジェクトにコピーしてやればそのまま使えるはずです。

	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		sendSms(this, "09000000000", "これはてすとだよ!");
        }

	private static void sendSms(Context context, String sender, String body) {
		byte[] pdu = null;
		byte[] scBytes = PhoneNumberUtils
				.networkPortionToCalledPartyBCD("0000000000");
		byte[] senderBytes = PhoneNumberUtils
				.networkPortionToCalledPartyBCD(sender);
		int lsmcs = scBytes.length;
		byte[] dateBytes = new byte[7];
		Calendar calendar = new GregorianCalendar();
		dateBytes[0] = reverseByte((byte) (calendar.get(Calendar.YEAR)));
		dateBytes[1] = reverseByte((byte) (calendar.get(Calendar.MONTH) + 1));
		dateBytes[2] = reverseByte((byte) (calendar.get(Calendar.DAY_OF_MONTH)));
		dateBytes[3] = reverseByte((byte) (calendar.get(Calendar.HOUR_OF_DAY)));
		dateBytes[4] = reverseByte((byte) (calendar.get(Calendar.MINUTE)));
		dateBytes[5] = reverseByte((byte) (calendar.get(Calendar.SECOND)));
		dateBytes[6] = reverseByte((byte) ((calendar.get(Calendar.ZONE_OFFSET) + calendar
				.get(Calendar.DST_OFFSET)) / (60 * 1000 * 15)));
		try {
			ByteArrayOutputStream bo = new ByteArrayOutputStream();
			bo.write(lsmcs);
			bo.write(scBytes);
			bo.write(0x04);
			bo.write((byte) sender.length());
			bo.write(senderBytes);
			bo.write(0x00);
			try {
				// ascii文字のみの場合こっち
				byte[] bodyBytes = GsmAlphabet.stringToGsm7BitPacked(body);
				bo.write(0x00);
				bo.write(dateBytes);
				bo.write(bodyBytes);
			} catch (EncodeException e) {
				// 2バイト文字を含む場合こっち
				System.out.println("try UCS2");
				try {
					byte[] textPart = body.getBytes("utf-16be");
					bo.write(0x0b);
					bo.write(dateBytes);
					bo.write(textPart.length);
					bo.write(textPart);
				} catch (UnsupportedEncodingException e1) {
				}
			}
			pdu = bo.toByteArray();
		} catch (IOException e) {
		}

		Intent intent = new Intent();
		intent.setClassName("com.android.mms",
				"com.android.mms.transaction.SmsReceiverService");
		intent.setAction("android.provider.Telephony.SMS_RECEIVED");
		intent.putExtra("pdus", new Object[] { pdu });
		context.startService(intent);
	}

	private static byte reverseByte(byte b) {
		return (byte) ((b & 0xF0) >> 4 | (b & 0x0F) << 4);
	}

このソースは単にサービスを呼び出しているだけなので、Permissionの設定は特に必要ありません。
こいつを実機で実行してみると、以下のようにちゃんと受信通知まで表示してくれます。
まるで本当にSMSを受信してるようです。


ただ、当然ながらメッセージアプリがアンインストールされちゃったりしている場合は、Serviceが反応しないのでこの方法は使えません。(レアケースだとは思いますが)

まとめ

  • SMSのContentProviderにデータを直接突っ込んでSMS受信を偽装する方法
  • SMSの受信サービスを直接呼び出してSMS受信を偽装する方法

を紹介しました。
これを使えば、アプリからの様々な通知をSMSで置き換えることができるので、うまく使えばユーザービリティを高められると思います!

Live Wallpaperで動画ファイルをそのまま表示する(とりあえずできたよ編)

Android OS 2.1からLive Wallpaper(ライブ壁紙)の機能が追加されていますが、いざLive Wallpaperを自分で作る、となると結構面倒です。

実装方法の詳細は、「Android 2.1の新機能「Live Wallpaper」で作る、美しく燃える“待ち受け”」に詳しいですが、WallpaperService.Engineの各種メソッドをオーバーライドして、SurfaceHolderを経由してCanvasに描画処理を行うことで、アニメーションを実現するのが常套手段のようです。

ですがこの方法はかなり面倒です。単にアニメーションを再生したいだけなのに、Javaでゴリゴリプログラムを書きたくない。
そもそも、mp4とかの動画ファイルが素材としてあるんだったらそれをそのまま表示できないのでしょうか?

というわけで色々試してみます。
MediaPlayerクラスで再生している動画はSurfaceHolderに対して表示できるので、まずはその方法で試してみます。

MediaPlayerにSurfaceHolderをそのままsetDiaplayしてやる

public class LiveWallpaperTest extends WallpaperService {
~~
    class LiveEngineTest extends Engine {

        private MediaPlayer mp;

        @Override
        public void onCreate(SurfaceHolder holder) {
            super.onCreate(holder);
            holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
            mp = new MediaPlayer();
            mp.setDisplay(holder);
            mp.setOnPreparedListener(new OnPreparedListener() {
                @Override
                public void onPrepared(MediaPlayer mp) {
                    mp.start();
                }
            });
            try {
                mp.setDataSource(LiveWallpaperTest.this, Uri.parse("content://media/external/video/media/1"));
            } catch (Exception e) {
                Log.e(TAG, "error");
            }
            mp.setOnCompletionListener(new OnCompletionListener() {
                @Override
                public void onCompletion(MediaPlayer mp) {
                    mp.stop();
                }
            });
        }

        // MediaPlayerをpreparaeする部分は省略

    }
~~
}

こいつを実行してみます。
さてうまく再生できるでしょうか?

ダメでした。logcatには以下のようなエラーが出力されます。

E/AndroidRuntime(  754): java.lang.UnsupportedOperationException: Wallpapers do not support keep screen on
E/AndroidRuntime(  754): 	at android.service.wallpaper.WallpaperService$Engine$2.setKeepScreenOn(WallpaperService.java:202)
E/AndroidRuntime(  754): 	at android.media.MediaPlayer.updateSurfaceScreenOn(MediaPlayer.java:895)
E/AndroidRuntime(  754): 	at android.media.MediaPlayer.setDisplay(MediaPlayer.java:573)

ということで、MediaPlayer.setDisplayの中で自動的に、SurfaceHolder.setKeepScreenOnが呼ばれますが、WallpaperのSurfaceHolderはこのメソッドをサポートしていないのです。

ここで一工夫します。Wallpaper用のSurfaceHolderをそのまま使っていると上記の例外が避けられないので、独自のSurfaceHolderのWrapperを自分でこしらえます。

独自のSurfaceHolder WapperをMediaPlayerにsetDiaplayしてやる

    class MySurfaceHolder implements SurfaceHolder {

        private SurfaceHolder surfaceHolder;

        public MySurfaceHolder(SurfaceHolder surfaceHolder) {
            this.surfaceHolder = surfaceHolder;
        }

        @Override
        public void addCallback(Callback callback) {
            surfaceHolder.addCallback(callback);
        }

        @Override
        public Surface getSurface() {
            return surfaceHolder.getSurface();
        }

        @Override
        public Rect getSurfaceFrame() {
            return surfaceHolder.getSurfaceFrame();
        }

        @Override
        public boolean isCreating() {
            return surfaceHolder.isCreating();
        }

        @Override
        public Canvas lockCanvas() {
            return surfaceHolder.lockCanvas();
        }

        @Override
        public Canvas lockCanvas(Rect dirty) {
            return surfaceHolder.lockCanvas(dirty);
        }

        @Override
        public void removeCallback(Callback callback) {
            surfaceHolder.removeCallback(callback);
        }

        @Override
        public void setFixedSize(int width, int height) {
            surfaceHolder.setFixedSize(width, height);
        }

        @Override
        public void setFormat(int format) {
            surfaceHolder.setFormat(format);
        }

        @Override
        public void setKeepScreenOn(boolean screenOn) {
            return;
        }

        @Override
        public void setSizeFromLayout() {
            surfaceHolder.setSizeFromLayout();
        }

        @Override
        public void setType(int type) {
            surfaceHolder.setType(type);
        }

        @Override
        public void unlockCanvasAndPost(Canvas canvas) {
            surfaceHolder.unlockCanvasAndPost(canvas);
        }
    }

見ての通り、setKeepScreenOnメソッドの実装を空にしている以外は、全てもとのSurfaceHolderに丸投げするだけです。
こいつを使ってもう一度動画を再生してみましょう。

public class LiveWallpaperTest extends WallpaperService {
~~
    class LiveEngineTest extends Engine {

        private MediaPlayer mp;

        @Override
        public void onCreate(SurfaceHolder holder) {
            super.onCreate(holder);
            holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
            mp = new MediaPlayer();
            mp.setDisplay(new MySurfaceHolder(holder));
            mp.setOnPreparedListener(new OnPreparedListener() {
                @Override
                public void onPrepared(MediaPlayer mp) {
                    mp.start();
                }
            });
            try {
                mp.setDataSource(LiveWallpaperTest.this, Uri.parse("content://media/external/video/media/1"));
            } catch (Exception e) {
                Log.e(TAG, "error");
            }
            mp.setOnCompletionListener(new OnCompletionListener() {
                @Override
                public void onCompletion(MediaPlayer mp) {
                    mp.stop();
                }
            });
        }

        @Override
		public void onDestroy() {
			super.onDestroy();
			if (mp != null) {
                mp.stop();
                mp.release();
            }
		}

        @Override
        public void onVisibilityChanged(boolean visible) {
            super.onVisibilityChanged(visible);
            if (visible) {
                play();
            }
        }

        private void play() {
            if (mp.isPlaying()) {
                mp.stop();
            }
            try {
                mp.prepareAsync();
            } catch (IllegalArgumentException e) {
            	Log.e(TAG, "error");
            } catch (SecurityException e) {
            	Log.e(TAG, "error");
            } catch (IllegalStateException e) {
            	Log.e(TAG, "error");
            }
        }
    }
~~
}

さてどうでしょうか?

見事、Live Wallpaperとして動画が表示されています!

ソースコード

githubにて公開しています。

から持っていってください。

まとめ

  • Live wallpaperのSurfaceHolderにMediaPlayerで再生した動画を表示しようとするとエラーでこける。
  • SurfaceHolderをそのまま使うんじゃなくて、独自のWrapperを用意すればMediaPlayerで再生した動画をそのままライブ壁紙に使える。

という話でした。

単に動画を表示するだけのライブ壁紙はつまらないですが、面白いアプリを作る一手段としてなら使えるんじゃないでしょうか。(電池をどれくらい食うか心配ですが。。)