前言

現在開始我們寫 Android App,請確保你有以下智識,否則應該跟不上。

  • 懂 Java (if-then-else, for loop, HashMap)
  • 懂 Hello World 程度的 android app
  • 懂設定 eclipse / IntelliJ IDEA 去 import library 和 compile & run android app

請留意本文會介紹一些 Android 相關概念, 但 code 以簡單化為目標,未必是 Android Design Best Practice。

前期準備

新增一個 Hello World Application

開啟 AndroidManifest.xml 新增以下 permission:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

第一個是容許 app 連接網絡,之後的是收發 SMS,最後的 Write External Storage 是遲些用來 debug 用的。

Game Started

現在我們要用到 Part 1 記錄的資訊 (沒有的話快去做一次吧,不過記著 Apple Reserve 只在上午八時至下午八時開放)。

回想一下 Workflow:

  1. 用戶到 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone
  2. 網頁 redirect 用戶到 https://signin.apple.com/IDMSWebAuth/login?.....
  3. 網頁會下載驗證碼 captcha
  4. 用戶在輸入 apple ID 、密碼和 驗證碼 captcha,按遞交
  5. Browser 將資料 post 到 authenticate 頁面
  6. 網站 redirect 用戶到第二頁 SMS 版面

說穿了 bot 其實只是代替 browser 自動進行 http submit 的動作而已。不過由於這次 Apple Reserve 加進了 captcha,所以中間需要人手輸入。

HTTP Client - okhttp

要 submit http request ,當然要相對應的 client 。 Android SDK 已有 DefaultHttpClient ,可以做相關的操作,但因為太 low level,需要很多自訂的 code。想更簡單的話推薦用其他 library,Okhttp 是選擇之一,這次就用它來玩一玩吧。

okhttp

http://square.github.io/okhttp/

下載了 okhttp 和相關的 library 後, 將它們放進 libs/ 資料夾下,再 import 進你的 IDE 裏。

要做 http get 的話只要

String httpGet(String url) throws IOException {
  OkHttpClient client = new OkHttpClient();
  Request request = new Request.Builder()
      .url(url)
      .build();

  Response response = client.newCall(request).execute();
  return response.body().string();
}

做 post 的話

public String httpPost() throws IOException {
    Map<String, String> params = new HashMap<String, String>();

    params.put("param1", "param1_value");
    params.put("param2", "param2_value");

    FormEncodingBuilder builder = new FormEncodingBuilder();
    for (String key : params.keySet()) {
        builder.add(key, params.get(key));
    }
    RequestBody formBody = builder.build();
    Request request = new Request.Builder()
            .url("https://web_page_to_be_post.com")
            .post(formBody)
            .build();
    Response response = execute(request);

    return response.body().string();
}

你只需懂得 get 和 post 便可做大部份的工作了。

Step 1 - 瀏覽首頁

我們只集中看看瀏覽第一頁的動作:

  1. okHttpClient 要到第一頁 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone
  2. 網站將 browser 重新導向至 apple ID login page https://signin.apple.com/IDMSWebAuth/login?.....

新增一個 class 叫 ReserveWorker, 它會負責所有關 network 的動作。 okhttp 自然是在這個 class 用的

public class AppleReserveWorker {
  private OkHttpClient okHttpClient;

  public AppleReserveWorker() {
      okHttpClient = new OkHttpClient();
  }
}

因為 Apple Reserve Page 需用到 cookie 和 session,所以我們需要 cookies 的支援

public AppleReserveWorker() {
    okHttpClient = new OkHttpClient();
    okHttpClient.setFollowSslRedirects(true);

    CookieManager cookieManager = new CookieManager();
    CookieHandler.setDefault(cookieManager);
    cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
    okHttpClient.setCookieHandler(cookieManager);
}

這樣 okHttpClient 便會自動記錄 cookies。

再新增一個 method 去做瀏覽第一頁:

public String visitFirstPage() throws Exception {
    Request request = new Request.Builder()
            .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
            .build();
    Response response = okHttpClient.newCall(request).execute();
}

但如何知道 okhttpClient 有否執行 redirect 呢? 要知道 redirect 後的 url , 可以這樣

    String resultUrl = response.request().url().toString();

我們只要將 resultUrl 對比 apple login page 的 URL 便知道有否 redirect 了。整個 method 會是這樣

public String visitFirstPage() throws Exception {
    Request request = new Request.Builder()
            .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
            .build();
    Response response = okHttpClient.newCall(request).execute();

    String resultUrl = response.request().url().toString();

    return resultUrl;
}

現在回到 MainActivity 中執行它

public class MainActivity extends Activity {
    private static final String TAG = "MyActivity";
    ReserveWorker reserveWorker;

    TextView tvMsg;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.acitivty_main);

        tvMsg = (TextView)findViewById(R.id.tv_msg);

        reserveWorker = new ReserveWorker();
        goFrontPage();
    }

    private void goFrontPage(){
        try {
            String resultUrl = appleReserveWorker.visitFirstPage();
            Log.d(TAG, "Result url is " + resultUrl);
        }
        catch(Exception e){
            e.printStackTrace();
        }
    }
}

試試 compile 放到 Android 上行一次,看看 logcat 有什麼?

10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ android.os.NetworkOnMainThreadException
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork(StrictMode.java:1145)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.BlockGuardOs.connect(BlockGuardOs.java:84)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connectErrno(IoBridge.java:127)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connect(IoBridge.java:112)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:192)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:460)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.Socket.connect(Socket.java:833)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.Platform$Android.connectSocket(Platform.java:220)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Connection.connect(Connection.java:148)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.OkHttpClient$1.connect(OkHttpClient.java:84)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:321)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:241)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.getResponse(Call.java:198)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.execute(Call.java:80)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.AppleReserveWorker.visitFirstPage(AppleReserveWorker.java:31)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.MyActivity.goFrontPage(MyActivity.java:22)
10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.MyActivity.onCreate(MyActivity.java:17)

一大堆 error code,太恐佈了!

為什麼有此問題呢?因為我們在 main thread 上執行 network 相關操作。Main thread 又名 UI thread, app 是用一個 process 來運行的,更新畫面全靠它來做,如果用它來做 network / disk io 這些相對較耗時的工作,app 便不能更新 UI、回應 user 的輸入等等,所以 android預設是不容許用 UI thread 來做這些功能。要做的話,便要開新 thread 來做。

最簡單的解決方法是用 AsyncTask:

new AsyncTask<Void, Void, String>() {
    @Override
    protected String doInBackground(Void... voids) {
        //do something in another thread
    }

    @Override
    protected void onPostExecute(String resultString) {
        //update the UI
    }
}.execute();

AsyncTask 很簡單,doInBackground 是用來做耗時的工作,完成後交給 onPostExecute 來做 ui 更新。所以之前的 goFrontPage() 會更新為:

private void goFrontPage(){
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... voids) {
            String resultUrl = null;
            try {
                resultUrl = reserveWorker.visitFirstPage();
            }
            catch(Exception e){
                e.printStackTrace();
            }

            return resultUrl;
        }

        @Override
        protected void onPostExecute(String resultString) {
            //update the UI

            Log.d(TAG, "Result url is " + resultString);
            boolean isLogin = (resultString.startsWith("https://signin.apple.com/IDMSWebAuth/"));
            if(isLogin){
                tvMsg.setText("Redirected to login page");
            }
            else{
                tvMsg.setText("Failed");
            }
        }
    }.execute();
}

注意的是,doInBackground 不能執行任何 UI 的更新,不然會死得很慘的。

現在再運行一次,這次沒問題了。在 logcat 上也可看到 redirect 後的網址。

Step 2 - 顯示 Captcha

去完第一頁,下一步當然是 login 了,我們需以下資料

  • Apple ID
  • Password
  • Captcha

Apple ID 和 Password 也很容易解決,問題是 captcha。它是一幅圖片,由於不能 skip 和 hardcode,所以我們必須要顯示在畫面上顯示 captcha 並讓用家自行輸入。

但如何顯示 captcha 呢?

從之前的經驗知道,下載任何東西也需要用新 thread 來做,那麼要用 AsyncTask 嗎 ? 在最原始的世界,我們可以用 AsyncTask 下載圖片的 byte 然後 decode 做 Bitmap 再顯示在 ImageView 上,所幸科技發展一日千里,Load image 問題已經有很多人遇到並解決了,我們不用再 reinvent the wheel。

來,讓我們使用 Glide 吧。

Glide

https://github.com/bumptech/glide

Glide 需要 Android Support Library v4 才能運作,請自行下載吧。

Glide 功能強大,有需要的請自行研究。為簡單起見我們只用最基本的功能:

Glide.load(myUrl).into(captchaImageView);

執行後圖片便會自動載入到 captchaImageView,省時省力 (這正是我們應該追求的最高境界)。

那麼現在到 layout_main.xml 中加進 ImageViewEditText,用來顯示 captcha。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent"
        >
    <TextView
            android:id="@+id/tv_msg"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Hello World, MyActivity"
            />
    <ImageView
            android:id="@+id/iv_captcha"
            android:layout_width="match_parent"
            android:layout_height="80dp"/>
    <EditText
            android:id="@+id/et_captcha"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
</LinearLayout>

運行一次看看,應該看到 captcha 了。

顯示不到 Captcha

什麼? 你還是顯示不到 Captcha? 這正正是 Android 最麻煩的地方,同一段 code 有些機無問題但另一些卻有問題。

其實,拿 captcha 應該要用同一 http client 去拿取,這樣才能用同一 session,才能拿到正確的圖片的。幸好當初選用 Glide 的其中一個原因是它支援 okHttp !

先到 Glide Release page 下載 Glide-okhttp-Integration library,import 進 project 後,在 ReserveWorker 中加進以下 method:

public OkHttpClient getOkHttpClient() {
    return okHttpClient;
}

然後在 Glide.load(myUrl) 前設定好使用 okHttpClient:

    Glide.get(MainActivity.this).register(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory(reserveWorker.getOkHttpClient()));

這樣 Glide 便會使用同一 client 拿 image,cookies 呀 session 呀什麼的也會共用了。

為了解 Glide 在載入 captcha 時有沒有遇到 exception,可以將原來 load captcha 的一句 code 變成:

    Glide.with(MainActivity.this).load(imageUrl).skipMemoryCache(true).diskCacheStrategy(DiskCacheStrategy.NONE).listener(new RequestListener<String, GlideDrawable>() {
        @Override
        public boolean onException(Exception e, String s, Target<GlideDrawable> glideDrawableTarget, boolean b) {
            tvMsg.setText("Error in loading captcha");
            Log.d(TAG, "Error in loading captcha");
            if(e != null){
                Log.d(TAG, "Exception is " + e.getClass().getSimpleName() + ": " + e.getMessage());
            }
            else{
                Log.d(TAG, "Exception is null");
            }
            return false;
        }

        @Override
        public boolean onResourceReady(GlideDrawable glideDrawable, String s, Target<GlideDrawable> glideDrawableTarget, boolean b, boolean b2) {
            tvMsg.setText("Got Captcha, please enter the string. ");
            return false;
        }
    }).into(captchaImageView);

這樣有問題時便可在 logcat 看到。

若還是載入不到 captcha 的話便 reboot 看看,reboot 後還是不行的話再問問。

Step 3 - 登入

最後是登入的步驟,需要以下資料:

Required parameters

相信大部份資料都一看即明,fdcBrowserData 即是 browser 資料,重用即可。其他的都是不變資料,唯一有需要處理的是 path,似乎每次會不同,究竟是何時製造出來的?

細看 firebug 記錄由首頁到 login 的資料,可看到 login page 的 URL 是

https://signin.apple.com/IDMSWebAuth/login?path=%2FHK%2Fen_HK%2Freserve%2FiPhone%3Fexecution%3De1s1%26p_left%3DAAAAAARx6gk%252BcoKdb1dcWaBp2a1SG9Z5fcrf958H1xT0ydAVyg%253D%253D%26_eventId%3Dnext&p_time=1413859369&rv=3&language=HK-EN&p_left=AAAAAARx6gk%2BcoKdb1dcWaBp2a1SG9Z5fcrf958H1xT0ydAVyg%3D%3D&appIdKey=db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3

看到 path 嗎?即是我們可從首頁 redirect 到 login page 的 URL 上找到 path !

新增以下 function 到 ReserveWorker 去抽出所有 query string value。

public static Map<String, String> extractQueryString(String url) {
    String param = url.substring(url.indexOf("?") + 1);
    if (param.indexOf("#") > -1) {
        param = param.substring(0, param.indexOf("#"));
    }
    String paramsStr[] = param.split("&");
    Map<String, String> params = new HashMap<String, String>();
    for (String str : paramsStr) {
        String keyVal[] = str.split("=");
        if (keyVal.length == 2 && keyVal[0].length() > 0 && keyVal[1].length() > 0) {
            params.put(keyVal[0], keyVal[1]);
        }
    }

    return params;
}

再更新之前 visitFrontPage()

Map<String, String> loginPageQueryString = new HashMap<String, String>();
public String visitFirstPage() throws Exception {
    Request request = new Request.Builder()
            .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
            .build();
    Response response = okHttpClient.newCall(request).execute();

    String resultUrl = response.request().url().toString();


    loginPageQueryString = extractQueryString(resultUrl);
    Log.d(TAG, "Path is " + loginPageQueryString.get("path"));

    return resultUrl;
}

便可將所有 query string 放進 loginPageQueryString 裏。

而在 ReserverWorker 新增 loginWithCaptcha function:

public synchronized String loginWithCaptcha(String captchaInput, String appleId, String password) throws Exception {
    Map<String, String> params = new HashMap<String, String>();
    params.put("openiForgotInNewWindow", "true");
    params.put("fdcBrowserData", "{\"U\":\"Mozilla/5.0 (Windows NT 6.3; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0\",\"L\":\"en-US\",\"Z\":\"GMT+08:00\",\"V\":\"1.1\",\"F\":\"TF1;016;;;;;;;;;;;;;;;;;;;;;;Mozilla;Netscape;5.0%20%28Windows%29;20100101;undefined;true;Windows%20NT%206.3%3B%20WOW64;true;Win32;undefined;Mozilla/5.0%20%28Windows%20NT%206.3%3B%20WOW64%3B%20rv%3A32.0%29%20Gecko/20100101%20Firefox/32.0;en-US;undefined;signin.apple.com;undefined;undefined;undefined;undefined;false;false;" + GregorianCalendar.getInstance().getTime().getTime() + ";8;6/7/2005%2C%209%3A33%3A44%20PM;1920;1080;;12.0;;;;2013;12;-480;-480;9/22/2014%2C%209%3A13%3A52%20AM;24;1920;1040;0;0;Adobe%20Acrobat%7CAdobe%20PDF%20Plug-In%20For%20Firefox%20and%20Netscape%2011.0.06;;;;;Shockwave%20Flash%7CShockwave%20Flash%2012.0%20r0;;;;;;;;;;;;;18;;;;;;;\"}");

    params.put("appleId", appleId);
    params.put("accountPassword", password);
    params.put("captchaInput", captchaInput);
    params.put("captchaAudioInput", "");
    params.put("appIdKey", "db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3");
    params.put("language", "HK-EN");
    params.put("path", URLDecoder.decode(loginPageQueryString.get("path")));
    params.put("rv", "3");
    params.put("sslEnabled", "true");
    params.put("Env", "PROD");
    params.put("captchaType", "image");
    params.put("captchaToken", "");

    FormEncodingBuilder builder = new FormEncodingBuilder();
    for (String key : params.keySet()) {
        builder.add(key, params.get(key));
    }
    RequestBody formBody = builder.build();
    Request request = new Request.Builder()
            .url("https://signin.apple.com/IDMSWebAuth/authenticate")
            .post(formBody)
            .build();
    Response response =  okHttpClient.newCall(request).execute();


    String resultUrl = response.request().url().toString();
    return resultUrl;
}

因為成功登入的話,URL 會變成 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2,所以這次也以 resultUrl 為成功登入與否的指標。

然後回到 layout_main.xml 裏加一登入按鈕

<Button
        android:id="@+id/btn_captcha"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Submit Captcha"
        />

並在 MainActivity 新增以下 method :

private void goLoginCaptcha() {
    tvMsg.setText("Submitting captcha");

    final String captchaInput = ((EditText) findViewById(R.id.et_captcha)).getText().toString();
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... params)  {
            String string = null;
            try {
                string = reserveWorker.loginWithCaptcha(captchaInput, APPLE_ID, PASSWORD);
            }
            catch(Exception e){
                e.printStackTrace();
            }

            return string;
        }

        @Override
        protected void onPostExecute(String s) {
            if ("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2".equals(s)) {
                tvMsg.setText("Apple ID Login successfully");
            } else {
                tvMsg.setText("Error: Apple ID Login failed");
            }
        }
    }.execute();
}

onCreate() 設定點擊 Button 便登入吧。

    findViewById(R.id.btn_captcha).setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            goLoginCaptcha();
        }
    });

運行試一試,輸入 captcha 按 Submit 應該已成功登入。

Final Screenshot

待續

今次解釋了如何進行 http get 和 post 的動作,以上的 code 可在以下網址找到:

https://github.com/goofyz/iphone6-reserve-bot/tree/part2.1

如果你已懂得發送接收 SMS,又已記錄所有 parameter 的話,你已經可以自行繼續寫整個 bot。Part 3 將教如何發送 SMS。


Apple iPhone Reserve Bot 教學 - 首頁