iPhone Reserve Bot 教學 4 - 接收 SMS
前言
來到 Part 4,今次是收 SMS。又來回顧一下完整的步驟:
- 在第一頁
- 網頁會下載 驗證碼 captcha
- 用戶在輸入 apple ID 、密碼和 驗證碼 captcha,按遞交
- 在第二頁會用 ajax 下載顯示 SMS 的碼
- 用戶用手機將 SMS 碼以 SMS 形式寄到 Apple 電話,等待回覆
- Apple 回覆 SMS code
- 用戶到第二頁輸入發送 SMS 的手機號碼和 SMS 回覆碼,遞交
- 在第三頁網頁會自動下載你的個人資訊
- 用戶選擇 Apple Store,網頁會下載 Apple Store 的 timeslot 資料
- 用戶選擇 iPhone Model 、大小和 Contract type 後,網頁會下載存貨資料
- 如有存貨,用戶可輸入姓名、電話、身份證明號碼,遞交
- 預訂成功/失敗
今次我們做第 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 編碼,需要的資料如下:
做法跟之前的差不多,也是用 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;
}
不知 _flowExecutionKey 和 p_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() 去儲存 _flowExecutionKey 和 p_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 而已。
而拿這 URL 跟 retrieveSmsCodePage() 的 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 可在以下網址找到
下回是最終回了。
Apple iPhone Reserve Bot 教學 - 首頁