[心得分享] iPhone Reserve Bot 教學 3 - 發送 SMS

本來想將 send SMS 和 receive SMS 一齊寫的,寫寫下發覺太長,所以今 part 只講 send SMS,receive SMS 留返下次 (寫左一半,part 4 應該唔駛等咁耐)。可以的話請看 原 post,formatting 會靚仔少少的。

====================================

前言

來到 Part 3 了。今次我們來玩 SMS,開始前我們來回顧一下完整的步驟:


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


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


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


拿取 SMS code

從 Part 1 我們得知網頁拿代碼的 request 是 get 以下網頁:
  1. https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2&ajaxSource=true&_eventId=context
複製代碼
SMS Code 的回應是:
  1. {
  2.   "firstTime" : true,
  3.   "IRSV141417879720141024" : "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAR<--TRIMED-->",
  4.   "keyword" : "data:image/png;base64,iVBORw0KGgoAAAANSU2u1cfWQ<--TRIMED-->",
  5.   "_flowExecutionKey" : "e1s2",
  6.   "p_ie" : "90166040-b3b6-4551-8d94-8f430f5150c0"
  7. }
  8. 知道 request 和 response 的樣式便準備就緒,在 ReserveWorker 中新增 retrieveSmsCodePage():

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

  16.     // get SMS code from body

  17.     return code
  18. }
複製代碼
以前 SMS 代碼是 keyword 的值,但那十月尾後已經改用 IRSV141417879720141024而不用 keyword。其實我們可以直接拿 IRSV141417879720141024 來顯示,但萬一那天 Apple 又改了用另一 key 來顯示的話便會有問題。最保險最萬全的方法是分析 html 裏的 javascript,看看究竟 SMS code 用那一 key,不過這樣做會很複雜,不適合這教學,折衷一點我們會用排除法,用非 keyword, p_ie 和firstTime,便應該是正確的 SMS code 圖片。
  1. // get SMS code from body
  2. try {
  3.   JSONObject jsonObject = new JSONObject(body);

  4.   Iterator<String> iterator = jsonObject.keys();
  5.   while(iterator.hasNext()){
  6.     String key = iterator.next();
  7.     if(!(key.equals(P_IE) || key.equals("keyword") || key.equals(FLOW_EXECUTION_KEY) || key.equals("firstTime"))){
  8.       code = jsonObject.getString(key);
  9.       log.debug("SMS key is " + key);
  10.     }
  11.   }
  12.   } catch (JSONException e) {
  13.       log.debug("Error in getting sms code: " + e.getMessage());
  14.   } catch (NullPointerException e) {
  15.       log.debug("Error in getting sms code: NPE");
  16. }
複製代碼
這樣便拿到 code 。

為了將 SMS code 圖片顯示在 MainActivity 上,我們在 layout_main.xml 便要加入
  1. <ImageView
  2.         android:id="@+id/iv_sms_code"
  3.         android:layout_width="match_parent"
  4.         android:layout_height="80dp"
  5.         android:background="#AAA"
  6.         />
  7. <EditText
  8.         android:id="@+id/et_sms_code"
  9.         android:layout_width="match_parent"
  10.         android:layout_height="wrap_content"/>
  11. <Button
  12.         android:id="@+id/btn_send_sms"
  13.         android:layout_width="match_parent"
  14.         android:layout_height="wrap_content"
  15.         android:text="Send SMS"
  16.         />
複製代碼
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 的話很簡單。我們只要逗號後面的亂碼。
  1. String[] splitString = smsCode.split(",");
  2. byte[] decodedString = Base64.decode(splitString[1], Base64.DEFAULT);
  3. Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length);
複製代碼
然後將其塞進 ImageView 便可以:
  1. ((ImageView) findViewById(R.id.iv_sms_code)).setImageBitmap(decodedByte);
複製代碼
因為要更新 ImageView ,所以有關 base64 的都在 MainActivity.getSmsCode() 中做:
  1. private void getSmsCode() {
  2.     addLog("Getting SMS request code");
  3.     new AsyncTask<Void, Void, String>() {
  4.         @Override
  5.         protected String doInBackground(Void... params) {
  6.             String smsCode = null;
  7.             try {
  8.                 smsCode = reserveWorker.retrieveSmsCodePage();
  9.             }
  10.             catch(Exception e){
  11.                 e.printStackTrace();
  12.             }

  13.             return smsCode;
  14.         }

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

  19.                 String[] splitString = smsCode.split(",");
  20.                 byte[] decodedString = Base64.decode(splitString[1], Base64.DEFAULT);
  21.                 Bitmap decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length);
  22.                 ((ImageView) findViewById(R.id.iv_sms_code)).setImageBitmap(decodedByte);
  23.             }
  24.         }
  25.     }.execute();
  26. }
複製代碼
運行一次試試看:

圖片

發送 SMS

在 MainActivity 新增空白的 sendSms(String code) method,將 Button#btn_send_sms 設為一 click 執行 sendSms():
  1. findViewById(R.id.btn_send_sms).setOnClickListener(new View.OnClickListener() {
  2.   @Override
  3.   public void onClick(View v) {
  4.       sendSms(((EditText)findViewById(R.id.et_sms_code)).getText().toString());
  5.   }
  6. });
複製代碼
而 sendSms() 很簡單,其實只要一句:
  1. private void sendSms(String code) {
  2.   SmsManager.getDefault().sendTextMessage("64500366", null, code, null, null);
  3. }
複製代碼
便能發送文字的 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 會變成:
  1. public static final String BROADCAST_SEND_SMS = "com.thirtysparks.apple.bot.sms.send";

  2. private void sendSms(String code) {
  3.     Intent intent = new Intent(BROADCAST_SEND_SMS);
  4.     PendingIntent sentIntent = PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
  5.     SmsManager.getDefault().sendTextMessage("64500366", null, code, sentIntent, null);
  6.     addLog("Sending SMS: " + code);
  7. }
複製代碼
接收 sentIntent 需要一個 BroadcastReceiver,新增一個 SendSmsBroadcastReceiver 來接受這 global broadcast 吧:
  1. public class SendSmsBroadcastReceiver extends BroadcastReceiver {
  2.     private static final String TAG = "SendSmsBroadcastReceiver";

  3.     @Override
  4.     public void onReceive(Context context, Intent intent) {
  5.     }
  6. }
複製代碼
onReceive() 是最重要的 method。當 sentIntent 被廣播時便是在 onReceive() 接收的,所以我們在裏面加上:
  1. public void onReceive(Context context, Intent intent) {
  2.     if (null != intent) {
  3.         Log.d(TAG, "Got Sent intent");
  4.         boolean success = false;
  5.         if(getResultCode() == Activity.RESULT_OK) {
  6.             success = true;
  7.         }
  8.         Log.d(TAG, "Sent result: " + success);
  9.     }
  10. }
複製代碼
便可以知道發送結果。

要登記接收 sentIntent,便要在 AndroidManifest.xml 的 <application> 中加入 <receiver> :
  1. <receiver
  2.     android:name=".SendSmsBroadcastReceiver"
  3.     android:enabled="true"
  4.     android:exported="true"
  5.   >
  6.   <intent-filter>
  7.       <action android:name="com.thirtysparks.apple.bot.sms.send"/>
  8.   </intent-filter>
  9. </receiver>
複製代碼
這裏的重點是


  • action 必須等於 sentIntent的 action (即 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:
  1. private void broadcastToMainActivity(Context context, boolean success) {
  2.     Intent in = new Intent(Constants.BROADCAST_SENT_SMS);
  3.     in.putExtra(Constants.KEY_SMS_SENT_RESULT, success);
  4.     LocalBroadcastManager.getInstance(context).sendBroadcast(in);
  5. }
複製代碼
我們在 broadcast 的 intent 中加進 SMS 發送 local broadcast 給 MainActivity,當然記得要在 onReceive() 的最後去 call 它。

然後在 MainActivity 中新增以下 class member作為 local broadcast receiver,接收 send SMS 的結果:
  1. BroadcastReceiver localBroadcastReceiver = new BroadcastReceiver() {
  2.     @Override
  3.     public void onReceive(Context context, Intent intent) {
  4.         if(intent != null){
  5.             if(intent.getAction().equals(BROADCAST_SEND_SMS)){
  6.                 boolean result = intent.getBooleanExtra(KEY_SEND_SMS_RESULT, false);
  7.                 if(result){
  8.                     addLog("Send SMS successfully");
  9.                 }
  10.                 else{
  11.                     addLog("Failed to send SMS");
  12.                 }
  13.             }
  14.         }
  15.     }
  16. };
複製代碼
它會在收到 broadcast action = BROADCAST_SEND_SMS 後檢查結果,然後顯示出來。

每一個 receiver 都需登記才能接收 broadcast 的,要登記接受 local broadcast 便在 MainActivity.onCreate() 中執行以下 method:
  1. private void registerReceiver(){
  2.     IntentFilter intentFilter = new IntentFilter();
  3.     intentFilter.addAction(BROADCAST_SEND_SMS);

  4.     LocalBroadcastManager.getInstance(this).registerReceiver(localBroadcastReceiver, intentFilter);
  5. }
複製代碼
做人記住要有好手尾,register 後記得要在離開時 unregister:
  1. @Override
  2. protected void onDestroy() {
  3.     super.onDestroy();
  4.     unregisterReceiver(localBroadcastReceiver);
  5. }
複製代碼
這樣 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.xml 的 persmission 中有加入 android.permission.WRITE_EXTERNAL_STORAGE便是用來做這 debug 用途。

加入以下 static method :
  1. public class FileUtil {
  2.     public static void outputToFile(String message){
  3.         try {
  4.             File logFile = new File(Environment.getExternalStorageDirectory(), "output.txt");
  5.             FileWriter fileWriter = new FileWriter(logFile, true);
  6.             BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
  7.             bufferedWriter.write(message + "\n");
  8.             bufferedWriter.close();
  9.             fileWriter.close();
  10.         } catch (FileNotFoundException e) {
  11.             e.printStackTrace();
  12.         } catch (IOException e) {
  13.             e.printStackTrace();
  14.         }
  15.     }
  16. }
複製代碼
透過它我們可以以隨時 call outputToFile(response.body().string()) ,將 okHttpClient 的 response 儲存出來。不過用電腦查看有一點要注意,有時透過將手機 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 。

====================================
Again, 可以的話請看 原 post,formatting 會靚仔少少的。

十分詳細,開始有D複習

TOP

十分詳細,開始有D複習
是但 發表於 2014-11-3 14:05



  有唔明可以係度問, 我會儘量解答

TOP

有D複習...........

TOP

好文

TOP

very good article

TOP

有唔明可以係度問, 我會儘量解答
goofyz 發表於 2014-11-3 20:33



多謝CHING先,暫時覺得SMS嗰part有d困難,可能真係好少用到

TOP

ching的無私奉獻,真厲害。支持。

TOP

之前都不知有LocalBroadcastReceiver....一直用硬來的方式去傳結果...(會明的會明XD)
獲益了, 感謝~

TOP

THANKS Share

TOP