iPhone Reserve Bot 教學 3 - 發送 SMS
前言
來到 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
thisPendingIntent
is broadcast when the message is successfully sent, or failed. The result code will beActivity.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 可在以下網址找到
多謝大家支持,請耐心等候 Part 4 。
Apple iPhone Reserve Bot 教學 - 首頁