iPhone Reserve Bot 教學 3 - 發送 SMS

前言

來到 Part 3 了。今次我們來玩 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. 預訂成功/失敗

第 1 至 3 步在 Part 2 完成,今次我們做第 3 和第 4 步,為此我們會:

  • 加一 ImageView 顯示 SMS 圖片
  • 加一 EditText 讓人手動輸入 SMS code
  • 加一 Button 去發送 SMS

拿取 SMS code

Part 1 我們得知網頁拿代碼的 request 是 get 以下網頁:

https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2&ajaxSource=true&_eventId=context

SMS Code 的回應是:

{
  "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"
}

知道 request 和 response 的樣式便準備就緒,在 ReserveWorker 中新增 retrieveSmsCodePage():

//get SMS code
public String retrieveSmsCodePage() throws Exception {
    Request request = new Request.Builder()
            .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2&ajaxSource=true&_eventId=context")
            .build();
    Response response = okHttpClient.newCall(request).execute();
    String body = response.body().string();
    
    // get SMS code from body

 	return code
}

以前 SMS 代碼是 keyword 的值,但那十月尾後已經改用 IRSV141417879720141024而不用 keyword。其實我們可以直接拿 IRSV141417879720141024 來顯示,但萬一那天 Apple 又改了用另一 key 來顯示的話便會有問題。最保險最萬全的方法是分析 html 裏的 javascript,看看究竟 SMS code 用那一 key,不過這樣做會很複雜,不適合這教學,折衷一點我們會用排除法,用非 keyword, p_iefirstTime,便應該是正確的 SMS code 圖片。

// get SMS code from body
try {
  JSONObject jsonObject = new JSONObject(body);

  Iterator<String> iterator = jsonObject.keys();
  while(iterator.hasNext()){
    String key = iterator.next();
    if(!(key.equals(P_IE) || key.equals("keyword") || key.equals(FLOW_EXECUTION_KEY) || key.equals("firstTime"))){
      code = jsonObject.getString(key);
      log.debug("SMS key is " + key);
    }
  }
  } catch (JSONException e) {
  	log.debug("Error in getting sms code: " + e.getMessage());
  } catch (NullPointerException e) {
  	log.debug("Error in getting sms code: NPE");
}

這樣便拿到 code 。

為了將 SMS code 圖片顯示在 MainActivity 上,我們在 layout_main.xml 便要加入

<ImageView
        android:id="@+id/iv_sms_code"
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="#AAA"
        />
<EditText
        android:id="@+id/et_sms_code"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
<Button
        android:id="@+id/btn_send_sms"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Send SMS"
        />

ImageView#iv_sms_code 用來顯示 SMS 的圖片,EditText#et_sms_code 讓用戶輸入 SMS code,Button#btn_send_sms 自然是用來發送 sms 的。

顯示 SMS Code

但 SMS Code 是亂碼來的,如何使用?如果你一直有玩開 iReserve,應該知道以前的 SMS 代碼是文字來的,那時直接拿來 send SMS 便可以 (Those were the days, my friend)。現在已經變成圖片,不能簡單的 copy & paste。那麼圖片跟那堆亂碼有什麼關係?

其實亂碼頭一句已經給了提示: base64。

base64 是 encode 的一種方法,將圖示的 bytes 變成 ASCII,方便傳送。要將亂碼變回 bitmap 的話很簡單。我們只要逗號後面的亂碼。

String[] splitString = smsCode.split(",");
byte[] decodedString = Base64.decode(splitString[1], Base64.DEFAULT);
Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length);

然後將其塞進 ImageView 便可以:

((ImageView) findViewById(R.id.iv_sms_code)).setImageBitmap(decodedByte);

因為要更新 ImageView ,所以有關 base64 的都在 MainActivity.getSmsCode() 中做:

private void getSmsCode() {
    addLog("Getting SMS request code");
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... params) {
            String smsCode = null;
            try {
                smsCode = reserveWorker.retrieveSmsCodePage();
            }
            catch(Exception e){
                e.printStackTrace();
            }

            return smsCode;
        }

        @Override
        protected void onPostExecute(String smsCode) {
            if (smsCode != null) {
                addLog("SMS Request code returned");

                String[] splitString = smsCode.split(",");
                byte[] decodedString = Base64.decode(splitString[1], Base64.DEFAULT);
                Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length);
                ((ImageView) findViewById(R.id.iv_sms_code)).setImageBitmap(decodedByte);
            }
        }
    }.execute();
}

運行一次試試看:

發送 SMS

MainActivity 新增空白的 sendSms(String code) method,將 Button#btn_send_sms 設為一 click 執行 sendSms():

findViewById(R.id.btn_send_sms).setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View v) {
      sendSms(((EditText)findViewById(R.id.et_sms_code)).getText().toString());
  }
});

sendSms() 很簡單,其實只要一句:

private void sendSms(String code) {
  SmsManager.getDefault().sendTextMessage("64500366", null, code, null, null);
}

便能發送文字的 SMS。

但如果你有試過人手 iReserve,應該試過 send sms 失敗吧 (「這個訊息未能送出」)。因為太多人同時間發送 SMS 時,很大機會送出失敗,我們一定要知道 SMS 是否成功送出,不然原來送出失敗我們還在呆呆的等著回覆就傻仔了。

要知道成功與否也不難,我們來查查 API Doc:

public void sendTextMessage (String destinationAddress, String scAddress, String text, PendingIntent sentIntent, PendingIntent deliveryIntent)

sentIntent 似乎是有用的 parameter,看看解釋:

If not NULL this PendingIntent is broadcast when the message is successfully sent, or failed. The result code will be Activity.RESULT_OK for success, or one of these errors......

究竟在說什麼?

其實 android 的 process 之間溝通是用 Intent,它是一個信息之類的東西,例如 Android 開機,系統會廣播一個開機 Intent,告訴所有登記接收這 Intent 的程式:「系統已經啟動啦」。程式收到後便可根據自己的需要做自己要做的事。而 PendingIntent 就是一個包裝了的 Intent,通常是 process A 要交給 process B 去執行時用到的。

簡單來說 Android 成功送出 SMS 後,sentIntent 會以 global broadcast 形式廣播出去,我們只要登記接受此 PendingIntent,便知道 SMS 是否成功發送。

接收 sentIntent: Global Broadcast

為此我們發送 SMS 的 method 會變成:

public static final String BROADCAST_SEND_SMS = "com.thirtysparks.apple.bot.sms.send";

private void sendSms(String code) {
    Intent intent = new Intent(BROADCAST_SEND_SMS);
    PendingIntent sentIntent = PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
    SmsManager.getDefault().sendTextMessage("64500366", null, code, sentIntent, null);
    addLog("Sending SMS: " + code);
}

接收 sentIntent 需要一個 BroadcastReceiver,新增一個 SendSmsBroadcastReceiver 來接受這 global broadcast 吧:

public class SendSmsBroadcastReceiver extends BroadcastReceiver {
    private static final String TAG = "SendSmsBroadcastReceiver";

    @Override
    public void onReceive(Context context, Intent intent) {
    }
}

onReceive() 是最重要的 method。當 sentIntent 被廣播時便是在 onReceive() 接收的,所以我們在裏面加上:

public void onReceive(Context context, Intent intent) {
    if (null != intent) {
        Log.d(TAG, "Got Sent intent");
        boolean success = false;
        if(getResultCode() == Activity.RESULT_OK) {
            success = true;
        }
        Log.d(TAG, "Sent result: " + success);
    }
}

便可以知道發送結果。

要登記接收 sentIntent,便要在 AndroidManifest.xml<application> 中加入 <receiver> :

<receiver
    android:name=".SendSmsBroadcastReceiver"
    android:enabled="true"
    android:exported="true"
  >
  <intent-filter>
  	<action android:name="com.thirtysparks.apple.bot.sms.send"/>
  </intent-filter>
</receiver>

這裏的重點是

  • action 必須等於 sentIntentaction (即 com.thirtysparks.apple.bot.sms.send )
  • android:exported 必須為 true,不然 android OS 不能執行此 SendSmsBroadcastReceiver,不會接收 global broadcast。

運行看看,應可在 logcat 看到 Sent result: true 了。

與 MainActivity 溝通: Local Broadcast

可是 onReceive() 不是由我們的 app process 去執行,而是由 android OS 其他的 process 去執行的 (scheduler?),我們的 MainActivity 不會知道這個 onReceive 的結果。

要通知 MainActivity我們會用到另一款的 Broadcast: Local Broadcast。顧名思義,Local broadcast 是 local 的,即是只會由你的 app 之間傳送,其他 app/process 不能發送或接收此類 broadcast。

首先在 SendSmsBroadcastReceiver 中加入 broadcastToMainActivity() 去發送 broadcast:

private void broadcastToMainActivity(Context context, boolean success) {
    Intent in = new Intent(Constants.BROADCAST_SENT_SMS);
    in.putExtra(Constants.KEY_SMS_SENT_RESULT, success);
    LocalBroadcastManager.getInstance(context).sendBroadcast(in);
}

我們在 broadcast 的 intent 中加進 SMS 發送 local broadcast 給 MainActivity,當然記得要在 onReceive() 的最後去 call 它。

然後在 MainActivity 中新增以下 class member作為 local broadcast receiver,接收 send SMS 的結果:

BroadcastReceiver localBroadcastReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        if(intent != null){
            if(intent.getAction().equals(BROADCAST_SEND_SMS)){
                boolean result = intent.getBooleanExtra(KEY_SEND_SMS_RESULT, false);
                if(result){
                    addLog("Send SMS successfully");
                }
                else{
                    addLog("Failed to send SMS");
                }
            }
        }
    }
};

它會在收到 broadcast action = BROADCAST_SEND_SMS 後檢查結果,然後顯示出來。

每一個 receiver 都需登記才能接收 broadcast 的,要登記接受 local broadcast 便在 MainActivity.onCreate() 中執行以下 method:

private void registerReceiver(){
    IntentFilter intentFilter = new IntentFilter();
    intentFilter.addAction(BROADCAST_SEND_SMS);

    LocalBroadcastManager.getInstance(this).registerReceiver(localBroadcastReceiver, intentFilter);
}

做人記住要有好手尾,register 後記得要在離開時 unregister:

@Override
protected void onDestroy() {
    super.onDestroy();
    unregisterReceiver(localBroadcastReceiver);
}

這樣 Send SMS 的部份便完成了。成功的話會出現 Sent SMS succesfully,失敗的話便要再 click 「Send SMS」 按鈕。

題外話: 如何 debug?

有時要知道okHttpClient 遞交的 parameter 有沒有錯, response 去了那一版,除了用 URL 來檢查,我們也想看看 html 的內容。

本來用 webview, 將 html string set 進去看看最後的網頁,但 Apple 網頁大部份是用 javascript 載入資料,結果 WebView 只是顯示一個載入畫面,失去 debug 的效果。

若果直接用 logcat print 出來,又會太長不能全部顯示,而且很難看得明白。我的做法是將 body 儲存為 output.html , 然後再到電腦上查看,跟 Apple 網頁對比去確認是否去到我想去的頁面。所以在最初的 AndroidManifext.xmlpersmission 中有加入 android.permission.WRITE_EXTERNAL_STORAGE便是用來做這 debug 用途。

加入以下 static method :

public class FileUtil {
    public static void outputToFile(String message){
        try {
            File logFile = new File(Environment.getExternalStorageDirectory(), "output.txt");
            FileWriter fileWriter = new FileWriter(logFile, true);
            BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
            bufferedWriter.write(message + "\n");
            bufferedWriter.close();
            fileWriter.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

透過它我們可以以隨時 call outputToFile(response.body().string()) ,將 okHttpClientresponse 儲存出來。不過用電腦查看有一點要注意,有時透過將手機 USB 連接電腦,output.txt 不會是最新的版本 (不肯定為何如此,可能是 MTP 引起的),遇到此情況你可在手機將檔案改名 (output.txt 改為 output1.txt),便可在電腦上見到最新的版本。

待續

今次講解了怎樣發送 SMS,怎樣知道發送 SMS 的結果,以及 Broadcast and Receiver 的概念。本來打算一拼說說接收 SMS 的,因為都是用 BroadcastReceiver 去做,但越寫越長,所以最後決定再分 part 4 講解。

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

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

多謝大家支持,請耐心等候 Part 4 。


Apple iPhone Reserve Bot 教學 - 首頁