本帖最後由 goofyz 於 2014-10-21 15:19 編輯
原 blog link: http://bit.ly/1pwapy4 ,圖片請到原 post 看。
==================================
前言
現在開始我們寫 Android App,請確保你有以下智識,否則應該跟不上。
- 懂 Java (if-then-else, for loop, HashMap)
- 懂 Hello World 程度的 android app
- 懂設定 eclipse / IntelliJ IDEA 去 import library 和 compile & run android app
請留意本文會介紹一些 Android 相關概念, 但 code 以簡單化為目標,不會是 Android Design Best Practice,
前期準備
新增一個 Hello World Application
開啟 AndroidManifest.xml 新增以下 permission:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
第一個是容許 app 連接網絡,之後的是收發 SMS,最後的 Write External Storage 是遲些用來 debug 用的。
Game Started
現在我們要用到 Part 1 記錄的資訊 (沒有的話快去做一次吧,不過記著 Apple Reserve 只在上午八時至下午八時開放)。
回想一下 Workflow:
用戶到 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone
網頁 redirect 用戶到 https://signin.apple.com/IDMSWebAuth/login?.....
網頁會下載驗證碼 captcha
用戶在輸入 apple ID 、密碼和 驗證碼 captcha,按遞交
Browser 將資料 post 到 authenticate 頁面
網站 redirect 用戶到第二頁 SMS 版面
說穿了 bot 其實只是代替 browser 自動進行 http submit 的動作而已。不過由於這次 Apple Reserve 加進了 captcha,所以中間需要人手輸入。
HTTP Client - okhttp
要 submit http request ,當然要相對應的 client 。 Android SDK 已有 DefaultHttpClient ,可以做相關的操作,但因為太 low level,需要很多自訂的 code。想更簡單的話推薦用其他 library,Okhttp 是選擇之一,這次就用它來玩一玩吧。
okhttp
http://square.github.io/okhttp/
下載了 okhttp 和相關的 library 後, 將它們放進 libs/ 資料夾下,再 import 進你的 IDE 裏。
要做 http get 的話只要- String httpGet(String url) throws IOException {
- OkHttpClient client = new OkHttpClient();
- Request request = new Request.Builder()
- .url(url)
- .build();
- Response response = client.newCall(request).execute();
- return response.body().string();
- }
複製代碼 做 post 的話- public String httpPost() throws IOException {
- Map<String, String> params = new HashMap<String, String>();
- params.put("param1", "param1_value");
- params.put("param2", "param2_value");
- 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://web_page_to_be_post.com")
- .post(formBody)
- .build();
- Response response = execute(request);
- return response.body().string();
- }
複製代碼 你只需懂得 get 和 post 便可做大部份的工作了。
Step 1 - 瀏覽首頁
我們只集中看看瀏覽第一頁的動作:
okHttpClient 要到第一頁 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone
網站將 browser 重新導向至 apple ID login page https://signin.apple.com/IDMSWebAuth/login?.....
新增一個 class 叫 ReserveWorker, 它會負責所有關 network 的動作。 okhttp 自然是在這個 class 用的- public class AppleReserveWorker {
- private OkHttpClient okHttpClient;
- public AppleReserveWorker() {
- okHttpClient = new OkHttpClient();
- }
- }
複製代碼 因為 Apple Reserve Page 需用到 cookie 和 session,所以我們需要 cookies 的支援- public AppleReserveWorker() {
- okHttpClient = new OkHttpClient();
- okHttpClient.setFollowSslRedirects(true);
- CookieManager cookieManager = new CookieManager();
- CookieHandler.setDefault(cookieManager);
- cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
- okHttpClient.setCookieHandler(cookieManager);
- }
複製代碼 這樣 okHttpClient 便會自動記錄 cookies。
再新增一個 method 去做瀏覽第一頁:- public String visitFirstPage() throws Exception {
- Request request = new Request.Builder()
- .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
- .build();
- Response response = okHttpClient.newCall(request).execute();
- }
複製代碼 但如何知道 okhttpClient 有否執行 redirect 呢? 要知道 redirect 後的 url , 可以這樣- String resultUrl = response.request().url().toString();
複製代碼 我們只要將 resultUrl 對比 apple login page 的 URL 便知道有否 redirect 了。整個 method 會是這樣- public String visitFirstPage() throws Exception {
- Request request = new Request.Builder()
- .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
- .build();
- Response response = okHttpClient.newCall(request).execute();
- String resultUrl = response.request().url().toString();
- return resultUrl;
- }
複製代碼 現在回到 MainActivity 中執行它- public class MainActivity extends Activity {
- private static final String TAG = "MyActivity";
- ReserveWorker reserveWorker;
- TextView tvMsg;
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.acitivty_main);
- tvMsg = (TextView)findViewById(R.id.tv_msg);
- reserveWorker = new ReserveWorker();
- goFrontPage();
- }
- private void goFrontPage(){
- try {
- String resultUrl = appleReserveWorker.visitFirstPage();
- Log.d(TAG, "Result url is " + resultUrl);
- }
- catch(Exception e){
- e.printStackTrace();
- }
- }
- }
複製代碼 試試 compile 放到 Android 上行一次,看看 logcat 有什麼?- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ android.os.NetworkOnMainThreadException
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork(StrictMode.java:1145)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.BlockGuardOs.connect(BlockGuardOs.java:84)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connectErrno(IoBridge.java:127)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connect(IoBridge.java:112)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:192)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:460)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.Socket.connect(Socket.java:833)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.Platform$Android.connectSocket(Platform.java:220)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Connection.connect(Connection.java:148)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.OkHttpClient$1.connect(OkHttpClient.java:84)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:321)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:241)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.getResponse(Call.java:198)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.execute(Call.java:80)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.AppleReserveWorker.visitFirstPage(AppleReserveWorker.java:31)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.MyActivity.goFrontPage(MyActivity.java:22)
- 10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.MyActivity.onCreate(MyActivity.java:17)
複製代碼 一大堆 error code,太恐佈了!
為什麼有此問題呢?因為我們在 main thread 上執行 network 相關操作。Main thread 又名 UI thread, app 是用一個 process 來運行的,更新畫面全靠它來做,如果用它來做 network / disk io 這些相對較耗時的工作,app 便不能更新 UI、回應 user 的輸入等等,所以 android預設是不容許用 UI thread 來做這些功能。要做的話,便要開新 thread 來做。
最簡單的解決方法是用 AsyncTask:- new AsyncTask<Void, Void, String>() {
- @Override
- protected String doInBackground(Void... voids) {
- //do something in another thread
- }
- @Override
- protected void onPostExecute(String resultString) {
- //update the UI
- }
- }.execute();
複製代碼 用 AsyncTask 很簡單,doInBackground 是用來做耗時的工作,完成後交給 onPostExecute 來做 ui 更新。所以之前的 goFrontPage() 會更新為:- private void goFrontPage(){
- new AsyncTask<Void, Void, String>() {
- @Override
- protected String doInBackground(Void... voids) {
- String resultUrl = null;
- try {
- resultUrl = reserveWorker.visitFirstPage();
- }
- catch(Exception e){
- e.printStackTrace();
- }
- return resultUrl;
- }
- @Override
- protected void onPostExecute(String resultString) {
- //update the UI
- Log.d(TAG, "Result url is " + resultString);
- boolean isLogin = (resultString.startsWith("https://signin.apple.com/IDMSWebAuth/"));
- if(isLogin){
- tvMsg.setText("Redirected to login page");
- }
- else{
- tvMsg.setText("Failed");
- }
- }
- }.execute();
- }
複製代碼 注意的是,doInBackground 不能執行任何 UI 的更新,不然會死得很慘的。
現在再運行一次,這次沒問題了。在 logcat 上也可看到 redirect 後的網址。
Step 2 - 顯示 Captcha
去完第一頁,下一步當然是 login 了,我們需以下資料
Apple ID
Password
Captcha
Apple ID 和 Password 也很容易解決,問題是 captcha。它是一幅圖片,由於不能 skip 和 hardcode,所以我們必須要顯示在畫面上顯示 captcha 並讓用家自行輸入。
但如何顯示 captcha 呢?
從之前的經驗知道,下載任何東西也需要用新 thread 來做,那麼要用 AsyncTask 嗎 ? 在最原始的世界,我們可以用 AsyncTask 下載圖片的 byte 然後 decode 做 Bitmap 再顯示在 ImageView 上,所幸科技發展一日千里,Load image 問題已經有很多人遇到並解決了,我們不用再 reinvent the wheel。
來,讓我們使用 Glide 吧
Glide
https://github.com/bumptech/glide
Glide 需要 Android Support Library v4 才能運作,請自行下載吧。
Glide 功能強大,有需要的請自行研究。為簡單起見我們只用最基本的功能:- Glide.load(myUrl).into(captchaImageView);
複製代碼 執行後圖片便會自動載入到 captchaImageView,省時省力 (這正是我們應該追求的最高境界)。
那麼現在到 layout_main.xml 中加進 ImageView 和 EditText,用來顯示 captcha。- <?xml version="1.0" encoding="utf-8"?>
- <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:orientation="vertical"
- android:layout_width="fill_parent"
- android:layout_height="fill_parent"
- >
- <TextView
- android:id="@+id/tv_msg"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="Hello World, MyActivity"
- />
- <ImageView
- android:id="@+id/iv_captcha"
- android:layout_width="match_parent"
- android:layout_height="80dp"/>
- <EditText
- android:id="@+id/et_captcha"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"/>
- </LinearLayout>
複製代碼 運行一次看看,應該看到 captcha 了。
Step 3 - 登入
最後是登入的步驟,需要以下資料:
Required parameters
相信大部份資料都一看即明,fdcBrowserData 即是 browser 資料,重用即可。其他的都是不變資料,唯一有需要處理的是 path,似乎每次會不同,究竟是何時製造出來的?
細看 firebug 記錄由首頁到 login 的資料,可看到 login page 的 URL 是- https://signin.apple.com/IDMSWebAuth/login?path=%2FHK%2Fen_HK%2Freserve%2FiPhone%3Fexecution%3De1s1%26p_left%3DAAAAAARx6gk%252BcoKdb1dcWaBp2a1SG9Z5fcrf958H1xT0ydAVyg%253D%253D%26_eventId%3Dnext&p_time=1413859369&rv=3&language=HK-EN&p_left=AAAAAARx6gk%2BcoKdb1dcWaBp2a1SG9Z5fcrf958H1xT0ydAVyg%3D%3D&appIdKey=db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3
複製代碼 看到 path 嗎?即是我們可從首頁 redirect 到 login page 的 URL 上找到 path !
新增以下 function 到 ReserveWorker 去抽出所有 query string value。- public static Map<String, String> extractQueryString(String url) {
- String param = url.substring(url.indexOf("?") + 1);
- if (param.indexOf("#") > -1) {
- param = param.substring(0, param.indexOf("#"));
- }
- String paramsStr[] = param.split("&");
- Map<String, String> params = new HashMap<String, String>();
- for (String str : paramsStr) {
- String keyVal[] = str.split("=");
- if (keyVal.length == 2 && keyVal[0].length() > 0 && keyVal[1].length() > 0) {
- params.put(keyVal[0], keyVal[1]);
- }
- }
- return params;
- }
複製代碼 再更新之前 visitFrontPage() 為- Map<String, String> loginPageQueryString = new HashMap<String, String>();
- public String visitFirstPage() throws Exception {
- Request request = new Request.Builder()
- .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
- .build();
- Response response = okHttpClient.newCall(request).execute();
- String resultUrl = response.request().url().toString();
- loginPageQueryString = extractQueryString(resultUrl);
- Log.d(TAG, "Path is " + loginPageQueryString.get("path"));
- return resultUrl;
- }
複製代碼 便可將所有 query string 放進 loginPageQueryString 裏。
而在 ReserverWorker 新增 loginWithCaptcha function:- public String loginWithCaptcha(String captchaInput, String appleId, String password) throws Exception {
- Map<String, String> params = new HashMap<String, String>();
- params.put("openiForgotInNewWindow", "true");
- params.put("fdcBrowserData", "{"U":"Mozilla/5.0 (Windows NT 6.3; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0","L":"en-US","Z":"GMT+08:00","V":"1.1","F":"TF1;016;;;;;;;;;;;;;;;;;;;;;;Mozilla;Netscape;5.0%20%28Windows%29;20100101;undefined;true;Windows%20NT%206.3%3B%20WOW64;true;Win32;undefined;Mozilla/5.0%20%28Windows%20NT%206.3%3B%20WOW64%3B%20rv%3A32.0%29%20Gecko/20100101%20Firefox/32.0;en-US;undefined;signin.apple.com;undefined;undefined;undefined;undefined;false;false;" + GregorianCalendar.getInstance().getTime().getTime() + ";8;6/7/2005%2C%209%3A33%3A44%20PM;1920;1080;;12.0;;;;2013;12;-480;-480;9/22/2014%2C%209%3A13%3A52%20AM;24;1920;1040;0;0;Adobe%20Acrobat%7CAdobe%20PDF%20Plug-In%20For%20Firefox%20and%20Netscape%2011.0.06;;;;;Shockwave%20Flash%7CShockwave%20Flash%2012.0%20r0;;;;;;;;;;;;;18;;;;;;;"}");
- params.put("appleId", appleId);
- params.put("accountPassword", password);
- params.put("captchaInput", captchaInput);
- params.put("captchaAudioInput", "");
- params.put("appIdKey", "db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3");
- params.put("language", "HK-EN");
- params.put("path", URLDecoder.decode(loginPageQueryString.get("path")));
- params.put("rv", "3");
- params.put("sslEnabled", "true");
- params.put("Env", "PROD");
- params.put("captchaType", "image");
- params.put("captchaToken", "");
- 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://signin.apple.com/IDMSWebAuth/authenticate")
- .post(formBody)
- .build();
- Response response = okHttpClient.newCall(request).execute();
- String resultUrl = response.request().url().toString();
- return resultUrl;
- }
複製代碼 因為成功登入的話,URL 會變成 https://reserve-hk.apple.com/HK/ ... hone?execution=e1s2,所以這次也以 resultUrl 為成功登入與否的指標。
然後回到 layout_main.xml 裏加一登入按鈕- <Button
- android:id="@+id/btn_captcha"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="Submit Captcha"
- />
複製代碼 並在 MainActivity 新增以下 method :- private void goLoginCaptcha() {
- tvMsg.setText("Submitting captcha");
- final String captchaInput = ((EditText) findViewById(R.id.et_captcha)).getText().toString();
- new AsyncTask<Void, Void, String>() {
- @Override
- protected String doInBackground(Void... params) {
- String string = null;
- try {
- string = reserveWorker.loginWithCaptcha(captchaInput, APPLE_ID, PASSWORD);
- }
- catch(Exception e){
- e.printStackTrace();
- }
- return string;
- }
- @Override
- protected void onPostExecute(String s) {
- if ("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2".equals(s)) {
- tvMsg.setText("Apple ID Login successfully");
- } else {
- tvMsg.setText("Error: Apple ID Login failed");
- }
- }
- }.execute();
- }
複製代碼 在 onCreate() 設定點擊 Button 便登入吧。- findViewById(R.id.btn_captcha).setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- goLoginCaptcha();
- }
- });
複製代碼 運行試一試,輸入 captcha 按 Submit 應該已成功登入。
待續
今次解釋了如何進行 http get 和 post 的動作,以上的 code 可在以下網址找到:
https://github.com/goofyz/iphone6-reserve-bot/tree/part2
如果你已懂得發送接收 SMS,又已記錄所有 parameter 的話,你已經可以自行繼續寫整個 bot。如果你不懂的話,請期待 Part 3 。
==================================
原 blog link: http://bit.ly/1pwapy4 ,圖片請到原 post 看。 |