前言

來到 Part 4,今次是收 SMS。又來回顧一下完整的步驟:

  1. 在第一頁
  2. 網頁會下載 驗證碼 captcha
  3. 用戶在輸入 apple ID 、密碼和 驗證碼 captcha,按遞交
  4. 在第二頁會用 ajax 下載顯示 SMS 的碼
  5. 用戶用手機將 SMS 碼以 SMS 形式寄到 Apple 電話,等待回覆
  6. Apple 回覆 SMS code
  7. 用戶到第二頁輸入發送 SMS 的手機號碼和 SMS 回覆碼,遞交
  8. 在第三頁網頁會自動下載你的個人資訊
  9. 用戶選擇 Apple Store,網頁會下載 Apple Store 的 timeslot 資料
  10. 用戶選擇 iPhone Model 、大小和 Contract type 後,網頁會下載存貨資料
  11. 如有存貨,用戶可輸入姓名、電話、身份證明號碼,遞交
  12. 預訂成功/失敗

今次我們做第 6 至 8 步。

接收 SMS

接收 SMS 的概念跟發送 SMS 差不多,都是用 BroadcastReceiver 接收 global broadcast 後,再用 local broadcast 通知 MainActivity

所以我們又要一 BroadcastReceiver 收取 Android OS 接收 SMS 的 intent,在 AndroidManifest.xml 新增以下:

<receiver
  android:name=".ReceiveSmsBroadcastReceiver"
  android:enabled="true"
  android:exported="true"
>
  <intent-filter android:priority="500">
       <action android:name="android.provider.Telephony.SMS_RECEIVED"/>
  </intent-filter>
</receiver>

ReceiveSmsBroadcastReceiver 即是長成這樣子:

public class ReceiveSmsBroadcastReceiver extends BroadcastReceiver {
    private static final String TAG = "ReceiveSmsBroadcastReceiver";
    @Override
    public void onReceive(Context context, Intent intent) {

        if (null != intent) {
            Bundle bundle = intent.getExtras();
            Log.d(TAG, "Received SMS intent");

            if (null != bundle) {
                Object[] pdus = (Object[]) bundle.get("pdus");
                SmsMessage[] smsMessage = new SmsMessage[pdus.length];
                String [] allMessageContent = new String[pdus.length];

                for (int i = 0; i < pdus.length; i++) {
                    smsMessage[i] = SmsMessage.createFromPdu((byte[]) pdus[i]);
                    allMessageContent[i] = smsMessage[i].getMessageBody();
                    for(String message:allMessageContent){
                        if(message != null) {
                             Log.d(TAG, "Got SMS: " + message);
                        }
                    }
                }
            }
        }
    }
}

測試一下,應該收到任何 SMS 後,logcat 也會顯示 Got SMS: <SMS Content> 的。

抽取 Reservation code

但我們不是想要所有的 SMS,只要特定的那個,所以要檢查內容是否 apple 給我們的編號。最簡單的是用 String.indexOf() 檢查有沒有 你的註冊代碼為 XXXXXXXX。若String.indexOf() > -1 便代表是我們需要的 SMS ,然後抽出 SMS code 便可。

String smsPattern = "你的註冊代碼為 (Your registration code is) ";
int idx = message.indexOf(smsPattern);
if(idx > -1){
  String smsCode = message.substring(idx + smsPattern.length());
  Log.d(TAG, "Matched SMS code: " + smsCode);
}

可是用此放法實在不夠 elegant。來,讓我們用 regular Expression 吧。不知道什麼是 Regular Expression 的建議學一學,基本的也已經很有用。

先定義 SMS code pattern

 String smsPattern = "你的註冊代碼為 \\(Your registration code is\\) ([a-zA-Z0-9]+)";

然後再這樣去抽編號出來

Pattern pattern = Pattern.compile(smsPattern);
Matcher matcher = pattern.matcher(message);
if (matcher.find()) {
  String smsRespondCode = matcher.group(1);
  Log.d(TAG, "Matched SMS code: " + smsRespondCode);

   broadcastMessageToActivity(context, smsRespondCode);
}

Regex 難度在於如何寫 Pattern,但學好的話以後做事便方便多了。

找到 code 後便可以通知 MainActivity 去更新 EditText#

private void broadcastMessageToActivity(Context context, String msg) {
    Intent in = new Intent(MainActivity.BROADCAST_RECEIVE_SMS);
    in.putExtra(MainActivity.KEY_RECEIVE_SMS_RESULT, msg);
    LocalBroadcastManager.getInstance(context).sendBroadcast(in);
}

當然 MainActivity 那邊的 localBroadcastReceiver 也要更新一下:

BroadcastReceiver localBroadcastReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        if(intent != null){
            if(BROADCAST_SEND_SMS.equals(intent.getAction())){
                boolean result = intent.getBooleanExtra(KEY_SEND_SMS_RESULT, false);
                if(result){
                    addLog("Send SMS successfully");
                }
                else{
                    addLog("Failed to send SMS");
                }
            }
            else if(BROADCAST_RECEIVE_SMS.equals(intent.getAction())){
                String smsCode = intent.getStringExtra(KEY_RECEIVE_SMS_RESULT);
                if(smsCode != null){
                    addLog("got reservation code: " + smsCode);
                    // submit reservation code
                }
            }
        }
    }
};

這個 localBroadcastReceiver 其實是應該分兩個 class 來對應不同的 action 的,這樣才是一個好 OOP,不過我懶,所以合在一起。

有了這 localBroadcastReceiver 便可以繼續 bot 的旅程了。

遞交預訂編碼

弄妥後,便可遞交 SMS 編碼,需要的資料如下:

遞交 SMS 編碼資料

做法跟之前的差不多,也是用 http Post。在 ReserveWorker 新增 submitSmsCode():

//submit SMS code
public String submitSmsCode(String phoneNum, String smsRespondCode) throws Exception {
    Map<String, String> params = new HashMap<String, String>();
    params.put("phoneNumber", phoneNum);
    params.put("reservationCode", smsRespondCode);
    params.put("p_ie", "???");
    params.put("_flowExecutionKey", "???");
    params.put("_eventId", "next");

    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://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2")
            .post(formBody)
            .build();
    Response response = okHttpClient.newCall(request).execute();

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

    String returnResponse = url;

    return returnResponse;
}

不知 _flowExecutionKeyp_ie 在哪裏來? 還記得之前拿 SMS request code 的 respond 嗎?

{
  "firstTime" : true,
  "IRSV141417879720141024" : "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAR<--TRIMED-->",
  "keyword" : "data:image/png;base64,iVBORw0KGgoAAAANSU2u1cfWQ<--TRIMED-->",
  "_flowExecutionKey" : "e1s2",
  "p_ie" : "90166040-b3b6-4551-8d94-8f430f5150c0"
}

就是這個了。我們更新之前的 retrieveSmsCodePage() 去儲存 _flowExecutionKeyp_ie 拿來用

//get SMS code
public String retrieveSmsCodePage() throws Exception {
    //........
    //......
    try {
        JSONObject jsonObject = new JSONObject(body);
        loginPageQueryString.put(P_IE, jsonObject.getString(key));
        loginPageQueryString.put(FLOW_EXECUTION_KEY, jsonObject.getString(FLOW_EXECUTION_KEY));

    //........
    //......

然後 submitSmsCode() 便可以用它們了:

params.put("p_ie", loginPageQueryString.get(P_IE));
params.put("_flowExecutionKey", loginPageQueryString.get(FLOW_EXECUTION_KEY));

當然要在 Button 執行它:

private void submitSmsReservationCode(final String smsReservationCode){
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... params)  {
            String result = null;
            try{
                result = reserveWorker.submitSmsCode(PHONE_NUMBER, smsReservationCode);
            }
            catch(Exception e){
                e.printStackTrace();
            }
            return result;
        }

        @Override
        protected void onPostExecute(String s) {
            //check submission result
        }
    }.execute();
}

不過這裏有一個問題,無論 reservation code 正確與否,url 也不像之前的有所改變,那麼如何知道結果呢?

如果你有用 firebug 檢查 request,應該看到成功失敗的話有此一 request:

https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s3

你可試試輸入成功的 code 和失敗的,看看結果。

看到嗎?分別在於拿回來的 json 是否有 errors 而已。

而拿這 URLretrieveSmsCodePage() 的 request 對比一下,除了 execution外,也是一樣的!從此得知 execution 是會在每次遞交後 + 1 的。當然,我們可以加幾個方法來拿取這 URL 結果,但這樣的話我們永遠只是低 level 的 programmer! 要稍為升升 level,自然是要簡化它,不讓它有這麼多重覆的 coding。

ReserveWorker 新增 getCommonAjax():

public String getCommonAjax() throws Exception {
    String url = String.format("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=%1$s&ajaxSource=true&_eventId=context", loginPageQueryString.get(FLOW_EXECUTION_KEY));
    Request request = new Request.Builder()
            .url(url)
            .build();
    Response response = okHttpClient.newCall(request).execute();

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

然後 retrieveSmsCodePage() 便可簡化為

public String retrieveSmsCodePage() throws Exception {
    String body = getCommonAjax();
    .....
}

不過別忘記 loginPageQueryString.get(FLOW_EXECUTION_KEY) 正正是在 retrieveSmsCodePage() 後 initialized 的,不過經我們反覆測試,肯定在拿 SMS code 時,execution 一定是 e1s2,所以可以先 hard code 進去:

public String retrieveSmsCodePage() throws Exception {
    loginPageQueryString.put(FLOW_EXECUTION_KEY, "e1s2");
    String body = getCommonAjax();
    .....
}

先運行一次看看確保拿 SMS code 沒問題,然後便可繼續檢查遞交 reservation code 了。

MainActivity 新增 getSubmitResult()

private void getSubmitResult(){
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... params)  {
            String result = null;
            try{
                result = reserveWorker.getCommonAjax();
            }
            catch(Exception e){
                e.printStackTrace();
            }
            return result;
        }

        @Override
        protected void onPostExecute(String s) {
            //parse the JSON
        }
    }.execute();
}

在這個 onPostExecute() 裏我們便可以檢查 JSON 有沒有 errors 便知是否失敗。若沒有 errors 的話便是 login 的資料,可以繼續進行。

@Override
protected void onPostExecute(String jsonStr) {
    //parse the JSON

    try {
        JSONObject jsonObject = new JSONObject(jsonStr);
        JSONArray errors = jsonObject.getJSONArray("errors");
        if(errors.length() > 0){
            for(int i=0; i < errors.length(); i++){
                addLog("Errors: " + errors.getString(i) );
            }
        }
        else{
            //we have reached page 3!
        }
    } catch (JSONException jsonException) {
        //NO ERROR, should be proceed
    } catch (NullPointerException e) {
        addLog("Null pointer.  Please start again");
    }
}

這樣我們終於到達 page 3 檢查存貨的頁面了,離最終步驟只差一步!

待續

今次講解了接收 SMS 的方法,都是用 BroadcastReceiver 去接收再用 local broadcast 通知 MainActivity 的。之後的步驟便簡單多了,只是重覆的用 okhttpClient request 資料再分析 json 資料而已,相信大家自行寫下去絕無問題。當然,好頭好尾,我也會寫到最後的步驟的。

今次的 code 可在以下網址找到

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

下回是最終回了。


Apple iPhone Reserve Bot 教學 - 首頁