Android:WiFi Beamをリリースしました。

Androidアプリ『WiFi Beam』をリリースしました。


※WiFi Beamは、Android 4.0(ICS)以上でしか使えないアプリです。しかもNFC必須。国内で販売している端末だと、使えるのはGalaxyNexusだけです。Nexus SをICS化している場合は使うことができますが、複数端末要ります。友達と使うべきアプリです。

勉強会に行くと、

「今日の会場の無線LANはこれになるので登録お願いしまーす」(ホワイトボードに書いてある)

と言われるけど、入力が面倒臭いから自分の持ってるモバイルルータで接続して云々ということもよくありますね。でもなんだかんだで通信費は抑えたかったり、速いネットワークが使いたいから、設定しちゃえばいいんですが、なんせ入力が面倒だと。みんなが同じ情報を一斉に入力するのが無駄じゃない?と。

なので、会場にGalaxyNexusが1台あれば、それがWiFi Beamでネットワーク登録を行なっておけば、端末を合わせてAndroid BeamでポンッとWiFi接続情報を渡してそのままWiFiに接続。さらに貰ったWiFi情報をその人のWiFi Beamにも登録されてるので、また近くの人にポンッと渡せる。というアプリ。

こういう入力の手間を省くためのアプリとして、Android Beamを使うのはいいんじゃないかなーと思っていたので、早めにこういうアプリが作れてよかったかなと思います。

時間があれば、動画でデモでも取れればなぁと思います。

あー、あと、WiFi Beamはカンパウェアにしています。
アプリ自体は無料ですが、よかったらカンパしてほしいなぁと。
カンパのコースは、

  • 缶コーヒーを奢る
  • ビッグマックを奢る
  • ビッグマックセットを奢る

にしています。
なんか金額を書くよりも身近な感じがしていいかなぁと思ってこうしてみました。
よかったらビッグマックセット奢ってください!!(^O^)


Android:Android Beamの設定画面が呼び出せない(GN)

Nexus SもICSになり、GalaxyNexusも買ったので、折角だからAndroid Beamアプリを作っちゃうもんねー!ということで今作っている最中なのですが、思わぬトラップに引っ掛かっています。

まずAndroid Beam DemoをGalaxyNexusにインストールして、それで挙動を確認しようと思って構っていたところ、ActionBarにある設定ボタンを押したら、なんと強制終了してしまいました。
docomo版のGalaxyNexusだけの問題かと思ったのですが、海外版のGalaxyNexusを持っている方に協力していただいて確認してもらったところ、やはり同じ模様…。ActivityNotFoundExceptionが出ていました。

試しに4.0.3にアプデしたNexus Sにインストールしてから設定ボタンを押したら、Android Beamの設定画面を呼び出していました。これでようやくまともな動作がわかったわけですが、なんでGalaxyNexusでエラーになるのかがわかりません…。サンプルコードがやっているのは凄くシンプルなことです。

Intent intent = new Intent(Settings.ACTION_NFCSHARING_SETTINGS);
startActivity(intent);

これだけ。Settings.ACTION_NFCSHARING_SETTINGSで暗黙的Intentを投げてるだけですね。でもGalaxyNexusだとこのIntent-Filterに引っ掛かる処理がないです。なんでやねーん!

次に、Nexus Sは上の暗黙的Intentが引っ掛かるので、PackageManagerを使って明示的に呼び出しているパッケージ名、クラス名を見て、それを参考に明示的Intentを投げて解決してやろうと思ってやってみました。

Intent intent = new Intent();
intent.setClassName("com.android.settings",
    "com.android.settings.Settings$AndroidBeamSettingsActivity");
startActivity(intent);

ところがこれでもGalaxyNexusではダメでした。Nexus SではOK。
原因は、ClassNotFoundExceptionです。
com.android.settings.nfc.NdefPushがないと…。

ないわけはないような…と思いながらも、そういうエラーが出ています。

今の所は解決方法がわかってないので、PackageManagerから暗黙的Intentが引っ掛かる場合は、暗黙的Intentで呼び出し、そうでない場合はAndroid Beamの設定画面よりもっと前の設定画面を呼び出そうかなと思います(妥協案で)。

PackageManager pm = getPackageManager();
Intent intent = new Intent(Settings.ACTION_NFCSHARING_SETTINGS);
List<ResolveInfo> apps = pm.queryIntentActivities(intent, 0);
if (apps.size() == 0) {
    intent = new Intent(Settings.ACTION_SETTINGS);
}
startActivity(intent);

デモアプリでエラーが発生しちゃいかんだろ!GalaxyNexus!!


Android:非同期にダウンロードした画像の表示方法

※この記事はAndroid Advent Calendarの19日のエントリーです。

さて、勢いでAndroid Advent Calendarに参加することにしたら、皆がガチの技術情報ばかりなので、私も技術情報にしようと思いました(ネタが滑りそうで怖くなったため)。

今回の記事は真新しい情報でもなく、非同期にDLした画像の表示方法です。
なぜこれを書くつもりになったかと言いますと、かなり前の記事ですが、Android:Adapter.getViewでAsyncTaskは危険というのを書いてまして、タブレットでAdapter.getViewメソッドで画像をAsyncTaskを使ってダウンロードさせるとAsyncTaskの呼び過ぎでアプリが落ちてました。それの自己解決方法の記事を書いてなかったので、折角だから書いておこうかなと思った次第です。ただし、あくまでも私流です。これが正解ということはありません。いい方法があったら教えていただきたいくらいです。

実装した内容は、以下の通り。

  1. ListView系でダウンロードする画像のURLを事前に全部抽出する
  2. List3つにダウンロードする画像を割り振る
  3. 画像ダウンロード専用のAsyncTaskを3つ起動させて、それぞれDLさせる
  4. ダウンロードした画像はファイルとして保存。SQLiteにURLと画像のファイルパスと有効期限を保存しておく
  5. ImageViewクラスを継承したRemoteImageViewクラスを作成
  6. RemoteImageViewクラスにハンドラを実装し、対象のURLの画像があるかSQLiteに問い合わせる
  7. 画像があったら表示。なかったら1秒後にリトライ
  8. 10回ループして画像がダウンロードできてなかったら、Not found画像を表示
  9. アプリ起動時に画像の有効期限を確認して、古いものは削除する

まず、画像情報を管理するデータベース用のクラスImageCacheDBを作成します。
AsyncTaskで画像をダウンロードし終わったタイミングでデータを登録します。

public class ImageCacheDB extends SQLiteOpenHelper {

    private static final String DB_FILE = "imagecache.db";
    public static final String TBL_CACHE = "ImageCache";
    private static final int DB_VERSION = 1;
    
    public interface CacheColumn {
        public static final String ID = "_id";
        public static final String NAME = "fileName";
        public static final String REGIST_DATE = "registDate";
        public static final String URL = "url";
        public static final String TYPE = "type";
    }
    
    private SQLiteDatabase mDB;
    
    public static ImageCacheDB instance;
    
    private ImageCacheDB(Context context) {
        super(context, DB_FILE, null, DB_VERSION);
    }
    
    public static ImageCacheDB getInstance(Context context) {
        if (instance == null) {
            instance = new ImageCacheDB(context);
        }
        return instance;
    }
    
    synchronized private SQLiteDatabase getDB() {
        if (mDB == null) {
            mDB = getWritableDatabase();
        }
        return mDB;
    }
    
    public void close() {
        if (mDB != null) {
            getDB().close();
        }
        super.close();
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("CREATE TABLE IF NOT EXISTS " + TBL_CACHE + " ("
                + CacheColumn.ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
                + CacheColumn.REGIST_DATE + " INTEGER,"
                + CacheColumn.URL + " VARCHAR(300),"
                + CacheColumn.TYPE + " VARCHAR(100),"
                + CacheColumn.NAME + " VARCHAR(20))"
        );
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

    }
    
    public long insert(String url) {
        ContentValues values = new ContentValues();
        values.put(CacheColumn.REGIST_DATE, new Date().getTime());
        values.put(CacheColumn.URL, url);
        long id = getDB().insertOrThrow(TBL_CACHE, null, values);
        return id;
    }

    public int update(long id, String filename, String type) {
        ContentValues values = new ContentValues();
        values.put(CacheColumn.TYPE, type);
        values.put(CacheColumn.NAME, filename);
        return getDB().update(TBL_CACHE, values, CacheColumn.ID + " = ?", new String[]{String.valueOf(id)});
    }
    
    public int delete(long id) {
        return getDB().delete(TBL_CACHE, CacheColumn.ID + " = ?", new String[]{String.valueOf(id)});
    }
    
    public Cursor exists(String url) {
        return getDB().query(TBL_CACHE, null, CacheColumn.URL + " = ?", new String[]{url}, null, null, null);
    }
    public Cursor existsFile(String url) {
        return getDB().query(TBL_CACHE, null, CacheColumn.URL + " = ? AND " + CacheColumn.NAME + " IS NOT NULL", new String[]{url}, null, null, null);
    }
    
    public Cursor findOlderCache() {
        return getDB().query(TBL_CACHE,
                null,
                CacheColumn.REGIST_DATE + " < ?",
                new String[]{String.valueOf(new Date().getTime() - 604800)}, // 7*24*60*60
                null, null, null);
    }
    
    public Cursor findAll() {
        return getDB().query(TBL_CACHE,
                null,
                null,
                null,
                null, null, null);
    }
    
    @Override
    protected void finalize() throws Throwable {
        if (mDB != null) {
            mDB.close();
        }
        this.close();
        super.finalize();
    }
}

RemoteImageViewクラス(抽象クラス)は以下の通り。
抽象クラスにしているのは、アプリによってNow loadingやNot foundの画像を変えることができるようにするためです。

abstract public class RemoteImageView extends ImageView {
    private static final String TAG = "RemoteImageView";
    
    public static final int IMG_DOWNLOADING = 1;
    
    public final Handler mHandler = new Handler(){

        @Override
        public void handleMessage(Message msg) {
            // 1秒毎にキャッシュからヒットするか検索する
            // ヒットしたら、検索をやめる
            // 画像表示に10回挑戦してダメだったらフラグを立てるとかしないとマズい
            if (msg.what == IMG_DOWNLOADING) {
                Context cxt = getContext();
                final String url = (String)msg.obj;
                int count = msg.arg1;
                LogUtils.d(TAG, url);
                ImageCacheDB db = ImageCacheDB.getInstance(cxt);
                if (url != null && !url.equals("")) {
                    final Cursor c = db.existsFile(url);
                    if (c.moveToFirst()) {
                        final String filename = c.getString(c.getColumnIndex(CacheColumn.NAME));
                        final String type = c.getString(c.getColumnIndex(CacheColumn.TYPE));
                        if (type.equals("image/jpg")
                                || type.equals("image/jpeg")
                                || type.equals("image/png")
                                || type.equals("image/gif")) {
                            Drawable drawable = Drawable.createFromPath(cxt.getFileStreamPath(filename).getAbsolutePath());
                            setImageDrawable(drawable);
                            setVisibility(RemoteImageView.VISIBLE);
                        } else {
                            // 表示できる類いではない
                            setImageNotFound();
                        }
                    } else {
                        if (count <= 10) {
                            setImageNowLoading();
                            msg = obtainMessage(IMG_DOWNLOADING, ++count, 0, url);
                            long current = SystemClock.uptimeMillis();
                            long nextTime = current + 1000;
                            sendMessageAtTime(msg, nextTime);
                        } else {
                            // チャレンジ10回して失敗したので失敗扱い
                            setImageNotFound();
                        }
                    }
                    c.close();
                } else {
                    setImageNotFound();
                }
            }
        }
    };

    public RemoteImageView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    public RemoteImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public RemoteImageView(Context context) {
        super(context);
    }

    abstract public void setImageNotFound();
    
    abstract public void setImageNowLoading();
}

あと、画像のURLのArrayListを渡したらマルチスレッドで画像をダウンロードしてくれるMultiThreadImageDownloaderクラス。単純に画像をダウンロードするAsyncTask(ImageDownloadCacheTaskクラス)を3つ起動しているだけです。パフォーマンス次第では、ダウンロード用のAsyncTaskの数を増減させてもいいかなと思いますが、まぁ3つもあればいいかなと思います。

public class MultiTheadImageDownloader {

    public static void execute(Context context, ArrayList<String> urls) {
        ArrayList<String> list1 = new ArrayList<String>();
        ArrayList<String> list2 = new ArrayList<String>();
        ArrayList<String> list3 = new ArrayList<String>();
        int i = 0;
        for (String url : urls) {
            switch (i % 3) {
                case 0: list1.add(url); break;
                case 1: list2.add(url); break;
                case 2: list3.add(url); break;
            }
            i++;
        }
        new ImageDownloadCacheTask(context).execute((String[])list1.toArray(new String[0]));
        new ImageDownloadCacheTask(context).execute((String[])list2.toArray(new String[0]));
        new ImageDownloadCacheTask(context).execute((String[])list3.toArray(new String[0]));
    }
}

で、実際に画像をダウンロードするImageDownloadCacheTaskクラスです。配列で画像のURLをもらうので、それを全てダウンロードするようにループを回します。対象URLの画像が既にダウンロード済みなら、無視。まだダウンロードしてなかったら、ダウンロードします。

public class ImageDownloadCacheTask extends AsyncTask<String, Void, Void> {
    
    private static final String TAG = "ImageDownloadCacheTask";

    private Context mContext;
    
    public ImageDownloadCacheTask(Context context) {
        mContext = context;
    }
    
    @Override
    protected Void doInBackground(String... urls) {
        ImageCacheDB db = ImageCacheDB.getInstance(mContext);
        long id = 0;
        HttpClient httpClient = null;
        for (String url : urls) {
            Cursor c = db.exists(url);
            if (!c.moveToFirst()) {
                try {
                    id = db.insert(url);
                    HttpGet httpRequest = new HttpGet(url);
                    httpClient = new DefaultHttpClient();
                    HttpResponse response;
                    response = (HttpResponse) httpClient.execute(httpRequest);
                    HttpEntity entity = response.getEntity();
                    BufferedHttpEntity bufHttpEntity = new BufferedHttpEntity(entity);
                    
                    // ファイルに保存
                    String filename = String.format("%06d", id);
                    String type = entity.getContentType().getValue();
                    FileOutputStream stream = mContext.openFileOutput(filename, Context.MODE_PRIVATE);
                    InputStream is = bufHttpEntity.getContent();
                    byte[] data = new byte[4096];
                    int size;
                    while((size = is.read(data)) > 0) {
                        stream.write(data, 0, size);
                    }
                    stream.close();
                    is.close();
                    // キャッシュディレクトリに画像を保存する
                    db.update(id, filename, type);
                } catch (Exception e) {
                    Log.e(TAG, e.getClass().getSimpleName(), e);
                    if (id > 0) {
                        try {
                            db.delete(id);
                        } catch (Exception e2) {
                            Log.e(TAG, e2.getClass().getSimpleName(), e2);
                        }
                    }
                } finally {
                    // クライアントを終了させる
                    if (httpClient != null) {
                        httpClient.getConnectionManager().shutdown();
                    }
                }
            }
            c.close();
        }
        return null;
    }
}

上記で準備はできたので、実際に使ってみましょう。
実はサンプルプロジェクトを作成してgithubに公開しました。

github: RemoteImageViewSample

上のほうで説明したRemoteImageViewを使ってGridViewで画像を表示しています。
取得元ははてなフォトライフの、「猫」のタグが付いてる画像のRSSです。
Androidクラスタは猫が好きな人が多いみたいなので、これにしました。
アプリを起動しますと、RSSの解析中にプログレスダイアログを表示し、画像のDL中はDL中の画像、DLし終わると猫の画像が出てきます。下のような画面です。

RSSの解析は、ライブラリのRomeを使いました。
これはAndroid用にカスタムされたのがjarで公開されてます。

http://code.google.com/p/android-rome-feed-reader/

では、これの説明です。

public class RemoteImageViewSampleActivity extends Activity {

    private static final String TAG = "RemoteImageViewSampleActivity";

    private GridView mGrid;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        mGrid = (GridView) findViewById(R.id.grid);
        // はてなフォトライフから猫のタグのついたRSSを取得、解析する
        new HatenaPhotoLifeRssReaderTask(this).execute();
    }

    /**
     * はてなフォトライフの猫のタグのついたRSSを解析する
     * 
     * @author Toyoaki Oko <chariderpato@gmail.com>
     */
    private class HatenaPhotoLifeRssReaderTask extends AsyncTask<Void, Void, ArrayList<String>> {

        protected Context mContext;
        protected ProgressDialog mProgress;

        public HatenaPhotoLifeRssReaderTask(Context context) {
            this.mContext = context;
        }
        
        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            mProgress = new ProgressDialog(mContext);
            // 戻るボタンが押された場合の処理
            mProgress.setOnCancelListener(new OnCancelListener() {
                
                public void onCancel(DialogInterface dialog) {
                    HatenaPhotoLifeRssReaderTask.this.cancel(true);
                }
            });
            mProgress.setMessage(mContext.getString(R.string.now_loading));
            mProgress.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            mProgress.show();
        }
        
        @Override
        protected ArrayList<String> doInBackground(Void... params) {
            ArrayList<String> imageUrls = new ArrayList<String>();
            final String feedUrl = "http://f.hatena.ne.jp/t/%E7%8C%AB?mode=rss";
            RssAtomFeedRetriever feedRetriever = new RssAtomFeedRetriever();
            SyndFeed feed = feedRetriever.getMostRecentNews(feedUrl);
            List<SyndEntry> entries = feed.getEntries();
            
            // RSSより、サムネイル用画像URLを抽出する
            for (SyndEntry entry : entries) {
                ArrayList list = (ArrayList) entry.getForeignMarkup();
                for (int i = 0; i < list.size(); i++) {
                    Element elm = (Element) list.get(i);
                    if (elm.getName().equals("imageurlmedium")) {
                        imageUrls.add(elm.getValue());
                        continue;
                    }
                }
            }
            return imageUrls;
        }

        @Override
        protected void onPostExecute(ArrayList<String> imageUrls) {
            mProgress.dismiss();
            if (isCancelled()) {
                return;
            }
            if (imageUrls != null) {
                HatenaPhotoLifeAdapter adapter = new HatenaPhotoLifeAdapter(imageUrls);
                mGrid.setAdapter(adapter);
                // 画像のダウンロードを実行する
                MultiTheadImageDownloader.execute(RemoteImageViewSampleActivity.this, imageUrls);
            }
            super.onPostExecute(imageUrls);
        }
    }

    /**
     * GridViewに渡すAdapterクラス
     * 
     * @author Toyoaki Oko <chariderpato@gmail.com>
     */
    private class HatenaPhotoLifeAdapter extends BaseAdapter {
        private ArrayList<String> mImageUrls;
        private Resources mRes;
        public int getCount() {
            return this.mImageUrls.size();
        }

        public String getItem(int position) {
            return this.mImageUrls.get(position);
        }

        public long getItemId(int position) {
            return position;
        }

        public HatenaPhotoLifeAdapter(ArrayList<String> imageUrls) {
            this.mImageUrls = imageUrls;
            this.mRes = getResources();
        }

        public View getView(int position, View convertView, ViewGroup parent) {
            ViewHolder holder;
            if (convertView == null) {
                convertView = new HatenaImageView(RemoteImageViewSampleActivity.this);
                holder = new ViewHolder();
                holder.thumbnail = (HatenaImageView) convertView;
                int thumbnailSize = mRes.getDimensionPixelSize(R.dimen.thumbnail_width);
                holder.thumbnail.setLayoutParams(new GridView.LayoutParams(thumbnailSize, thumbnailSize));
                holder.thumbnail.setScaleType(ScaleType.FIT_CENTER);
                convertView.setTag(holder);
            } else {
                holder = (ViewHolder) convertView.getTag();
            }
            // 画像のURL
            final String imageUrlMedium = this.getItem(position);
            // RemoteImageViewに画像読み込み依頼を投げる
            Message msg = holder.thumbnail.mHandler.obtainMessage(RemoteImageView.IMG_DOWNLOADING,
                    1, 0, imageUrlMedium);
            msg.sendToTarget();
            return convertView;
        }
    }

    private class ViewHolder {
        HatenaImageView thumbnail;
    }

    private class HatenaImageView extends RemoteImageView {

        public HatenaImageView(Context context) {
            super(context);
        }

        /**
         * 画像が見つからなかった場合に表示する画像
         */
        @Override
        public void setImageNotFound() {
            setImageResource(R.drawable.not_found);
        }

        /**
         * 画像のダウンロード中に表示する画像
         */
        @Override
        public void setImageNowLoading() {
            setImageResource(R.drawable.now_loading);
        }
    }
}

160行くらいあるので、読むのが億劫になりそうですが、やっていることはシンプルです。

  1. onCreateで、GridViewをメンバ変数に入れ、その後、はてなフォトライフのRSSをHatenaPhotoLifeRssReaderTaskを使って非同期で解析
  2. HatenaPhotoLifeRssReaderTask.doInBackgorundでRomeを使ってサムネイルのURLを抽出してArrayListをonPostExecuteへ
  3. HatenaPhotoLifeRssReaderTask.onPostExecuteでHatenaPhotoLifeAdapterを生成。これがGridView用のアダプタ。画像のダウンロード自体はMultiTheadImageDownloaderに行なわせる
  4. HatenaPhotoLifeAdapter.getView内で、HatenaImageViewを作って画像のリクエストをハンドラに依頼
  5. HatenaImageViewに、画像をDL中の画像を表示させるメソッドsetImageNowLoadingや画像がない場合のメソッドsetImageNotFoundを定義しておく

キャッシュの削除処理とかは省略してます。
上記の処理を行ないますと、GridViewに猫の画像が表示されます。

いかがでしたでしょうか?1度起動した後に、再起動してみるとわかりますが、今度は直ぐに画像が表示されるはずです(RSSの解析は長いけど)。
ただ、この実装方法は、画像のキャッシュがSDカードではなく本体のメモリに保存してます。小さい画像とはいえ、数が多いと割とすぐに容量が多くなります。SDカードのキャッシュディレクトリに保存するように変更したほうがいいだろうなぁと思いつつ、今のところこのままです(^_^;)

参考になりましたら幸いです!


日本Androidの会 中国支部 第25回勉強会で講師してきた

題名の通りですが、2011.12.10(土)に、日本Androidの会 中国支部(#CJAG)の勉強会で講師をしてきました。

他の支部では、LTはしたことあるのですが、講師はしたことありませんでした。(#JAG4 ではちょくちょくやってたけど)

題名は、「アプリ内課金してみた」です。
スライドをSlideShareにアップしておいたので、よかったら見てみてください。

とぅぎゃったーに様子をまとめましたので、こちらも参考にどうぞ。

日本Androidの会 中国支部 第25回勉強会

翌日の今日は、広島ということもあり、友達と牡蠣の食べ放題に行ってきました!
去年も行きましたが、今回もとても楽しかったですねぇ〜!!ちょっとトラブルもあったりしましたが(^_^;)
年に1度くらい、こういうことができたらいいなぁと思います。


Android:WiFiCutterがアンドロイダーで紹介されました

11月上旬にリリースしたAndroidアプリ「WiFiCutter」が、Androidアプリレビューサイトのアンドロイダーで紹介されました!!

アンドロイダーのWiFiCutterの紹介ページ

今までtwitterやFacebookでアプリのリリース情報や、DL状況などを報告しながらマーケティングしていました。アプリの機能は非常にシンプルながら、何気に便利なアプリなので、2週間で700DLを超えるなど、比較的いい結果だなぁと思っていましたが、アンドロイダーで紹介された途端に、1日で500DLも増えました。さすがです。さすがアンドロイダー様です。

WiFiCutterは、WiFiの電波が弱くなったら自動でWiFiを切って終了させるアプリです。いつまでも弱いWiFi APに接続し続けて、全然ネットができなかったりするのが煩わしいときがあるので、それならさっさと切断して3Gで通信したいなぁという意見をtwitterのTLで見かけて、作りました。WiFiをOnにしたら自動起動・Offにしたら自動終了するようにも作っているので、ステータスバーには必要なときしかいません。

WiFiCutterはアプリ内課金を使ってシェアウェアにしています(1週間は機能無制限で使える。それ以降は気に入ったら150円で使用権を買う方式)。2週間経ってもあまり買われてなかったのですが(700DLされても15人が購入くらい)、アンドロイダーに掲載されたところ、2日で同じくらいの方々に購入していただけました!(まだアプリお試し期間があるのにありがたい!)

紹介されると嬉しいですし、アプリを買っていただけるとさらに嬉しいですね(^-^)これからも便利なアプリを作っていきたいなぁと思います。

Android Market: WiFiCutterはこちら