2015年2月16日月曜日

IngressのGlyph Hackをハックする


IngressのGlyph Hackをハック中。

フレームバッファのバイナリをBitmapに変換して必要なとこだけキャプチャするのは完了。
こうして人の脳は退化していくわけですね。

ついでに自前の検出器を作成して、それがどの図柄なのかを検出するのも完了。
画面上の表示位置が変わらないし図柄がすごく単純なので、OpenCVなんて使う必要は全くありませんでした。

そしてあとは自動で絵を描くだけだ~と思ったら、後一歩のところで壁にぶつかりました。

そもそも、画面キャプチャ、検出器作成まで出来たら、あとはイージーだと思って何も調べてなかったのですが、結構厄介な問題がありました。

よそ様のアプリを操作するためにタッチイベントを生成するには、android.permission.INJECT_EVENTSが必要なのですが、こいつがいわゆるシステム権限らしくうまく動きません。

AndroidManifest.xmlで宣言しても、SecurityExceptionが発生します。


システム権限ということであれば、/system/app配下に放り込めば良いのかと考え、以下のようにしてADB Shellから無理やり放り込んでシステムアプリ化してみました。

$ adb shell
$ su
# mount -o rw,remount /system
# cp /sdcard/AutoGlyph.apk /system/app
# chmod 644 /system/app/AutoGlyph.apk
# reboot

が、やはり動きません。


INJECT_EVENTSprotectionLevelsignatureで、公式の説明にはthe application that declared the permissionと同じ証明書で署名されていないとダメだとあります。
googleの署名じゃないとダメってことでしょうか?

もしかするとprotectionLevelsignatureOrSystemであれば、システムアプリ化するだけでいけたのかもしれませんが。。

これはお蔵入りの予感。。


ただ、LMT Launcherとかは普通に動いてるわけで、何かやり方はあるんでしょう。
セキュリティ的には出来ちゃダメな気もしますが。

システムアプリを偽装できるのか、Input Subsystemあたりを利用するのか。
LMT Launcherのpackagenamecom.android.lmtなのが気になりますが、それだけではダメな気がします。

う~ん。。


Read More...

2015年2月15日日曜日

Androidアプリからスクリーンショット /system/bin/screencap


前回に続きスクリーンショットについてです。

/system/bin/screencap -p test.png

前回は上記のように-pオプションを付けて実行し、ファイルの拡張子も.pngにしていたので、pngファイルで保存されていました。

普通の用途ではそれでいいんだと思いますが、今回は訳あって1200ミリ秒ぐらいの中で全部やりたいので、時間的にギリギリです。

そこで、pngへの変換を省いて生のBitmap画像を得る方法について実験します。


/system/bin/screencapコマンドについては、pオプションやファイル名を指定せずに実行すると、いわゆるフレームバッファの生データが標準出力に出力されます。

データのはじめにはwidth、height、formatの各4byteで12byteのヘッダが付いてくるそうです。

とりあえずヘッダの中身を見てみます。

 2684682240
 655360
 16777216

うーん、まったく意味が分からない。。
実際のwidthやheightで割ってみても意味のありそうな数字になりません。

LITTLE_ENDIANで読み直してみます。

 1440
 2560
 1

これだ!

そしてその後には14,745,600byteのデータが飛んできました。

Nexus6で実験しているので、画面サイズは縦×横が2,560×1,440です。

2560 * 1440 = 3686400

14745600 / 3686400 = 4

1ピクセルあたり4byteということは、普通に考えれば32bitということでしょうか。
ただ、ネットで調べると16bitのダブルバッファリングだという説があったので、適当に16bitのヘッダを付加して表示してみます。

はい。ぜんぜんダメでした。

素直に32bitのヘッダを付加してみます。

上下が逆転しています。

Bitmapのデータはなぜか左下から右方向に向かって格納されているそうなのですが、フレームバッファには普通に並んでいるので、byte配列を逆さにしてからBitmapに食わせます。

色情報の順番も変わってしまいました。また、左右は入れ替えなくて良いようです。

行単位で逆にします。

まだちょっと色がおかしい。
どうやらRとBが逆になっているようですね。

このサンプルだと分かりにくいのでもう少しカラフルなサンプルでRとBを入れ替えると。

完成。


正体の良く分からないバイナリをいい感じに加工して読めるデータにする作業って、何か高度なパズルを解いているようで楽しいです。

まぁちゃんと調べれば正体は分かるんでしょうけど。。


Read More...

2015年2月8日日曜日

Androidアプリからスクリーンショット /system/bin/screencap -p


Androidアプリからスクリーンショットを取るには、JNIとかでフレームバッファを直接見に行くしかないのかと思っていたのですが、Android4.0から/system/bin/screencapでスクリーンショットが可能になったとのことです。

root化は必要なものの、ずいぶん楽になったようですね。

try {
    final String SAVE_DIR = "/test/";
    String fileName = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()) + ".png";
    String fullpath = Environment.getExternalStorageDirectory().getPath() + SAVE_DIR + fileName;
    Process p = Runtime.getRuntime().exec("su");
    OutputStream os = p.getOutputStream();
    os.write(("/system/bin/screencap -p " + fullpath).getBytes("ASCII"));
    os.flush();
    os.close();
    p.waitFor();
    p.destroy();
} catch (IOException e) {
    e.printStackTrace();
} catch (InterruptedException e) {
    e.printStackTrace();
}

これ終わったらちゃんとdestroy()で開放してあげないと、色々と後で死にそうですね。

Read More...

2014年11月29日土曜日

AndroidアプリのIn-app Billing version2からversion3への更新 ③テスト


In-app Billing version3の最終回です。
今回は作ったものがまともに動くかテストする方法を確認します。

テストには二通りの方法があり、ある程度の動きはサンドボックステストで確認し、最終的に実購入テストという流れかと思います。


  • サンドボックステスト
サンドボックステストのために、以下4つの予約済みアイテムIDが用意されています。

購入成功(android.test.purchased)
キャンセル、クレジットカード無効など(android.test.canceled)
払い戻し(android.test.refunded)
存在しないアイテム(android.test.item_unavailable)

android.test.canceledandroid.test.refundedの場合は、RESPONSE_CODEBILLING_RESPONSE_RESULT_USER_CANCELEDあたりが返ってくるのかと思ったのですが、BILLING_RESPONSE_RESULT_OKが返ってきます。

ただし、INAPP_PURCHASE_DATAINAPP_DATA_SIGNATUREはnullです。

android.test.item_unavailableの場合は、onActivityResultに渡されてくるresultCodeRESULT_CANCELEDが返ってきます。

こっちのActivityのRESULT_CANCELEDは0で、RESULT_OKが-1です。In-app Billingで返ってくるレスポンスコードはBILLING_RESPONSE_RESULT_OKが0なのでややこしいです。


ちなみにandroid.test.purchasedで一度購入成功すると、二度と購入出来なくなるのですが、「設定→アプリ→Google Play→データを削除」でデータを削除すると、再度購入出来るようになります。

  • 実購入テスト
サンドボックステストに飽きたら実購入テストに入ります。
実購入テストでは、Google Playと実際に通信して購入処理をテストします。


アルファ版配布用に、google グループで適当にグループを作成し、テスト用のgmailアドレスを招待しておきます。

テスト購入で課金が発生したりしないように、Developer Consoleにログインし、テストアカウントを設定します。
「設定→アカウントの詳細→ライセンス テスト→テスト用のアクセス権がある Gmail アカウント」にテスト用のgmailアドレスを入力します。

テストしたいapkをリリース用のKeyStoreで署名して、アルファ版テストにアップロードします。

「テスターのリストを管理→アプリのアルファ テスト版を提供するユーザー」という画面で先ほど作成したグループを登録すると、オプトインのためのアドレスが表示されます。

テスターが「テスターになる」をタップすると設定は完了で、あとは実際にGoogle Playから実機にインストールして試すだけです。

ただ、アルファ版が有効になるまで結構時間がかかる(半日とか1日とか)ようなので、作ってはアップロード作ってはアップロードという試し方は出来なそうな雰囲気です。

とは言え、やはり課金関連で失敗するとひどいことになるので、十分なテストの上で公開したいところです。

時間を置くと、実際にGoogle Playからダウンロード出来るようになります。

ダウンロードしたアルファ版のアプリで購入ボタンを押すと、しっかりとテスト注文である旨が表示されます。
(アプリ名やカード番号は消しました)

アルファテストで購入完了すると、Googleウォレット販売者コンソールでは以下のように「追加済み」と表示されます。

ということで、苦手意識のあったIn-app Billingですが、version3になって本当にすっきり分かりやすくなりました。

食わず嫌いだった人はこれを機に、是非再チャレンジしてみると良いと思います。


Read More...

AndroidアプリのIn-app Billing version2からversion3への更新 ②レスポンスを受け取る


前回はIn-app Billing version3のリクエスト送信方法について触りましたので、今回はそのレスポンス処理方法についてです。

  • レスポンスを受け取る
startIntentSenderForResultのレスポンスはActivityのonActivityResultで受け取ります。

startIntentSenderForResultの第2引数で渡したリクエストコードとの一致を確認した上で、resultCoderesponseCodeが問題ないか確認し、レスポンスの中身を取り出します。

purchaseDataはJSON形式になっていて、その中のproductIdを確認すると、どの商品が購入されたかが分かります。

protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQUEST_CODE) {
        int responseCode = data.getIntExtra("RESPONSE_CODE", -1);
        if (resultCode == RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) {
            String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
            if(purchaseData != null) {
                String productId = "";
                try {
                    JSONObject jsonObj = new JSONObject(purchaseData);
                    productId = jsonObj.optString("productId");
                } catch (JSONException e) {
                    e.printStackTrace();
                }
                //必要な処理
            }
        }
    }
}

ちなみに、getIntExtradefaultValueを0にしているサンプルを良く見かけるのですが、0だとBILLING_RESPONSE_RESULT_OKになってしまって微妙な気がしたので-1にしてみました。
なんか理由があるのかもしれませんが。

  • 署名の確認
テストアカウントであればサンドボックステストでも署名が返ってくるはずですが、うまくいかなかったので実購入テストで試しました。

登場人物はonActivityResultに渡されてきたIntentに入っている、INAPP_PURCHASE_DATAINAPP_DATA_SIGNATUREと、公開鍵であるBASE64_ENCODED_PUBLIC_KEYです。

BASE64_ENCODED_PUBLIC_KEYは、Developers Consoleで確認出来る公開鍵です。

INAPP_PURCHASE_DATAはレスポンスのjson文字列で、INAPP_DATA_SIGNATUREはそれを公開鍵で署名したものです。

署名検証にはjava.security.Signatureを利用し、流れとしてはSignaturegetInstanceメソッドで生成し、initVerifyメソッドで公開鍵で初期化。updateメソッドでレスポンスのjson文字列をバイト配列に変換したものを渡しverifyメソッドで検証します。

protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQUEST_CODE) {
        int responseCode = data.getIntExtra("RESPONSE_CODE", -1);
        if (resultCode == RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) {
            String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
            String signatureData = data.getStringExtra("INAPP_DATA_SIGNATURE");
            if (purchaseData != null && signatureData != null) {
                if(verify(generatePublicKey(BASE64_ENCODED_PUBLIC_KEY),purchaseData, signatureData)) {
                    //署名OK
                }
            }
        }
    }
}

private final String SIGNATURE_ALGORITHM = "SHA1withRSA";
private final String KEY_FACTORY_ALGORITHM = "RSA";

public boolean verify(PublicKey publicKey, String signedData, String signature) {
    Signature sig;
    try {
        sig = Signature.getInstance(SIGNATURE_ALGORITHM);
        sig.initVerify(publicKey);
        sig.update(signedData.getBytes());
        if (!sig.verify(Base64.decode(signature, Base64.DEFAULT))) {
            return false;
        }
        return true;
    } catch (InvalidKeyException e) {
    } catch (NoSuchAlgorithmException e) {
    } catch (SignatureException e) {
    }
    return false;
}

public PublicKey generatePublicKey(String encodedPublicKey) {
    PublicKey pKey = null;
    try {
        byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT);
        KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
        pKey = keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
    } catch (NoSuchAlgorithmException e) {
    } catch (InvalidKeySpecException e) {
    }
    return pKey;
}

署名のところはもう少し色々と考えなくてはいけないこともありますが、とりあえずは検証出来ました。
次回はここまで作成したもののテスト方法についてです。

Read More...

AndroidアプリのIn-app Billing version2からversion3への更新 ①リクエストを送る



GoogleがIn-app Billing Version 2 APIを殺すそうなので、もう長らく更新していなかったAndroidアプリを更新する羽目になっています。

昔version2の頃にアプリ内課金を実装したときは、正直意味が分からないけどサンプル通りに書いたら動いたというレベルだったので、結構苦手意識があります。

ただ、すごくシンプルになったという話も聞くので、これを機にまとめてみようと思います。


  • AIDLファイルの追加
サンプルアプリTrivialDriveに含まれている、Android Definititon Language(AIDL)ファイルを利用し、インターフェースファイルIIAppBillingService.javaを作ります。

Android SDK ManagerでGoogle Play Billing Libraryをインストールします 。


<android-sdk>/extras/google/play_billing/samples/TrivialDrive/からサンプルプロジェクトをインポートします。

TrivialDrivaサンプルの/srcディレクトリからIInAppBillingService.aidlファイルをコピーし、自分のアプリの/srcディレクトリへ貼り付けます。

/genディレクトリ内にIInAppBillingService.javaが自動的に作成されます。

  • AndroidManifestの更新
これはversion2と変わっていないようです。

AndroidManifest.xmlに以下のパーミッションを追加します。

<uses-permission android:name="com.android.vending.BILLING" />

  • ServiceConnectionの作成とIInAppBillingServiceへのバインド
接続時と切断時のコールバックメソッドを持つServiceConnectionを実装し、bindServiceメソッドでバインドします。

ServiceConnectionを実装します。

private IInAppBillingService mIInAppBillingService;
private ServiceConnection mServiceConnection= new ServiceConnection() {
    @Override
    public void onServiceDisconnected(ComponentName name) {
        mIInAppBillingService = null;
    }

    @Override
    public void onServiceConnected(ComponentName name, IBinder service) {
        mIInAppBillingService = IInAppBillingService.Stub.asInterface(service);
    }
};

IInAppBillingServiceを使用できるようにするため、ActivityonCreateメソッドでbindServiceメソッドを呼びます。
Intentの引数は、クラス名のIInAppBillingServiceではなく、InAppBillingServiceなのが紛らわしいですね。

Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND");
bindService(serviceIntent, mServiceConnection, Context.BIND_AUTO_CREATE);

終了処理として、Activityが終了時にonDestroyメソッドでunbindServiceメソッドでアンバインドします。

if (mServiceConnection != null) {
    unbindService(mServiceConnection);
}


なお、このあたりで大きくハマったのですが、bindServiceを呼んでからServiceConnectiononServiceConnectedが呼ばれるまでにはタイムラグがあり、直後にIInAppBillingServiceをいじくろうとするとぬるぽで死にます。

bindService自体はtrueで返ってくるのに、IInAppBillingServiceはnullのまま。

時間を計ってみたところ、50~150ミリ秒後ぐらいでまちまちでしたが、何にしろタイムラグがあります。

直後にスレッド止めたりして待ち合わせをしても、ほぼ同時に返ってくるものの結局ぬるぽ。

まぁ何のためにonServiceConnectedメソッドを実装する必要があるのか考えれば、分かりそうなものでしたが。。

ということで、わざわざコールバックメソッドを実装させられている訳なので、onServiceConnectedからトリガーしてあげればぬるぽにはならない訳です。

  • サポート状況の確認
version3のAPIをサポートしているか確認します。

int response = mIInAppBillingService.isBillingSupported(3, getPackageName(), "inapp");
if(response == BILLING_RESPONSE_RESULT_OK) {
    //OKの場合の処理
}

  • 購入状況の確認
getPurchasesメソッドを呼ぶと、購入済みアイテムの情報を取得出来ます。

RESPONSE_CODEや、INAPP_PURCHASE_ITEM_LISTなど、いくつかの情報を含むBundleが返されるので、必要に応じて取り出し利用します。

Bundle bundle = mIInAppBillingService.getPurchases(3, getPackageName(), "inapp", null);
if(bundle.getInt("RESPONSE_CODE") == BILLING_RESPONSE_RESULT_OK) {
    ArrayList<String> purchases = bundle.getStringArrayList("INAPP_PURCHASE_ITEM_LIST");
    for (int i = 0, n = purchases.size(); i < n; i++) {
        //purchases.get(i)の中身によって処理を変える。
    }
}

  • 購入処理
getBuyIntentメソッドを呼んで、返されたBundleからBUY_INTENTキーのPendingIntentを取得します。
取得したPendingIntenstartIntentSenderForResultメソッドを呼び出します。

getBuyIntentメソッドの第4引数はDeveloper Payloadというやつで、空文字でもnullでもレスポンス時には空文字が返ってきます。
本来は任意の文字列を入れて、海賊版対策に使います。

startIntentSenderForResultメソッドの第2引数はコールバックを受けたときにリクエストを識別するための任意のint値です。

Bundle buyIntentBundle = mIInAppBillingService.getBuyIntent(3,
        getPackageName(), itemId, "inapp", "");
if (buyIntentBundle.getInt("RESPONSE_CODE") == BILLING_RESPONSE_RESULT_OK) {
    PendingIntent pendingIntent = buyIntentBundle
        .getParcelable("BUY_INTENT");
    startIntentSenderForResult(pendingIntent.getIntentSender(),
        REQUEST_CODE, new Intent(), Integer.valueOf(0),
        Integer.valueOf(0), Integer.valueOf(0));
}

なお、サポート状況の確認から購入処理まで、IInAppBillingServiceのメソッドを呼ぶ際には、RemoteExceptionが発生するので、適時catchします。

あとはActivityのonActivityResultへレスポンスが来るので、適時処理するだけです。


次回はstartIntentSenderForResultのレスポンス部分、onActivityResultでの処理についてです。


Read More...

2014年11月15日土曜日

AdMobをGoogle Mobile Ads SDKを使って最新のライブラリで更新する



定期的に最新のライブラリへ更新を要求してきて、既存アプリのメンテを余儀なくさせるAdMobについてです。

しばらく見ない間に、Google Admob ads SDKだの、Google Play Services SDKだの、Google Mobile Ads SDKだの、ライブラリも良く分からない状況になっています。

軽く調べたところ、最新はGoogle Play 開発者サービスのGoogle Mobile Adsに内包されているようなので、既存のアプリを切り替えていきます。

かなりの期間放置していたので、何世代か前の状態からの更新になるかと思います。


まずはGoogle Mobile Ads SDKですが、Android3.2以降でのコンパイルが必要だそうです。
つまり、project.propertiestargetがandroid-13以降に設定されている必要があります。

次に、Android 2.3以降のランタイムが必要だそうです。
AndroidManifest.xmlandroid:minSdkVersion9以降に設定されている必要があります。

今回更新するアプリのminSdkVersionは今まで7にしていたので、今回の更新で2.1、2.2ユーザを切り捨てることになります。

悩ましいところですが、こういうことは定期的に発生し、どこかで割り切る必要があります。


では実際に既存アプリを修正していきます。


  • ライブラリの追加

Android SDK ManagerからGoogle Play servicesをインストールします。

完了すると<android-sdk>/extras/google/google_play_services/libproject/配下にプロジェクトが配置されます。

配置されたgoogle-play-services_libライブラリプロジェクトを、FileImportAndroidExisting Android Code into Workspaceでインポートします。

今回更新するプロジェクトを右クリックして、プロパティAndroidライブラリー追加から追加します。

ちなみに、既存アプリに上記の流れでライブラリを追加したところ、以下のエラーが出ました。

Conversion to Dalvik format failed: Unable to execute dex:
Multiple dex files define Lcom/google/ads/AdRequest$ErrorCode;

どうやらライブラリのコンフリクトというやつで、古いGoogleAdMobAdsSdkライブラリがライブラリフォルダに入っていたので削除で解決しました。


  • AndroidManifestの設定

AndroidManifest.xmlをいくつか修正します。

まずは、以下のmeta-dataタグを追加します。

<meta-data android:name="com.google.android.gms.version"
       android:value="@integer/google_play_services_version"/>

はじめは以下のエラーが出ていました。

No resource found that matches the given name (at 'value' with value'@integer/google_play_services_version').

ライブラリのところを見てみると、以下のように×がついています。

結局良く分からないのですが、色々調べながらgoogle-play-servicesのインポートをやり直したりしている内に直りました。

ただ、直った後に別のアプリに同じことをしていたらまた同じエラー出たり、良く分からない動きをします。ネットでもハマっている人は多そうでした。

結局、google-play-servicesのほうではなく、アプリのほうのインポートをやり直すとうまくいきました。

google-play-servicesライブラリを追加した時に、アプリによって絶対パスで表示されたり、相対パスで表示されたりしていたので、アプリ側のインポート時に問題があったのかも知れません。


次に、com.google.android.gms.ads.AdActivityを宣言します。

<activity android:name="com.google.android.gms.ads.AdActivity"
       android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize" />

ちなみに古いコードでは、android:namecom.google.ads.AdActivityだったので、ここだけ変更しました。


最後に、ネットワーク権限を設定します。

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

これは前と同じだったので触っていません。


  • Viewの追加
xmlで設定する場合は、com.google.android.gms.ads.AdViewを追加します。

<com.google.android.gms.ads.AdView android:id="@+id/adView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       ads:adUnitId="MY_AD_UNIT_ID"
       ads:adSize="BANNER"/>

com.google.ads.AdViewだったものを、com.google.android.gms.ads.AdViewに変更するだけです。

ただ、AdViewだけの変更だと、以下のIllegalStateExceptionが出ました。

Ad Size and Ad unit id must be set before loadAd is called

これはXMLネームスペースの設定を変更していないため発生していました。
前はxmlns:ads="http://schemas.android.com/apk/lib/com.google.ads"だったものを、xmlns:ads="http://schemas.android.com/apk/res-auto"に変更します。


また、MY_AD_UNIT_IDの部分が、前はパブリッシャー IDというaから始まる15文字の文字列だったのに対し、現在は広告ユニットIDというca-app-pub-xxxxxxxxxxxxxxxx/nnnnnnnnnnという形式の文字列に置き換わりました。

これはAdMob管理画面内の収益化タブで確認出来ます。


ちなみにいつからあるのか知りませんが、ads:adSizeSMART_BANNERが選択出来るようになっています。

BANNERからSMART_BANNERに変更するだけで、自動的に画面サイズにあわせて真ん中に表示してくれます。

昔は自分で何かしら設定して、センタリングさせたと記憶しています。
これは便利ですね。


  • リソースのルックアップと読み込み

ActivityonCreateに以下コードを書きます。

AdView adView = (AdView)this.findViewById(R.id.adView);
AdRequest adRequest = new AdRequest.Builder().build();
adView.loadAd(adRequest);

自分の昔のコードを見るとAdRequestを直接newしていたのですが、AdRequestの生成方法が変わったようです。


  • テストデバイスの指定

そのまま実機で起動すると、テスト起動時にも普通に広告が表示されてしまうので、テスト用デバイスを指定しておきます。

一旦画面を表示させると、LogCatに以下のように端末IDのようなものが表示されます。

Use AdRequest.Builder.addTestDevice("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") to get test ads this device.

上記のIDをコピーして、AdRequestを以下のように生成すると、指定した実機ではテスト用のバナー広告が表示されるようになります。

AdRequest request = new AdRequest.Builder()
    .addTestDevice("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
    .build();

こんな感じになります。

ちゃんとセンタリングもされています。


ちなみに、実機でテストを実行するときに、製品版がインストールされてた場合、前はエラーになってた気がしますが、アンインストールするか聞いてくれるようになっています。

色々とイラっとしていた部分が便利になっていたりして、久しぶりに触ると楽しいです。
Read More...