2015/03/08

ループする HorizontalScrollView

Android の widget に有りそうで無いのが左右が繋がって循環ループできる View。
横スクロールする HorizontalScrollView は有るけど端っこで止まってしまう。

ググっても質問ばかりでまともな回答が無いようなので汎用的な widget を作ってみた。

ソース

200 行程のクラス1つに収まった。

LoopHScrollView.java:

// Copyright (c) 2015 kotemaru.org  /  License is APL-2.0
package org.kotemaru.android.fw.widget;

import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;

public class LoopHScrollView extends HorizontalScrollView {
    private InnerLayout mInnerLayout;
    private AnimeManager mAnimeManager;
    private GestureDetector mGestureDetector;
    private CsutomOnGestureListener mOnGestureListener = new CsutomOnGestureListener();

    public LoopHScrollView(Context context) {
        this(context, null);
    }
    public LoopHScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public LoopHScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mAnimeManager = new AnimeManager();
        mGestureDetector = new GestureDetector(context, mOnGestureListener);

        mInnerLayout = new InnerLayout(context);
        mInnerLayout.setOrientation(LinearLayout.HORIZONTAL);
        FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
                LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
        mInnerLayout.setLayoutParams(params);
        this.addView(mInnerLayout);
    }

    public void setChildViewGroup(ViewGroup child) {
        CloneView clone1 = new CloneView(getContext(), child);
        CloneView clone2 = new CloneView(getContext(), child);

        LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(
                LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
        mInnerLayout.removeAllViews();
        mInnerLayout.addView(clone1, childParams);
        mInnerLayout.addView(child, childParams);
        mInnerLayout.addView(clone2, childParams);
    }

    public class InnerLayout extends LinearLayout {
        public InnerLayout(Context context) {
            super(context);
        }
        @Override
        public void onWindowFocusChanged(boolean hasFocus) {
            super.onWindowFocusChanged(hasFocus);
            resizing();
        }
        @Override
        public void onSizeChanged(int w, int h, int oldw, int oldh) {
            super.onSizeChanged(w, h, oldw, oldh);
            resizing();
        }
        private void resizing() {
            if (getChildCount() == 3) {
                View clone1 = this.getChildAt(0);
                View origin = this.getChildAt(1);
                View clone2 = this.getChildAt(2);
                int width = origin.getMeasuredWidth();
                if (width < LoopHScrollView.this.getMeasuredWidth()) {
                    // TODO: Can not scroll small child view.
                    mInnerLayout.removeView(clone1);
                    mInnerLayout.removeView(clone2);
                } else {
                    clone1.setMinimumWidth(width);
                    clone2.setMinimumWidth(width);
                }
            }
        }
    }

    public static class CloneView extends ViewGroup {
        ViewGroup mOrigin;

        public CloneView(Context context, ViewGroup origin) {
            super(context);
            mOrigin = origin;
        }
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
        }
        @Override
        protected void dispatchDraw(Canvas canvas) {
            mOrigin.draw(canvas);
        }
        @Override
        public boolean dispatchKeyEvent(KeyEvent event) {
            return mOrigin.dispatchKeyEvent(event);
        }
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            boolean result = mOrigin.dispatchTouchEvent(ev);
            this.invalidate();
            return result;
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) mOnGestureListener.onDown(ev);
        return super.onInterceptTouchEvent(ev);
    }
    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        return mGestureDetector.onTouchEvent(ev);
    }

    private class CsutomOnGestureListener extends GestureDetector.SimpleOnGestureListener {
        boolean mIsFirstScroll = true; // Note: 子要素のACTION_DOWNが届かず誤動作するので初回を無視する。

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (!mIsFirstScroll) mAnimeManager.startScroll(velocityX);
            mIsFirstScroll = false;
            return true;
        }
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            mAnimeManager.startFling(-velocityX / 50);
            return true;
        }
        @Override
        public boolean onDown(MotionEvent ev) {
            mAnimeManager.stopFling();
            mIsFirstScroll = true;
            return true;
        }
    };

    private class AnimeManager {
        private static final int INTERVAL = 50; // ms
        private static final float ATTENUAION_RATE = 0.90F;
        private float mDelta = 0;
        private final Runnable mUpdateRunner = new Runnable() {
            @Override
            public void run() {
                onUpdate();
            }
        };
        public void update() {
            Handler handler = getHandler();
            if (handler == null) return;
            handler.postDelayed(mUpdateRunner, INTERVAL);
        }

        public void startScroll(float delta) {
            stopFling();
            if (!loopScrollPosition()) {
                scrollBy((int) delta, 0);
            }
        }
        public void startFling(float delta) {
            mDelta = delta;
            update();
        }
        public void stopFling() {
            mDelta = 0.0F;
        }
        private void onUpdate() {
            if (Math.abs(mDelta) > 1.0F) {
                scrollBy((int) mDelta, 0);
                mDelta = mDelta * ATTENUAION_RATE;
                loopScrollPosition();
                update();
            } else {
                stopFling();
            }
        }
        private boolean loopScrollPosition() {
            int curX = computeHorizontalScrollOffset();
            int unitWidth = computeHorizontalScrollRange() / 3;
            if (curX > unitWidth * 1.8F) {
                scrollTo(curX - unitWidth, 0);
                return true;
            } else if (curX < unitWidth * 0.2F) {
                scrollTo(curX + unitWidth, 0);
                return true;
            }
            return false;
        }
    }
}
  • やっていることは同じ 子View を3つ横に並べてスクロールさせているだけ。
    • 端に来ると真ん中にジャンプする。
  • 肝は指定された子Viewの表示をコピーする CloneView 内部クラス。
    • dispatch{Draw|KeyEvent|MotionEvent}() メソッドをオリジナルに転送するだけだがほぼ同じ動きをさせる事ができている。

使い方

使い方は layout.xml に LoopHScrollView を指定して Activity.onCreate() からループする内容となる子ViewGroupを設定するだけ。
ループする内容は実装上の都合により別の layout に定義する必要がある。

Activity の main_activity.xml の抜粋

    <org.kotemaru.android.fw.widget.LoopHScrollView
        android:id="@+id/loopHScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    </org.kotemaru.android.fw.widget.LoopHScrollView>
  • LoopHScrollView を好きな所へ埋め込む。

ループする内容 の loop_scroll_item.xml の例

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content" android:layout_height="match_parent"
    android:orientation="vertical" >
    <LinearLayout  android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:orientation="horizontal" >
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#ff8888" android:text="Red" android:textSize="24dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#ffff88" android:text="Yellow" android:textSize="20dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#88ff88" android:text="Green" android:textSize="24dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#88ffff" android:text="Cyan" android:textSize="24dp" />
        <CheckBox android:layout_width="100dp" android:layout_height="wrap_content"
            android:background="#8888ff" android:text="Blue" android:textSize="24dp" />
    </LinearLayout>
</LinearLayout>
  • 横幅は LoopHScrollView より大きい幅が必要。
    • 小さいとスクロールできない。
  • トップ要素は ViewGroup が必須。

onCreate() の実装

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main_activity);

    LoopHScrollView scrollView = (LoopHScrollView) findViewById(R.id.loopHScrollView);
    LayoutInflater inflater = LayoutInflater.from(this);
    ViewGroup child = (ViewGroup) inflater.inflate(R.layout.loop_scroll_item, null, false);
    scrollView.setChildViewGroup(child);
}
  • 子要素を生成して設定しているだけ。
    • 子要素は ViewGroup なら何でも良い。
  • イベントは KeyKevent と MotionEvent は拾う。
    • 但し、ドラッグ系の操作はできない。(HorizontalScrollViewと同じ)

実行結果

ちゃんとループ出来てます。
セレクタ系 UI では使えそうな気がする。

追記(2016/6/4)

フラグメントから使うと上手くいかないとのコメントが有ったので修正方法。

    public class InnerLayout extends LinearLayout {
        public InnerLayout(Context context) {
            super(context);
        }

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            if (getChildCount() == 3) {
                View clone1 = this.getChildAt(0);
                View origin = this.getChildAt(1);
                View clone2 = this.getChildAt(2);
                int width = origin.getMeasuredWidth();
                if (width < LoopHScrollView.this.getMeasuredWidth()) {
                    // TODO: Can not scroll small child view.
                    mInnerLayout.removeView(clone1);
                    mInnerLayout.removeView(clone2);
                } else {
                    clone1.setMinimumWidth(width);
                    clone2.setMinimumWidth(width);
                }
                super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
  • サイズ補正のタイミングを onMeasure() に移動して解決。

2015/03/05

縦横比固定FrameLayout

Android の画面設計をやっていると縦横比(アスペクト比)を固定したまま 端末の画面サイズ一杯にレイアウトしたい事がよく有るのだけど標準の機能に無い。

ググってみたところ自力で計算して設定するしか無いらしい。

このサイトを参考に汎用的に使える FrameLayout を作ってみた。

FixedAspectFrameLayout.java:

// Copyright (c) 2015 kotemaru.org  / License is APL-2.0
package org.kotemaru.android.fw.widget;

import org.kotemaru.android.fw.R;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;

/**
 * 縦横比固定FrameLayout。
 * - 上位のLayoutによって動的に決定された幅(or高さ)に応じて縦横比が一定になるように高さ(or幅)を決定する。
 * - 縦横比はカスタム属性 aspectRate に float (幅÷高さ) で与る。
 * - 参考:http://stackoverflow.com/a/13846628/804479
 * 
 * @author kotemaru.org
 */
public class FixedAspectFrameLayout extends FrameLayout {
    private float mAspectRate;

    public FixedAspectFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.fw_FixedAspectFrameLayout);
        this.mAspectRate = a.getFloat(R.styleable.fw_FixedAspectFrameLayout_aspectRate, 1.0F);
        a.recycle();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        if (widthMode == MeasureSpec.EXACTLY && heightMode != MeasureSpec.EXACTLY) {
            int h = (int) (MeasureSpec.getSize(widthMeasureSpec) / mAspectRate);
            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY));
        } else if (widthMode != MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            int w = (int) (MeasureSpec.getSize(heightMeasureSpec) * mAspectRate);
            super.onMeasure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), heightMeasureSpec);
        } else {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
}

attr.xml

<resources>
    <declare-styleable name="fw_FixedAspectFrameLayout">
        <attr name="aspectRate" format="float" />
    </declare-styleable>
</resources>

 

使い方

使い方は layout.xml に FixedAspectFrameLayout を指定してカスタム属性の aspectRate を指定するだけ。

layout.xml

    <org.kotemaru.android.fw.widget.FixedAspectFrameLayout
        xmlns:custom="http://schemas.android.com/apk/res/org.kotemaru.android.fw"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#00ff00"
        custom:aspectRate="2.35" >
    </org.kotemaru.android.fw.widget.FixedAspectFrameLayout>
  • この例ではシネスコのアスペクト比の FrameLayout になるので内側に適当な View を match_parent で入れれば良い。

実行結果

 

  • 端末も縦横も関係無く縦横比を維持したしたまま画面一杯にレイアウトできている。

2014/12/29

Androidで非同期HTTP通信

Androidフレームワークに対する疑問の一つに HttpClient が有る。

通信は非同期で行えと口をすっぱくして言うくせに標準の HttpClient は同期式しか用意されていない。 しかもこいつは connect 処理に対してはキャンセルが効かない。 タイムアウトしか出来ないと言う腐った実装だったりする。

非同期通信ライブラリくらい有るだろうと思って調べてみたら3つほど出てきた。

Apache HttpAsyncClient 4.0

Android で動かすとエラーになる。
Android に入っている Apache HttpCore が古いため。
最新の HttpCore を使うにはパッケージ名を変更して別のライブラリとしてインストールする必要が有るらしい。

ボツ。

loopj Android Asynchronous Http Client

定番ぽい雰囲気。
でも標準の HttpClient をラップしているだけで NIO は使って無い。
つまりスレッドの占有状態は変わらない。
しかも API がいまいちダサい。

俺的にちょっと違う。

koush AndroidAsync

NIO 使って1スレッドで動くと書いてある。おぉ求めていた物。
でもサンプルコードがちょこっと有るだけでドキュメントが無い。
ソースも見てみたがコメントすら書いてない。orz

作りかけなのかドキュメント書くのが嫌いなのかは分からんけどいずれにせよライブラリとしては使えない。

だったら自分で書いてやらぁ

腹が立って来たので自分で実装してみることにした。

  • NIO + Selector で通信スレッドを1つで済ませる。
  • API は極力標準の HttpClient をパクって。
  • レスポンスは InputStream と ByteBuffer を選べる用に。
  • Keep-Alive と Chunked-Transfer をサポート。

ぐらいで。

分かってたけど簡単じゃ無いね NIO は。
全部のクラスが状態遷移マシンですわ。

何とか最低限のテストコードを通せました。

InputStream を受け取る POST のサンプルコード。

クラスが Async~ になるだけでほぼ標準のHttpClientと同じように使える。
InputStream は全文をオンメモリに持つので大きいデータには使えない。 PipedInputStream を使うオプションが有ってもいいかもしれない。
ちなみに execute() は UIスレッドで実行してもエラーにならない。

AsyncHttpClient mClient = new AsyncHttpClient();
private void doSend() {
    AsyncHttpPost request = new AsyncHttpPost("http://192.168.0.2/cgi-bin/log.sh");
    HttpEntity httpEntity = new StringEntity("Test data");
    request.setHttpEntity(httpEntity);

    mClient.execute(request, new AsyncHttpListenerBase() {
        @Override
        public void onResponseBody(HttpResponse httpResponse) {
            if (httpResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                // サーバからエラー
            }
            try {
                InputStream is = httpResponse.getEntity().getContent();
                BufferedReader br = new BufferedReader(new InputStreamReader(is));
                String line;
                while ((line = br.readLine()) != null) {
                    Log.i("DEBUG", "->" + line);
                }
                br.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
}

ByteBuffer を受け取る GET のサンプルコード。

ByteBuffer を直接で無く ByteBufferReader で受け取るのは使い終わった ByteBuffer を返して貰うため。(再利用する)
この辺り利便性と実行効率を両立させるのが難しいです。

Google さんは Chunked で応答して来ますがアプリに渡すときには平文に変換しています。ここが実装上の一番面倒なところ。

それと FileChannel って Selector 使えないのね。はじめて知ったわ。

private void doSend2() {
    AsyncHttpGet request = new AsyncHttpGet("http://www.google.co.jp/");
    mClient.execute(request, new AsyncHttpListenerBase() {
        FileChannel mFileChannel;

        @Override
        public boolean isResponseBodyPart() {
            return true;
        }
        @Override
        public void onResponseBodyPart(ByteBufferReader transporter) {
            try {
                if (mFileChannel == null) {
                    @SuppressWarnings("resource")
                    FileOutputStream file = new FileOutputStream(getFilesDir()+"/index.html");
                    mFileChannel = file.getChannel();
                }

                ByteBuffer buffer = transporter.read();
                if (buffer != null) {
                    while (buffer.hasRemaining()) {
                        if (mFileChannel.write(buffer) == -1) break;
                    }
                } else {
                    mFileChannel.close();
                }
                transporter.release(buffer);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        @Override
        public void onResponseBody(HttpResponse httpResponse) {
            // not called.
        }
    });
}

まとめ

結局現状では loopj で妥協するくらいしか無さそう。

今回作ったライブラリはちゃんとテストを行えば実用になりそうな気がする。
興味のある人はこちらからどうぞ

追記

HTTPSも欲しいなと思って作り始めたらどつぼに嵌ったよ。
JSSE のドキュメントがアバウト過ぎて訳分かんねー。
でも何とか Selector + SSLEngine の組み合わせで動かすことに成功。

需要は無いと思うけどソースはこちら


2014/12/06

PureJavaでAndroidのSQLite

Android で SQLite を使うときに結構冗長になるので何とかならないかと考えてみた。

Android 向けの O/R マッパーもぼちぼち出てきているようだけど そもそも Android でそんなに大規模なテーブル構成を使うことも無いので XML とかでテーブル定義書かされるのもウザかったりする。

なので目指すのは標準の Java だけでシンプルに SQLite を扱う方法。

  • 1つのクラスで閉じている。
    • テーブル生成、挿入、更新、削除、検索ができる。
  • カラム名の定義(テーブル定義)は一ヶ所で済む。
  • 定義したカラム名は eclipse の補間やリファクタが効く。
  • コード量が少なく見通しが効く。
  • 将来的に複雑な SQL が使いたくなっても耐えられる。

以上の要件を満たせる用に考えたテンプレがこれ。

package sample;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class SQLiteSample extends SQLiteOpenHelper {
    static final String DB_NAME = "sample.db";
    static final int VERSION = 100;

    public interface Column {
        public String name();
        public String type();
    }

    // テーブル定義
    private static final String SAMPLE_TABLE = "SAMPLE_TABLE";
    public enum SampleCols implements Column {
        _ID("integer primary key autoincrement"),
        FIRST_NAME("text"),
        SECOND_NAME("text");

        // --- 以下、定形 (enumは継承が出来ないので) ---
        private String mType;
        private String mWhere;

        SampleCols(String type) {
            mType = type;
            mWhere = name() + "=?";
        }
        // @formatter:off
        public String type() {return mType;}
        public String where() {return mWhere;}
        public long getLong(Cursor cursor) {return cursor.getLong(cursor.getColumnIndex(name()));}
        public int getInt(Cursor cursor) {return cursor.getInt(cursor.getColumnIndex(name()));}
        public String getString(Cursor cursor) {return cursor.getString(cursor.getColumnIndex(name()));}
        public void put(ContentValues values, long val) {values.put(name(), val);}
        public void put(ContentValues values, int val) {values.put(name(), val);}
        public void put(ContentValues values, String val) {values.put(name(), val);}
        // @formatter:on
    }
    private SampleBean toBean(Cursor cursor) {
        SampleBean data = new SampleBean(
                SampleCols._ID.getLong(cursor),
                SampleCols.FIRST_NAME.getString(cursor),
                SampleCols.SECOND_NAME.getString(cursor)
                );
        return data;
    }
    private ContentValues fromBean(SampleBean bean) {
        ContentValues values = new ContentValues();
        SampleCols._ID.put(values, bean.getId());
        SampleCols.FIRST_NAME.put(values, bean.getFirstName());
        SampleCols.SECOND_NAME.put(values, bean.getSecondName());
        return values;
    }


    SQLiteSample(Context context) {
        super(context, DB_NAME, null, VERSION);
    }
    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(getCreateTableDDL(SAMPLE_TABLE, SampleCols.values()));
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DELETE TABLE " + SAMPLE_TABLE + ";");
        onCreate(db);
    }

    public SampleBean query(long id) {
        SQLiteDatabase db = getReadableDatabase();
        Cursor cursor = db.query(SAMPLE_TABLE, null, SampleCols._ID.where(), toArgument(id), null,null,null);
        if (!cursor.moveToNext()) return null;
        return toBean(cursor);
    }
    public long insert(SampleBean bean) {
        SQLiteDatabase db = getWritableDatabase();
        long id = db.insert(SAMPLE_TABLE, null, fromBean(bean));
        return id;
    }
    public long update(SampleBean bean) {
        SQLiteDatabase db = getWritableDatabase();
        long id = db.update(SAMPLE_TABLE, fromBean(bean), SampleCols._ID.where(), toArgument(bean.getId()));
        return id;
    }
    public long delete(SampleBean bean) {
        SQLiteDatabase db = getWritableDatabase();
        long id = db.delete(SAMPLE_TABLE, SampleCols._ID.where(), toArgument(bean.getId()));
        return id;
    }

    private String[] toArgument(long id) {
        return  new String[] { Long.toString(id) };
    }
    private String getCreateTableDDL(String table, Column[] columns) {
        StringBuilder sbuf = new StringBuilder();
        sbuf.append("CREATE TABLE ").append(table).append('(');
        for (Column column : columns) {
            sbuf.append(column.name()).append(' ').append(column.type()).append(',');
        }
        sbuf.setLength(sbuf.length() - 1);
        sbuf.append(");");
        return sbuf.toString();
    }
}

enum に継承機能が無いのが辛い。

ちなみに ContentProvider にしている例はこちら


2014/03/05

ViewPagerのループ

リモコンアプリを作って気がついたのだが ViewPager ってページの循環(ループ)が出来ない。

どうしても循環させたい場合は ViewPager を全部作り直す必要が有るっぽい。

このライブラリ使わせてもらえ、で話は終わってしまうのですがもう少し突っ込んでみました。

PagerAdapter だけで頑張ってみた。

ViewPager のソースを見ると子要素として持っている View は表示中のページとその左右のページの3枚のみでした。

ならば最初と最後に擬似的なページを用意してやればループしたように見せられるじゃないでしょうか。

こういうことです。

C'A B C A'
  • A と A' の View は共有します。
  • A' に遷移した時は A にジャンプします。
  • C,C' も同じです。

具体的に実装してみた物がこれです。

public class LoopPagerAdapter extends PagerAdapter {
    private static final String TAG = "LoopPagerAdapter";

    private ViewPager viewPager;
    private Page[] pages;

    public LoopPagerAdapter(ViewPager viewPager, View[] views) {
        super();
        this.viewPager = viewPager;
        this.pages = new Page[views.length+2];

        for (int i=0;i<views.length; i++) {
            pages[i+1] = new Page(views[i]);
        }
        pages[0] = new Page(views[views.length-1]);
        pages[pages.length-1] = new Page(views[0]);
        viewPager.setOnPageChangeListener(onPageChangeListener);
    }

    private OnPageChangeListener onPageChangeListener = new OnPageChangeListener() {
        @Override
        public void onPageSelected(int position) {
            if (position == 0) viewPager.setCurrentItem(getCount()-2,false);
            if (position == getCount()-1) viewPager.setCurrentItem(1,false);
        }
        @Override
        public void onPageScrollStateChanged(int state) {}
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {}
    };

    @Override
    public int getCount() {
        return pages.length;
    }

    @Override
    public Object instantiateItem(ViewGroup viewGroup, int position) {
        Log.d(TAG, "instantiateItem:" + position);
        Page page = pages[position];
        View view = page.getView();
        for (int i=0;i<pages.length; i++) {
            if (pages[i].getView() == view) pages[i].setValid(false);
        }
        page.setValid(true);

        viewGroup.removeView(view);
        viewGroup.addView(view);
        return page;
    }

    @Override
    public boolean isViewFromObject(View view, Object object) {
        Page page = (Page) object;
        return page.isValid() && page.getView() == view;
    }

    @Override
    public void destroyItem(ViewGroup viewGroup, int position, Object object) {
        Page page = (Page) object;
        Log.d(TAG, "destroyItem:" + position+" : "+((TextView)page.getView()).getText());
        if (page.isValid()) {
            viewGroup.removeView(page.getView());
        }
        page.setValid(false);
    }
}

public class Page {
    private View view;
    private boolean isValid = true;

    public Page(View view) {
        this.view = view;
    }
    public View getView() {
        return view;
    }
    public boolean isValid() {
        return isValid;
    }
    public void setValid(boolean isValid) {
        this.isValid = isValid;
    }
}

A と A' は同時に存在できないのでどちらが有効なのかを管理する為に Page クラスを用意しています。

実際に動かしてみます。

ちゃんと A と C を行き来できました。
但、必ず3ページ以上必要な上、ViewPager の実装に依存するため将来動かなくなるかもしれませんw

ViewFlipper で代用

ページング用のクラスとしてもう一つ ViewFlipper が有ります。 こちらは最初からループ可能です。
但、Fragment に対応していませんし操作が Swipe でなく Fling になります。

フリックに反応して左右にページ遷移する ViewFlipper のサンプルはこうなります。

public class FlingViewFlipper extends ViewFlipper {
    private static final String TAG = "FlingViewFlipper";
    private final Animation right_in_trans_anim = createAnim(1, 0);
    private final Animation right_out_trans_anim = createAnim(0, 1);
    private final Animation left_in_trans_anim = createAnim(-1, 0);
    private final Animation left_out_trans_anim = createAnim(0, -1);
    private GestureDetector gestureDetector;

    public FlingViewFlipper(Context context, AttributeSet attrSet) {
        super(context, attrSet);
        this.gestureDetector = new GestureDetector(context, onGestureListener);
        setFlipInterval(0);
    }

    // Require delegate from Activiy.onTouchEvent()
    public boolean onTouchEvent(MotionEvent ev) {
        gestureDetector.onTouchEvent(ev);
        return false;
    }

    private OnGestureListener onGestureListener = new SimpleOnGestureListener() {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            Log.d(TAG, "onFling:" + velocityX);
            if (velocityX < -300) {
                setOutAnimation(left_out_trans_anim);
                setInAnimation(right_in_trans_anim);
                showNext();
                return true;
            } else if (velocityX > 300) {
                setOutAnimation(right_out_trans_anim);
                setInAnimation(left_in_trans_anim);
                showPrevious();
                return true;
            }
            return false;
        }
    };

    private static Animation createAnim(float startX,float entX) {
        Animation anim = new TranslateAnimation(
            Animation.RELATIVE_TO_PARENT, startX, Animation.RELATIVE_TO_PARENT, entX,
            Animation.RELATIVE_TO_PARENT, 0, Animation.RELATIVE_TO_PARENT, 0
        );
        anim.setDuration(300);
        anim.setStartOffset(0);
        return anim;
    }
}

感想

PageViewer はちょと実装が複雑過ぎる気がしました。
PagerAdapter がどう呼ばれるのか PageViewer のソースを見ないと理解が難しいです。

どうしても Fragment でページを管理したいと言う要求がなければ普通に ViewFlipper を使うのがおすすめです。


2014/03/01

USB赤外線リモコン アプリ

Nexus7からUSB赤外線リモコンを操る(前編)「(中編)」 「(後編)」 を元に簡単なリモコンアプリを作りました。

使い方

デバイスの準備

専用のデバイスが必要です。
Nexus7からUSB赤外線リモコンを操る(前編)」 を参照してください。

起動

アプリをインストールした状態でデバイスを接続するとダイアログが表示されるので「OK」をタップするとアプリが自動的に起動します。

赤外線の登録

アプリが起動したらメニューの「登録モード on/off」を選択して登録モードにします。

登録モードはボタンの枠が水色になります。
この状態で登録したいボタンをタップします。

ダイアログが出るのでデバイスに向けてリモコンを操作してください。

正常に登録できるとダイアログが消えるので続けて他のボタンを登録します。

赤外線の送信

もう一度、メニューの「登録モード on/off」を選択して登録モードを解除し各ボタンをタップすれば登録した赤外線が送信されます。

リモコンの選択

横方向にスワイプするとリモコンを選択できます。

登録データの保存と復元

メニューの「登録データ保存」/「登録データ復元」を選択すると登録データの保存と復元が行えます。

現在のところ保存先は Android/data/org.kotemaru.android.irrc/IrData.db に固定です。
ファイル形式は Sqlite です。

リモコン画面のカスタマイズ

現状ではリモコン画面のカスタマイズは Android の開発環境が無いとできません。
SVN からプロジェクトを落として Eclipse で開いてください。

リモコン画面は HTML で記述されています。 テンプレートとなる assets/remocon/1.TV.html を同じフォルダに別名をつけてコピーしてください。 自動的に新しいリモコンとして追加されます。

HTMLに id 属性の付いた <button> を配置すればそのまま登録可能なリモコンのボタンになります。 <button> の id 属性は DB 上のキーとなるので HTMLページ内で一意でなければなりません。

<title> タグはリモコン選択時にアプリタイトルとして表示されるので適当な物を指定して下さい。

アイコンについて

ボタンのアイコンは こちら からお借りしました。 300種類くらい有るので適当な物が見つかると思います。

雑感

本当は、リピート機能やリモコン画面の登録機能も付けたかったのですが需要が不明なのでここまでとしました。 このデバイスを使っている人は基本開発者だと思うで後は好きにしてください。(^^;

Android アプリとしては WebView の JavaScript から USBデバイスの制御をしているので中々面白いものになっていると思います。


2014/02/23

Nexus7からUSB赤外線リモコンを操る(後編)

Nexus7からUSB赤外線リモコンを操る(前編)「(中編)」の続きです。 先に前/中編をみてください。

赤外線リモコンキットのプロトコル

フォーラムにもなぜかプロトコルについての資料がありません。 ファームウェアのソース公開されているので自分で調べろ(or決めろ)って事でしょうか。

仕方ないのでこちらのサイトを参考にファームのソースからプロトコルを調べました。

基本形

パケットは64バイトの固定長です。

要求パケットの1バイト目にコマンドのコードが入り、 応答パケットの1バイト目に同じコマンドのコードが入って戻ってきます。

パケットの2バイト目以降が要求パラメータまたは応答の戻り値となっています。

応答にエラーコードと言うものは無いようなので Timeout で検出と思われます。

家電のリモコンから赤外線データの受信

リモコンから赤外線データを受信するにはデバイスを受信モードにしデータを取得してから受信モードを終了します。

操作パケットデータ(64byte固定)
受信モード開始
(1)  要求->0x53,0x01,0xff…0xff
(2)  応答<-0x53,0x00,0x00…0x00
データ取得(繰り返す)
(3)  要求->0x52,0xff,0xff…0xff
(4)  応答<-0x52,0xXX,0xXX…0xXX
受信モード終了
(5)  要求->0x53,0x00,0xff…0xff
(6)  応答<-0x53,0x00,0x00…0x00

リモコンからまだ赤外線データを受け取っていない場合は (4) の応答の2バイト目が 0x00 となる為、データが取得できるまで (3),(4) を繰り返します。

デバイスから赤外線データの送信

取得したデータを送信します。

(4) で受け取ったデータの1バイト目を送信コマンド(0x61)に差し替えて要求するだけです。
投げっぱなしで応答は有りません。

操作パケットデータ(64byte固定)
データ送信
(7)  要求->0x61,0xXX,0xXX…0xXX

プロトコルの実装

家電のリモコンから赤外線データの受信

応答が非同期となるのでリスナインターフェースを用意します。 後は、パケットを作って非同期タスクに投げるだけです。

public interface IrrcResponseListener {
    public void onIrrcResponse(byte[] data);
}

public void startReceiveIR(IrrcResponseListener listener) {
    byte[] buff = initBuffer(new byte[PACKET_SIZE], (byte) 0xff);
    buff[0] = RECEIVE_IR_MODE_CMD;
    buff[1] = 1;
    new RequestAsyncTask(listener).request(buff, true, false);
}

public void endReceiveIR(IrrcResponseListener listener) {
    byte[] buff = initBuffer(new byte[PACKET_SIZE], (byte) 0xff);
    buff[0] = RECEIVE_IR_MODE_CMD;
    buff[1] = 0;
    new RequestAsyncTask(listener).request(buff, true, false);
}

public void getReveiveIRData(IrrcResponseListener listener) {
    byte[] buff = initBuffer(new byte[PACKET_SIZE], (byte) 0xff);
    buff[0] = RECEIVE_IR_DATA_CMD;
    new RequestAsyncTask(listener).request(buff, true, true);
}

デバイスから赤外線データの送信

パケットを作って非同期タスクに投げるだけです。

public void sendData(byte[] buff) {
    buff[0] = SEND_IR_CMD;
    new RequestAsyncTask(null).request(buff, false, false);
}

非同期タスク

応答有無、リトライ有無の指定にしたがってプロトコルにそった送受信を行っているだけです。
異常系やキャンセル処理への考慮は不十分です。
#APIが混乱しているのは仕様ですw

private class RequestAsyncTask extends AsyncTask<byte[], Void, byte[]> {
    private IrrcResponseListener listener;
    private boolean withResponse = false;
    private boolean withRetry = false;

    public RequestAsyncTask(IrrcResponseListener listener) {
        this.listener = listener;
    }

    public void request(byte[] buff, boolean withResponse, boolean withRetry) {
        this.withResponse = withResponse;
        this.withRetry = withRetry;
        execute(buff);
    }

    @Override
    protected byte[] doInBackground(byte[]... args) {
        Log.d(TAG, "RequestAsyncTask start");
        try {
            byte[] reqData = args[0];
            byte[] resData = null;
            boolean isRetry = false;
            do {
                doRequest(reqData);
                if (withResponse) {
                    resData = doResponse();
                    if (resData[0] != reqData[0]) {
                        Log.e(TAG, "Bad resposne code " + resData[0]);
                        return null;
                    }
                    if (withRetry && resData[1] == 0x00) {
                        sleep(500);
                        isRetry = true;
                    } else {
                        isRetry = false;
                    }
                }
            } while (isRetry);
            return resData;
        } catch (Throwable t) {
            Log.e(TAG, t.getMessage(), t);
            return null;
        }
    }

    @Override
    protected void onPostExecute(byte[] result) {
        if (listener != null) {
            listener.onIrrcResponse(result);
        }
    }
    private void doRequest(byte[] buff) throws IOException {
        …省略(中編参照)
    }
    private byte[] doResponse() throws IOException {
        …省略(中編参照)
    }
}

動かしてみる

受信と送信のボタン2つだけの Activity を作って動かしてみました。

Nexus7に繋げるのですがここで一つ問題が。
赤外線リモコンキットのコネクタは Mini-USB なので micro-USB と直結しようとするとレアなケーブルが必要で手持ちに有りませんでした。

結果こんな事に(笑)

それはそれとして、
アプリの受信ボタンをタップしてからデバイスの受光部分に向けてリモコンを操作します。
電源ボタンを押してみました。

デバイスを家電機器に向けてアプリの送信ボタンをタップすると無事、家電の電源が入って実験成功です。

 

まとめ

これで Nexus7 から赤外線リモコンキットを操作することが可能になりました。

基本的に Android から USBデバイスを操作するのは同じ流れで行けると思うのですが やはり OSが一部デバイスをアプリに使わせてくれないのは致命的な問題のような気がします。 普通のUSBデバイスはファームの書き換えなんてさせてくれませんから。

追々、リモコンアプリを作って行きたいのですが ボタン配置のカスマイズをできるようにしないといけないので以外に難しそうです。

ソース全体は以下のSVNを参照して下さい。


2014/02/22

Nexus7からUSB赤外線リモコンを操る(中編)

Nexus7からUSB赤外線リモコンを操る(前編)」の続きです。先に前編をみてください。

以前 USB 接続をやった時は PC がホストで Android がデバイスだったけれど今回は Android がホストになるのでやること多いです。

基本的なやり方はいつもの勝手に翻訳さんのサイトを参照しました。

但し、割と内容が薄く書いて無い注意事項が結構あります。

マニフェスト

マニフェストはドキュメント通りです。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.kotemaru.android.irrc" android:versionCode="1" android:versionName="1.0" >
    <uses-sdk android:minSdkVersion="12" />
    <permission android:name="android.hardware.usb.host" ></permission>
    <uses-feature android:name="android.hardware.usb.host" android:required="true" />

    <application
        android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" >
        <activity
            android:name="org.kotemaru.android.irrc.MainActivity"
            android:configChanges="orientation|screenSize"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <intent-filter>
                <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
            </intent-filter>
            <meta-data
                android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
                android:resource="@xml/device_filter" />
        </activity>
    </application>
</manifest>

USB_DEVICE_ATTACHED で Activity を起動する設定にすると 既に Activity が起動していても USB_DEVICE_ATTACHED で Activity が onCreate() から再起動されます。

LAUNCHER からも起動できるようにして有るとちょっとややこしい感じになります。

res/xml/device_filter.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <usb-device vendor-id="8938" product-id="30" />
</resources>

デバイスを特定する情報を記述します。 注意事項は値の記法が10進数なことです。 最初、16進数で書いてはまりました。

USBデバイスの構造

USBデバイスの構造は少し複雑なので整理します。
1つのUSBデバイスは複数のインターフェース(機能)を持ちます。通常は1つです。
各インターフェースは入出力の Endpoint を複数持ちます。通常は1つか2つです。

赤外線リモコンキットは4つのインタフェースを持ち以下のような構造になっていました。
但し、データ通信意外は前編のファームウェアによりダミーになっています。

  • UsbDevice : 赤外線リモコンキット
    • UsbInterface[0] : キーボード
      • UsbEndpoint[0] : IN(INTERRAPT)
      • UsbEndpoint[1] : OUT(INTERRAPT)
    • UsbInterface[1] : マウス
      • UsbEndpoint[0] : IN(INTERRAPT)
    • UsbInterface[2] : ボリュームコントローラ
      • UsbEndpoint[0] : IN(INTERRAPT)
    • UsbInterface[3] : データ通信
      • UsbEndpoint[0] : IN(INTERRAPT)
      • UsbEndpoint[1] : OUT(INTERRAPT)

UsbEndpoint のタイプは CONTROL,ISOC,BULK,INTERRAPT の4種類あります。 通常アプリが使用するのは BLUK か INTERRAPT で赤外線リモコンキットは IN/OUT ともに INTERRAPT(非同期) で通信します。

詳細は以下のサイトが詳しいです。

デバイスの認識

起動直後の処理

public static IrrcUsbDriver init(MainActivity activity, String permissionName) {
    IrrcUsbDriver driver = new IrrcUsbDriver(activity, permissionName);
    // USB_DEVICE_ATTACHEDから起動された場合は intent がデバイスを持っている。
    UsbDevice device = activity.getIntent().getParcelableExtra(UsbManager.EXTRA_DEVICE);
    if (device == null) {
        // LAUNCHER からの起動の場合は接続済デバイス一覧から検索する。
        device = findDevice(driver.usbManager, VENDER_ID, PRODUCT_ID);
    }
    /*
     * USB_DEVICE_ATTACHED で起動するように AndroidManifest.xml を記述すると
     * USB_DEVICE_ATTACHED で必ず onCreate() が呼ばれるので Activity から設定した Receiver は呼ばれない。
     * 従って、ここで onAttach() を呼ぶ。
     */
    driver.onAttach(device);
    return driver;
}

USB_DEVICE_ATTACHED から Activity が起動された場合は intent がデバイスを持っているでそのままデバイス認識の処理に入ります。

LAUNCHER から起動された場合は自前でデバイス一覧から検索します。 それでも見つからない場合の処理はアプリしだいです。

デバイス認識の処理

public String onAttach(UsbDevice device) {
    Log.d(TAG, "onAttach:" + device);
    usbDevice = device;
    if (usbDevice == null) {
        Log.e(TAG, "Not found USB Device.");
        return "Not found USB Device.";
    }
    if (usbManager.hasPermission(usbDevice)) {
        return onStart(usbDevice);
    } else {
        // デバイスの利用許可をユーザに求める。
        // 結果は UsbReceiver.onReceive()にコールバック。
        usbManager.requestPermission(usbDevice, permissionIntent);
    }
    return null;
}

アプリがUSBデバイスを使用するにはユーザの許可が要ります。 許可が無い場合はユーザに許可を求めるリクエストを投げて Receiver で受け取ります。

許可があればデバイスとの接続を開始します。

デバイス接続の処理

public String onStart(UsbDevice device) {
    Log.d(TAG, "onStart:" + device);
    if (! device.equals(usbDevice)) {
        return "No device attach.";
    }
    if (! usbManager.hasPermission(usbDevice)) {
        return "No device permission.";
    }

    usbConnection = usbManager.openDevice(usbDevice);
    // TODO:インターフェースの検出は端折ってます。
    UsbInterface usbIf = usbDevice.getInterface(INTERFACE_INDEX);

    // EndPointの検索。分かってる場合は直接取り出しても良い。
    for (int i = 0; i < usbIf.getEndpointCount(); i++) {
        UsbEndpoint ep = usbIf.getEndpoint(i);
        Log.d(TAG, "tye=" + ep.getType());
        if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_INT) {
            if (ep.getDirection() == UsbConstants.USB_DIR_IN) {
                endpointIn = ep;
            } else if (ep.getDirection() == UsbConstants.USB_DIR_OUT) {
                endpointOut = ep;
            }
        }
    }
    if (endpointIn == null || endpointOut == null) {
        Log.e(TAG, "Device has not IN/OUT Endpoint.");
        return "Device has not IN/OUT Endpoint.";
    }
    // デバイスの確保
    usbConnection.claimInterface(usbIf, true);
    isReady = true;
    return null;
}

この辺りはドキュメント通りです。
Endpoint のタイプは分かっていますがあえてチェックしています。

デバイスの終了処理

public String onDetach(UsbDevice device) {
    Log.d(TAG, "onDetach:" + device);

    if (!device.equals(usbDevice)) {
        Log.d(TAG, "onDetach: Other device.");
        return "Other device";
    }

    if (usbConnection != null) {
        UsbInterface usbIf = usbDevice.getInterface(INTERFACE_INDEX);
        usbConnection.releaseInterface(usbIf);
        usbConnection.close();
    }
    usbConnection = null;
    usbDevice = null;
    isReady = false;
    return null;
}

ここもドキュメント通りです。注意事項はありません。

レシーバ

レシーバの登録

public static UsbReceiver init(MainActivity activity, Driver driver, String permissionName) {
    UsbReceiver receiver = new UsbReceiver(activity, driver, permissionName);
    IntentFilter filter = new IntentFilter();
    filter.addAction(permissionName);  // USBデバイスの利用許可の通知を受ける。
    filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
    activity.registerReceiver(receiver, filter);
    return receiver;
}

デバイスの利用許可とDETACHEDを受け取ります。

レシーバの処理

@Override
public void onReceive(Context context, Intent intent) {
    String action = intent.getAction();
    Log.d(TAG,"onReceive:"+action);
    UsbDevice device = (UsbDevice)intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
    if (permissionName.equals(action)) {
        String errorMeg = driver.onStart(device);
        if (errorMeg != null) {
            activity.errorDialog(errorMeg);
        }
    } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) {
        if (driver.onDetach(device) == null) {
            activity.finish();
        }
    }
}

デバイス使用許可が来たらデバイスの接続開始処理を呼びます。
DETACHED で Activity を終わらせていますがアプリの仕様しだいです。

データの送受信

USB_ENDPOINT_XFER_INT(非同期) の通信は UI スレッドでは行えません。 必ず、AsyncTask か Thread で行います。

非同期パケット送信

    private void doRequest(byte[] buff) throws IOException {
        Log.d(TAG, "request:" + dump(buff));

        ByteBuffer buffer = ByteBuffer.allocate(buff.length);
        UsbRequest request = new UsbRequest();
        buffer.put(buff);

        request.initialize(usbConnection, endpointOut);
        request.queue(buffer, buff.length);

        UsbRequest finishReq;
        while ((finishReq = usbConnection.requestWait()) != request) {
            if (finishReq == null) throw new IOException("Request failed.");
            sleep(100);
        }
    }

非同期パケット送信の方法はこれでほぼ定形だと思います。

requestWait() の戻り値は同時に走っている他の UsbRequest の場合もあるのでループでチェックします。 null は何らかのエラーが有ったとき返るようです。

非同期パケット受信

    private byte[] doResponse() throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(endpointIn.getMaxPacketSize());
        buffer.clear();
        UsbRequest request = new UsbRequest();
        request.initialize(usbConnection, endpointIn);
        request.queue(buffer, endpointIn.getMaxPacketSize());

        UsbRequest finishReq;
        while ((finishReq = usbConnection.requestWait()) != request) {
            if (finishReq == null) throw new IOException("Request failed.");
            sleep(100);
        }

        // Note: OSバージョンにより flip() の必要性が異なる気がする...
        if (buffer.remaining() == 0) buffer.flip();

        byte[] buff = new byte[buffer.remaining()];
        buffer.get(buff);
        Log.d(TAG, "response:" + dump(buff));
        return buff;
    }

非同期パケット受信もこれでほぼ定形だと思います。

謎なのは エミュレータ(4.0.4) では buffer.flip() は必要無かったのですが Nexus7(4.4.2) では必要になりました。 赤外線リモコンキットはパケットが固定長なのでこのコードで どちらも動作しますがデバイスによっては OSバージョンのチェックが必要かもしれません。

感想

通信できるようになるまで作法と言うか手順が多いですね。
通信自体も非同期だと一手間かかります。
Android の USBホスト実装は基本的にこの形に乗りそうです。

ソース全体は以下のSVNを参照して下さい。

いよいよリモコンを操ります。
Nexus7からUSB赤外線リモコンを操る(後編)」に続きます。


プロフィール
20年勤めた会社がリーマンショックで消滅、紆余曲折を経て現在はフリーランスのSE。 失業をきっかけにこのブログを始める。

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿【java】

リンク

アーカイブ