本來想將 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 以下網頁:- 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_ie 和firstTime,便應該是正確的 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 必須等於 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:- 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.xml 的 persmission 中有加入 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()) ,將 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 會靚仔少少的。 |