@blog.justoneplanet.info

日々勉強

androidでArrayAdapterを使う

■実装

mListView = (ListView) findViewById(R.id.list);
mListView.setAdapter(mOriginalArrayAdapter);

mOriginalArrayAdapterを生成するとする。

public class OriginalArrayAdapter extends ArrayAdapter<OriginalDataRow> {
    private LayoutInflater inflater;

    /**
     * セル内における可変要素
     */
    public static class ViewHolder {
        public TextView text;
    }

    public OriginalArrayAdapter(Context context, int textViewResourceId, ArrayList<OriginalDataRow> data) {
        super(context, textViewResourceId, data);
        inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        final OriginalDataRow data = getItem(position);// フィールドなどから取得してはいけない...(a)

        // viewのセットアップ
        ViewHolder holder = null;
        if (convertView == null){
            holder = new ViewHolder();
            convertView = inflater.inflate(R.layout.cell_original_data, null);
            holder.text = (TextView) convertView.findViewById(R.id.text);
            convertView.setTag(holder);
        }
        else {
            holder = (ViewHolder) convertView.getTag();
        }

        // dataをviewに書き出す
        holder.text.setText(data.text);
        return convertView;
    }
}

filterを使用する

    @Override
    public void onTextChanged(CharSequence s, int start, int count, int after) {
        String query = mEditText.getText().toString();
        if (query.equals("")) {
            mListView.clearTextFilter();
        }
        else {
            mListView.setFilterText(query);
        }
    }

(a)の部分でsuperクラスのgetItemを使用しないとフィルタリングが上手く適用されない。また、filterを使用する場合は、リストアイテムのtoString値が使用されるため、以下のように必要に応じてOverrideする必要がある。

public class OriginalDataRow {
    public String name;
    @Override
    public String toString() {
        return name;// filterに使用する文字列
    }
}

CakePHP 2.0.4で多言語対応する

■手順

アプリケーションのrootディレクトリで以下のコマンドを実行する。

php lib/Cake/Console/cake.php i18n

以下のように表示される。

Welcome to CakePHP v2.0.4 Console
---------------------------------------------------------------
App : app
Path: /var/www/domain/app/
---------------------------------------------------------------
I18n Shell
---------------------------------------------------------------
[E]xtract POT file from sources
[I]nitialize i18n database table
[H]elp
[Q]uit
What would you like to do? (E/I/H/Q)

Eを選択する。

What is the path you would like to extract?
[Q]uit [D]one
[/var/www/domain/app/] >

Dを選択する。

What is the path you would like to output?
[Q]uit
[/var/www/domain/app//Locale] >
Would you like to merge all domains strings into the default.pot file? (y/n)

yを選択すると、ファイルを読み込んでデフォルトの言語ファイルが生成される。

  • /var/www/domain/app/Locale/default.pot
  • /var/www/domain/app/Locale/eng

/var/www/domain/app/Locale/engをコピーして/var/www/domain/app/Locale/jpnを作り、/var/www/domain/app/Locale/default.potをコピーして、/var/www/domain/app/Locale/jpn/LC_MESSAGES/default.poを作る。この時、拡張子を変更しないと上手く動作しない。

翻訳ファイル

翻訳ファイルは以下のように記述する。

#: Controller/HogeController.php:65;166;188;214
msgid "Hello world"
msgstr "世界こんにちわ"

CakePHP 2.0でログインフォームを作る

■実装

app/Controller/UsersController.php

App::uses('AppController', 'Controller');
/**
 * Users Controller
 * @property User $User
 */
class UsersController extends AppController {

    public $uses = array('User');

    public $components = array(
        'Session',
        'Auth' => array(
            'loginRedirect' => array('controller'  => 'users', 'action' => 'index'),// ログイン後のリダイレクト先
            'logoutRedirect' => array('controller' => 'pages', 'action' => 'display', 'home')// ログアウト後のリダイレクト先
        )
    );

    public function beforeFilter() {
        //$this->Auth->allow('index'/*, 'add'*/);// 認証を除外するアクションを指定する
    }

    /**
     * ユーザを追加する画面
     * @return void
     */
    public function add() {
        /*if ($this->request->is('post')) {
            $this->User->create();
            if ($this->User->save($this->request->data)) {
                $this->Session->setFlash(__('The user has been saved'));
                $this->redirect(array('action' => 'index'));
            }
            else {
                $this->Session->setFlash(__('The user could not be saved. Please, try again.'));
            }
        }*/
    }

    /**
     * ログイン画面
     * @return void
     */
    public function admin_login() {
        if ($this->Auth->login()) {
            $this->redirect($this->Auth->redirect());
        }
        else {
            $this->Session->setFlash(__('Invalid username or password, try again'));
        }
    }

    /**
     * ログアウト画面
     * @return void
     */
    public function admin_logout() {
        $this->redirect($this->Auth->logout());
    }
}

app/Model/User.php

App::uses('AppModel', 'Model');

/**
 * User Model
 * ユーザのモデル
 */
class User extends AppModel {
    public function __construct($id = false, $table = null, $ds = null) {
        $this->useDbConfig = Configure::read('Config.environment');
        parent::__construct($id, $table, $ds);
    }

    /**
     * 保存前に実行される
     * @return void
     */
    public function beforeSave() {
        if (isset($this->data[$this->alias]['password'])) {
            $this->data[$this->alias]['password'] = AuthComponent::password($this->data[$this->alias]['password']);
        }
        return true;
    }

}

Cake1系ではpasswordというカラム名が自動的にハッシュ化されていた。Cake2系では上述のように実装する必要がある。

app/View/Users/admin_login.ctp

以下のようにログインフォームを記述する。

<?php echo $this->Session->flash('auth'); ?>
<?php echo $this->Form->create('User');?>
<fieldset>
<legend><?php echo __('Please enter your username and password'); ?></legend>
<?php
echo $this->Form->input('username');
echo $this->Form->input('password');
?>
</fieldset>
<?php echo $this->Form->end(__('Login'));?>

$thisを介してSessionとFormにアクセスするところがCake2系では異なる。

app/View/Users/admin_logout.ctp

<h3>ログアウトしました。</h3>

■参考

殆ど参考文献の簡略化日本語版だな。

androidのSQLiteでAUTOINCREMENT値を取得する

SELECT `seq` FROM sqlite_sequence WHERE `name` = 'TABLENAME';

上述を踏まえて以下のようにすることで取得できる。

    /**
     * auto_incrementで代入した最大値
     * @return
     */
    public long getLastInsertId() {
        long index = 0;
        SQLiteDatabase sdb = getReadableDatabase();
        Cursor cursor = sdb.query(
                "sqlite_sequence",
                new String[]{"seq"},
                "name = ?",
                new String[]{TABLENAME},
                null,
                null,
                null,
                null
        );
        if (cursor.moveToFirst()) {
            index = cursor.getLong(cursor.getColumnIndex("seq"));
        }
        cursor.close();
        return index;
    }

robotiumを使ってみる

@ayunyanさんに教えていただいたので使ってみる。

■シナリオ

以下のようにandroid版顔文字辞典を操作するとする。

  1. メニューを開く
  2. カテゴリ追加をタップする
  3. カテゴリ名を入力し登録する(a)
  4. 登録したカテゴリ(a)をロングタップする
  5. 編集を選択する
  6. カテゴリ名を変更して登録する(b)
  7. 編集したカテゴリ(b)をロングタップする
  8. 削除をタップする

■テストコード

以下のようにして「みんなの顔文字辞典」のカテゴリー追加・編集・削除のダイアログのテストを行う。

package info.justoneplanet.android.kaomoji.test.dialog;

import com.jayway.android.robotium.solo.Solo;

import info.justoneplanet.android.kaomoji.KaomojiFavorite;
import info.justoneplanet.android.kaomoji.R;
//import android.app.Instrumentation;
import android.test.ActivityInstrumentationTestCase2;

/**
 * カテゴリを追加するダイアログのテストケース
 * @author justoneplanet
 * カテゴリの登録・編集・削除は決まった順番で行われる必要があるためメソッド名で順序を指定した
 */
public class CategoryDialogBuilderTest extends ActivityInstrumentationTestCase2<KaomojiFavorite> {

    private KaomojiFavorite mActivity;
    //private Instrumentation mInstrumentation;
    private Solo solo;

    private static final String OLD_CATEGORY_NAME = "新しいカテゴリ";
    private static final String NEW_CATEGORY_NAME = "新カテゴリ";

    public CategoryDialogBuilderTest() {
        super("info.justoneplanet.android.kaomojifavorite", KaomojiFavorite.class);
    }

    @Override
    public void setUp() throws Exception {
        super.setUp();
        mActivity = getActivity();
        //mInstrumentation = getInstrumentation();
        solo = new Solo(getInstrumentation(), getActivity());
    }

    @Override
    public void tearDown() throws Exception {
        super.tearDown();
        mActivity = null;
        //mInstrumentation = null;
    }

    /**
     * カテゴリを正しく追加できるか
     */
    public void testCategory1Add() {
        // メニュー > カテゴリを追加
        solo.clickOnMenuItem(mActivity.getString(R.string.menu_category_add));
        solo.enterText(0, OLD_CATEGORY_NAME);// 新しいカテゴリ名を入力
        // 登録するボタン
        solo.clickOnButton(mActivity.getString(R.string.function_register_category));
    }

    /**
     * カテゴリを正しく追加できるか
     */
    public void testCategory2Edit() {
        // リストの最後の要素がわからないのでテキストで探索
        solo.clickLongOnText(OLD_CATEGORY_NAME);
        // カテゴリの編集をクリック
        solo.clickOnText(mActivity.getString(R.string.function_edit_category));
        solo.clearEditText(0);// テキストを消す
        solo.enterText(0, NEW_CATEGORY_NAME);// 新カテゴリ名を入力
        // 登録するボタン
        solo.clickOnButton(mActivity.getString(R.string.function_register_category));
    }

    /**
     * カテゴリを削除できるか
     * @throws InterruptedException
     */
    public void testCategory3Delete() {
        // リストの最後の要素がわからないのでテキストで探索
        solo.clickLongOnText(NEW_CATEGORY_NAME);
        // カテゴリの削除をクリック
        solo.clickOnText(mActivity.getString(R.string.function_delete_category));
        // すぐに終了してしまうと削除クエリが実行されないみたい(Thread.sleepよりもこっち)
        solo.waitForDialogToClose(1000);
    }
}

操作する要素がテキストで指定できる探索が非常にテストを行いやすい!感謝!

clickInListについて

以下のようにして使用した。おそらく表示しているlistアイテムの配列の中でのpositionを指定していると思われる。また、表示されていないlistアイテムを上からの位置で指定することはできなかった。

private String tapEmoticonAfterCategory(String category, int position) {
    solo.clickOnText(category);
    solo.waitForDialogToClose(1000);// 作用するまでwait
    ArrayList<TextView> list = solo.clickInList(position);// 表示されているlistの要素配列のposition
    solo.waitForDialogToClose(1000);// 作用するまでwait
    Log.e("tapped", String.valueOf(list.get(0).getText()));
    return String.valueOf(list.get(0).getText());
}

■参考

おまけ

当初は以下のようにThread.sleepを使用していたが、ProgressDialogを表示し通信結果を待つような場合に期待した動作にならなかった。

    /**
     * カテゴリを削除できるかテストする
     * @throws InterruptedException
     */
    public void testCategory3Delete() throws InterruptedException {
        // リストの最後の要素がわからないのでテキストで探索
        solo.clickLongOnText(NEW_CATEGORY_NAME);
        // カテゴリの削除をクリック
        solo.clickOnText(mActivity.getString(R.string.function_delete_category));
        Thread.sleep(1000);// 期待の動作とならない
    }

テキスト(半角カナ?)によってはいまいち探索できないときがある。

AsyncTaskのテストをする

■コード

以下のようにUIスレッドでAsyncTaskをインスタンス化する必要がある。

public class EveryoneTaskTest extends ActivityInstrumentationTestCase2<Kaomoji> {
    private Kaomoji mActivity;
    private Instrumentation mInstrumentation;
    private EveryoneTask everyoneHttp;
    private CountDownLatch countDownLatch;
    private String mResult;

    public EveryoneTaskTest() {
        super("info.justoneplanet.android.kaomoji", Kaomoji.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        mActivity = getActivity();
        mInstrumentation = getInstrumentation();
        countDownLatch = new CountDownLatch(1);
    }
    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
        mActivity = null;
        mInstrumentation = null;
    }

    public void testExecute() throws InterruptedException, JSONException {
        mActivity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                everyoneHttp = new EveryoneTask(new EveryoneTask.Observer(){
                    @Override
                    public void onHttpUpdate(String result, MODE mode) {
                        mResult = result;
                        countDownLatch.countDown();// カウントを0にする
                    }
                });
                everyoneHttp.execute(null);
            }
        });
        countDownLatch.await();// countが0になるまで処理を待機

        // 要素の検証
        JSONObject elm;
        JSONArray ary = new JSONArray(mResult);
        assertTrue(ary.length() == 150);
    }
}

失敗例

以下のようにアプリのメインスレッド以外でAsyncTaskのインスタンス化を行なってはならない。挙動が不安定になりonPostExecuteが実行されない。

/**
 * みんなな顔文字を通信して取得するクラスのテストケース
 * @author justoneplanet
 */
public class EveryoneHttpTest extends AndroidTestCase implements EveryoneHttp.Observer {
    CountDownLatch countDownLatch;
    private EveryoneHttp everyoneHttp;
    private String mResult;

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        countDownLatch = new CountDownLatch(1);
    }

    public void testDoInBackground() throws InterruptedException, JSONException {
        everyoneHttp = new EveryoneHttp(this);// get everyone's emoticons from transfer
        everyoneHttp.execute();
        countDownLatch.await();// countが0になるまで処理を待機

        JSONArray ary = new JSONArray(mResult);
        assertTrue(ary.length() == 150);// 要素数の検証

        // 要素の検証
        JSONObject elm = ary.getJSONObject(0);
        Log.e("elm", elm.toString());
        assertFalse(elm.getString("face").equals(null));
        assertFalse(elm.getString("tag").equals(null));
    }

    /**
     * 通信完了後に実行される
     */
    @Override
    public void onHttpUpdate(String result) {
        mResult = result;
        countDownLatch.countDown();// カウントを0にする
    }
}

非同期処理自体はAsyncTaskをwrapしたクラスで行なっている。Observerを使ったのでテストコードもすっきり。

androidでAndroidWebDriverを使ってみる

元ネタはTim BrayのIntroducing Android WebDriverという記事。

■準備

テストプロジェクトで右クリックし「propaties> Java Build Path > Libraries > Add External JARs」で以下のjarをインポートする。

  • android_webdriver_library-srcs.jar
  • android_webdriver_library.jar
  • guava-10.0.1.jar

■コード

Tim様の記事ではgoogle.comが使用されているが以下のような理由によってテストにパスしない。

  • ロケールが違うので日本語表示される
  • 結果がリッチすぎて上手くdomを抽出できない

テストコード内でgoogle.comのリンクをクリックするようにできると思うが、html構造を把握するのが大変なので以下のようなコードでmozillaのサイトでテストすることにした。

package bootcamp2011.android.test;

import java.util.List;

import bootcamp2011.android.Bootcamp2011Activity;
import android.test.ActivityInstrumentationTestCase2;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.android.AndroidWebDriver;;

public class Bootcamp2011ActivityTest extends
        ActivityInstrumentationTestCase2<Bootcamp2011Activity> {

    private AndroidWebDriver driver;

    public Bootcamp2011ActivityTest() {
        super("bootcamp2011.android.bootcamp2011activity", Bootcamp2011Activity.class);
    }

    @Override
    protected void setUp() throws Exception {
      driver = new AndroidWebDriver(getActivity());
    }

    @Override
    protected void tearDown() {
       driver.quit();
    }

    public void testGoogleShouldWork() {
        // mozillaのサイトを訪問
        driver.get("https://developer.mozilla.org/ja/JavaScript");

        // 検索boxを抽出して文字入力し送信
        WebElement searchBox = driver.findElement(By.id("q"));
        searchBox.sendKeys("querySelector");
        searchBox.submit();

        // 検索結果にquerySelectorという文字列が含まれているかテスト(仮)
        WebElement resultSection = driver.findElement(By.id("search-results"));
        List<WebElement> searchResults = resultSection.findElements(By.tagName("table"));
        WebElement result = searchResults.get(0);
        assertTrue(result.getText().contains("querySelector"));
    }
}

By.cssSelector()というメソッドがあるのだが上手く働かない…一応、動作するサンプルが「android-sdk-mac_x86/extras/google/webdriver/TestAnAndroidWebApp/src/simple/app/test/SimpleGoogleTest.java」にある。

SimpleCursorTreeAdapterのgetChildrenCursorがアプリ起動時にも実行される

android 2.3未満で起こった挙動のメモ。android 2.3以降では起こらない。

メモ

以下のようにして開閉可能なListActivityを定義する。

public class KaomojiFavorite extends ExpandableListActivity implements FavoriteHelper.Listener {
    private FavoriteHelper favoriteHelper;
    private CategoryHelper categoryHelper;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getExpandableListView().setCacheColorHint(0);// scroll時の背景

        // カテゴリを取得
        categoryHelper = new CategoryHelper(getApplicationContext(), null);
        Cursor categoryCursor = categoryHelper.getCursor();
        startManagingCursor(categoryCursor);

        // お気に入りを取得してlistにセット
        favoriteHelper = new FavoriteHelper(getApplicationContext(), null);
        ExpandableListAdapter adapter = favoriteHelper.getSimpleAdapter(this, categoryCursor);
        setListAdapter(adapter);

    }

カテゴリ毎にお気に入りを表示する仕組みだ。FavoriteHelperクラスの実装は以下のようになっている。

    public ExpandableListAdapter getSimpleAdapter(Listener listener, Cursor categoryCursor)
    {
        mListener = listener;
        ExpandableListAdapter adapter = new ExpandableListAdapter(
                mContext,
                categoryCursor,
                R.layout.category,
                new String []{"_id", "name"},
                new int []{R.id.list_id, R.id.list_category},
                R.layout.list,
                new String []{"item", "_id"},
                new int []{R.id.list_item, R.id.list_id}
        );
        return adapter;
    }

    /**
     * categoryデータ(Cursor)を元にしてExpandableListActivity用のAdapterを生成する
     * @author justoneplanet
     */
    public class ExpandableListAdapter extends SimpleCursorTreeAdapter {

        public ExpandableListAdapter(Context context, Cursor groupCursor,
                int groupLayout, String[] groupFrom, int[] groupTo,
                int childLayout, String[] childFrom, int[] childTo) {
            super(context, groupCursor, groupLayout, groupFrom, groupTo, childLayout, childFrom, childTo);
        }

        /**
         * カテゴリ用のCursorからIDを取得して子データを検索取得する
         * android 2.2以前ではアプリ起動時にも実行される
         */
        @Override
        protected Cursor getChildrenCursor(Cursor groupCursor) {
            final long idCategory = groupCursor.getLong(groupCursor.getColumnIndex("_id"));
            SQLiteDatabase sdb = getReadableDatabase();
            Cursor cursor = sdb.query(
                    "favorite",
                    new String[]{"_id", "id_category", "item"},
                    "id_category = ?",
                    new String[]{String.valueOf(idCategory)},
                    null,
                    null,
                    null,
                    null
            );
            mListener.onGetChildrenCursor(cursor);
            return cursor;
        }
    }

以上。めも。

Objective-CでSingleton

■実装

#import <Foundation/Foundation.h>

@interface TapManager : NSObject {
}
+ (id)instance;
+ (id)allocWithZone:(NSZone *)zone;
- (id)copyWithZone:(NSZone *)zone;
- (id)retain;
- (unsigned)retainCount;
- (void)release;
- (id)autorelease;
- (void)registerWithFace:(NSString *)face withTag:(NSString *)tag;
@end
#import "TapManager.h"
#import "HistoryDB.h"
#import "Util.h"

@implementation TapManager
static TapManager *_instance = nil;

+ (TapManager *)instance {
    @synchronized(self) {
        if (_instance == nil) {
            [[self alloc] init];//代入はしない
        }
    }
    return _instance;
}

// zoneからオブジェクトを生成する
+ (id)allocWithZone:(NSZone *)zone {
    @synchronized(self) {
        if (_instance == nil) {
            _instance = [super allocWithZone:zone];// 最初のみ代入する
            return _instance;// 最初のみ値を返す
        }
    }
    return nil;
}

- (id)copyWithZone:(NSZone *)zone {
    return self;
}

- (id)retain {
    return self;
}

- (unsigned)retainCount {
    return UINT_MAX;
}

- (void)release {
}

- (id)autorelease {
    return self;
}

- (void)registerWithFace:(NSString *)face withTag:(NSString *)tag {
    UIPasteboard *pb = [UIPasteboard generalPasteboard];
    [pb setValue:face forPasteboardType:@"public.utf8-plain-text"];
    [Util showAlert:@"Hello" text:@"World"];
    // ...その他の処理
}
@end

NSNullとnilを判定する

■失敗例1

UITableViewのcellを設定する部分で以下のようなコードを書きクラッシュした。

NSString *tag = (NSString *)[row objectForKey:@"tag"];
[cell.tag setText:tag];
return cell;

■失敗例2

nilの場合があるなと考えて以下のようにする。

NSString *tag = (NSString *)[row objectForKey:@"tag"];
if (tag == nil) {
    [cell.tag setText:@""];
}
else {
    [cell.tag setText:tag];
}
return cell;

残念ながらクラッシュは依然として起こる。

■解

tagはNSNullであり以下の判定に修正。

NSString *tag = (NSString *)[row objectForKey:@"tag"];
if (tag == nil || [tag isEqual:[NSNull null]]) {
    [cell.tag setText:@""];
}
else {
    [cell.tag setText:tag];
}
return cell;

参考

NSNullは配列の要素のようなオブジェクトしか許されない所でnilを表すのに用いられる