commit 52209017a3b1d89fb886f305e88646a9ef7ed529 Author: seven332 Date: Sun Jan 24 00:17:57 2016 +0800 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..28bbe8f98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle +/local.properties +.DS_Store +/build +/src +*.iml +.idea +/captures diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..93ece6711 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,27 @@ +[submodule "yorozuya"] + path = yorozuya + url = https://github.com/seven332/Yorozuya +[submodule "hotspot"] + path = hotspot + url = https://github.com/seven332/Hotspot +[submodule "vector"] + path = vector + url = https://github.com/seven332/Vector +[submodule "anani"] + path = anani + url = https://github.com/seven332/Anani +[submodule "easyrecyclerview"] + path = easyrecyclerview + url = https://github.com/seven332/EasyRecyclerView +[submodule "beerbelly"] + path = beerbelly + url = https://github.com/seven332/BeerBelly +[submodule "conaco"] + path = conaco + url = https://github.com/seven332/Conaco +[submodule "rippleold"] + path = rippleold + url = https://github.com/seven332/RippleOld +[submodule "unifile"] + path = unifile + url = https://github.com/seven332/UniFile diff --git a/anani b/anani new file mode 160000 index 000000000..07fd88b42 --- /dev/null +++ b/anani @@ -0,0 +1 @@ +Subproject commit 07fd88b420d7c5253a92c9f59e2e308478e01654 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..75a476917 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,8 @@ +/build +/src/main/java-gen +manifest-merger-release-report.txt +/src/main/libs +/src/main/obj + +# Intellij +*.iml diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..c478a479e --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,51 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 23 + buildToolsVersion "23.0.2" + + defaultConfig { + applicationId "com.hippo.ehviewer" + minSdkVersion 15 + targetSdkVersion 23 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + sourceSets { + main { + java.srcDirs = ['src/main/java', 'src/main/java-gen'] + // Disable default NdkCompile task + jni.srcDirs = [] + // Set new jni lib dir + jniLibs.srcDir 'src/main/libs' + } + } +} + +dependencies { + compile project(':anani') + compile project(':beerbelly') + compile project(':conaco') + compile project(':easyrecyclerview') + compile project(':hotspot') + compile project(':okhttp3') + compile project(':rippleold') + compile project(':unifile') + compile project(':vector') + compile project(':yorozuya') + compile fileTree(dir: 'libs', include: ['*.jar']) + testCompile 'junit:junit:4.12' + compile 'com.android.support:appcompat-v7:23.1.1' + compile 'com.android.support:cardview-v7:23.1.1' + compile 'com.android.support:customtabs:23.1.1' + compile 'com.android.support:design:23.1.1' + compile 'de.greenrobot:greendao:2.0.0' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..468280303 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in E:\Android\android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/app/src/androidTest/java/com/hippo/ehviewer/ApplicationTest.java b/app/src/androidTest/java/com/hippo/ehviewer/ApplicationTest.java new file mode 100644 index 000000000..09ded3dd7 --- /dev/null +++ b/app/src/androidTest/java/com/hippo/ehviewer/ApplicationTest.java @@ -0,0 +1,13 @@ +package com.hippo.ehviewer; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * Testing Fundamentals + */ +public class ApplicationTest extends ApplicationTestCase { + public ApplicationTest() { + super(Application.class); + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/hippo/ehviewer/CookieDBTest.java b/app/src/androidTest/java/com/hippo/ehviewer/CookieDBTest.java new file mode 100644 index 000000000..f46a9f3a0 --- /dev/null +++ b/app/src/androidTest/java/com/hippo/ehviewer/CookieDBTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer; + +import com.hippo.okhttp.CookieDB; + +import junit.framework.Assert; +import junit.framework.TestCase; + +public class CookieDBTest extends TestCase { + + private static final String[] TEST_DOMAIN = { + null, + "", + "asdfghjkl", + "asdfghjkl.sds", + "asdfghjkl.sds.sddddd", + "asdfghjkl.sds.sddddd.aaaaa", + "asdfghjkl.sds.sddddd.", + "asdfghjkl.sds..", + }; + + private static final String[] TEST_COOKIES_DOMAIN = { + "", + "", + "asdfghjkl", + "asdfghjkl.sds", + "sds.sddddd", + "sddddd.aaaaa", + "sddddd.", + ".", + }; + + public void testCookiesDomain() { + for (int i = 0; i < TEST_DOMAIN.length; i++) { + Assert.assertEquals(TEST_COOKIES_DOMAIN[i], CookieDB.cookiesDomain(TEST_DOMAIN[i])); + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..67d8f2144 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/hippo/ehviewer/EhApplication.java b/app/src/main/java/com/hippo/ehviewer/EhApplication.java new file mode 100644 index 000000000..9c7435a9a --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/EhApplication.java @@ -0,0 +1,77 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer; + +import android.app.Application; +import android.content.Context; +import android.support.annotation.NonNull; + +import com.hippo.ehviewer.client.EhClient; +import com.hippo.ehviewer.client.EhCookieStore; +import com.hippo.network.StatusCodeException; +import com.hippo.okhttp.CookieDB; + +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; + +public class EhApplication extends Application { + + private EhCookieStore mEhCookieStore; + private EhClient mEhClient; + private OkHttpClient mOkHttpClient; + + @Override + public void onCreate() { + super.onCreate(); + + GetText.initialize(this); + CookieDB.initialize(this); + StatusCodeException.initialize(this); + Settings.initialize(this); + } + + public static EhCookieStore getEhCookieStore(@NonNull Context context) { + EhApplication application = ((EhApplication) context.getApplicationContext()); + if (application.mEhCookieStore == null) { + application.mEhCookieStore = new EhCookieStore(); + } + return application.mEhCookieStore; + } + + @NonNull + public static EhClient getEhClient(@NonNull Context context) { + EhApplication application = ((EhApplication) context.getApplicationContext()); + if (application.mEhClient == null) { + application.mEhClient = new EhClient(application); + } + return application.mEhClient; + } + + public static OkHttpClient getOkHttpClient(@NonNull Context context) { + EhApplication application = ((EhApplication) context.getApplicationContext()); + if (application.mOkHttpClient == null) { + application.mOkHttpClient = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .cookieJar(getEhCookieStore(application)) + .build(); + } + return application.mOkHttpClient; + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/GetText.java b/app/src/main/java/com/hippo/ehviewer/GetText.java new file mode 100644 index 000000000..02840d112 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/GetText.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer; + +import android.content.Context; +import android.content.res.Resources; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; + +public class GetText { + + private static Resources sResources; + + public static void initialize(Context context) { + sResources = context.getApplicationContext().getResources(); + } + + @NonNull + public static String getString(@StringRes int id) { + return sResources.getString(id); + } + + @NonNull + public static String getString(@StringRes int id, Object... formatArgs) { + return sResources.getString(id, formatArgs); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/Settings.java b/app/src/main/java/com/hippo/ehviewer/Settings.java new file mode 100644 index 000000000..77f11f22c --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/Settings.java @@ -0,0 +1,104 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import com.hippo.yorozuya.NumberUtils; + +public class Settings { + + private static Context sContext; + private static SharedPreferences sSettingsPre; + + public static void initialize(Context context) { + sContext = context.getApplicationContext(); + sSettingsPre = PreferenceManager.getDefaultSharedPreferences(sContext); + } + + public static boolean getBoolean(String key, boolean defValue) { + return sSettingsPre.getBoolean(key, defValue); + } + + public static void putBoolean(String key, boolean value) { + sSettingsPre.edit().putBoolean(key, value).apply(); + } + + public static int getInt(String key, int defValue) { + return sSettingsPre.getInt(key, defValue); + } + + public static void putInt(String key, int value) { + sSettingsPre.edit().putInt(key, value).apply(); + } + + public static long getLong(String key, long defValue) { + return sSettingsPre.getLong(key, defValue); + } + + public static void putLong(String key, long value) { + sSettingsPre.edit().putLong(key, value).apply(); + } + + public static float getFloat(String key, float defValue) { + return sSettingsPre.getFloat(key, defValue); + } + + public static void putFloat(String key, float value) { + sSettingsPre.edit().putFloat(key, value).apply(); + } + + public static String getString(String key, String defValue) { + return sSettingsPre.getString(key, defValue); + } + + public static void putString(String key, String value) { + sSettingsPre.edit().putString(key, value).apply(); + } + + public static int getIntFromStr(String key, int defValue) { + return NumberUtils.parseIntSafely(sSettingsPre.getString(key, Integer.toString(defValue)), defValue); + } + + public static void putIntToStr(String key, int value) { + sSettingsPre.edit().putString(key, Integer.toString(value)).apply(); + } + + private static final String KEY_DISPLAY_NAME = "display_name"; + private static final String DEFAULT_DISPLAY_NAME = null; + + public static String getDisplayName() { + return getString(KEY_DISPLAY_NAME, DEFAULT_DISPLAY_NAME); + } + + public static void putDisplayName(String value) { + putString(KEY_DISPLAY_NAME, value); + } + + private static final String KEY_SHOW_WARNING = "show_warning"; + private static final boolean DEFAULT_SHOW_WARNING = true; + + public static boolean getShowWarning() { + return getBoolean(KEY_SHOW_WARNING, DEFAULT_SHOW_WARNING); + } + + public static void putShowWarning(boolean value) { + putBoolean(KEY_SHOW_WARNING, value); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/CancelledException.java b/app/src/main/java/com/hippo/ehviewer/client/CancelledException.java new file mode 100644 index 000000000..93c7ffa0b --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/CancelledException.java @@ -0,0 +1,24 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +public class CancelledException extends Exception { + + public CancelledException() { + super("CANCELED"); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhClient.java b/app/src/main/java/com/hippo/ehviewer/client/EhClient.java new file mode 100644 index 000000000..c711a9884 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhClient.java @@ -0,0 +1,160 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +import android.content.Context; +import android.os.AsyncTask; + +import com.hippo.ehviewer.EhApplication; +import com.hippo.yorozuya.PriorityThreadFactory; +import com.hippo.yorozuya.SimpleHandler; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import okhttp3.Call; +import okhttp3.OkHttpClient; + +public class EhClient { + + public static final String TAG = EhClient.class.getSimpleName(); + + public static final int METHOD_SIGN_IN = 0; + + private final ThreadPoolExecutor mRequestThreadPool; + private final OkHttpClient mOkHttpClient; + + public EhClient(Context context) { + int poolSize = 3; + BlockingQueue requestWorkQueue = new LinkedBlockingQueue<>(); + ThreadFactory threadFactory = new PriorityThreadFactory(TAG, + android.os.Process.THREAD_PRIORITY_BACKGROUND); + mRequestThreadPool = new ThreadPoolExecutor(poolSize, poolSize, + 1L, TimeUnit.SECONDS, requestWorkQueue, threadFactory); + mOkHttpClient = EhApplication.getOkHttpClient(context); + } + + public void execute(EhRequest request) { + if (!request.isCancelled()) { + Task task = new Task(request.method, request.callback); + task.executeOnExecutor(mRequestThreadPool, request.args); + request.task = task; + } else { + request.callback.onCancel(); + } + } + + public class Task extends AsyncTask { + + private int mMethod; + private Callback mCallback; + + private Call mCall; + private boolean mStop; + + public Task(int method, Callback callback) { + mMethod = method; + mCallback = callback; + } + + public void stop() { + if (!mStop) { + mStop = true; + + if (mCallback != null) { + final Callback finalCallback = mCallback; + SimpleHandler.getInstance().post(new Runnable() { + @Override + public void run() { + finalCallback.onCancel(); + } + }); + } + + Status status = getStatus(); + if (status == Status.PENDING) { + cancel(false); + } else if (status == Status.RUNNING) { + if (mCall != null) { + mCall.cancel(); + } + } + + // Clear + mCall = null; + mCallback = null; + } + } + + private Object signIn(Object... params) throws Exception { + Call call = EhEngine.prepareSignIn(mOkHttpClient, (String) params[0], (String) params[1]); + if (!mStop) { + mCall = call; + return EhEngine.doSignIn(call); + } else { + throw new CancelledException(); + } + } + + @Override + protected Object doInBackground(Object... params) { + try { + switch (mMethod) { + case METHOD_SIGN_IN: + return signIn(params); + default: + return new IllegalStateException("Can't detect method " + mMethod); + } + } catch (Exception e) { + return e; + } + } + + @SuppressWarnings("unchecked") + @Override + protected void onPostExecute(Object result) { + if (mCallback != null) { + //noinspection StatementWithEmptyBody + if (!(result instanceof CancelledException)) { + if (result instanceof Exception) { + mCallback.onFailure((Exception) result); + } else { + mCallback.onSuccess(result); + } + } else { + // onCancel is called in stop + } + } + + // Clear + mCall = null; + mCallback = null; + } + } + + public interface Callback { + + void onSuccess(E result); + + void onFailure(Exception e); + + void onCancel(); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhConfig.java b/app/src/main/java/com/hippo/ehviewer/client/EhConfig.java new file mode 100644 index 000000000..28f696e0e --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhConfig.java @@ -0,0 +1,665 @@ +/* + * Copyright 2015 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +import android.text.TextUtils; + +/** + * Some configurable stuff about EH. It ends up cookie. + */ +public class EhConfig implements Cloneable { + + /** + * The Cookie key of uconfig + */ + public static final String KEY_UCONFIG = "uconfig"; + + /** + * The key of load images through the Hentai@Home Network + */ + private static final String KEY_LOAD_FROM_HAH = "uh"; + /** + * The key of scale images width + */ + private static final String KEY_SCALE_WIDTH = "rx"; + /** + * The key of scale images height + */ + private static final String KEY_SCALE_HEIGHT = "ry"; + /** + * The key of the behavior for downloading archiver + */ + private static final String KEY_ARCHIVER_DOWNLOAD = "ar"; + /** + * The key of display mode would you like to use on the front and search pages + */ + private static final String KEY_LAYOUT_MODE = "dm"; + /** + * The key of categories would you like to view as default on the front page + */ + private static final String KEY_DEFAULT_CATEGORIES = "cats"; + /** + * The key of exclude certain namespaces from a default tag search + */ + private static final String KEY_EXCLUDED_NAMESPACES = "xns"; + /** + * The key of hide galleries in certain languages from the gallery list and searches + */ + private static final String KEY_EXCLUDED_LANGUAGES = "xl"; + /** + * The key of how many results would you like per page for the index/search page and torrent search pages + */ + private static final String KEY_RESULT_COUNT = "rc"; + /** + * The key of mouse-over thumb + */ + private static final String KEY_MOUSE_OVER = "tl"; + /** + * The key of preview size + */ + private static final String KEY_PREVIEW_SIZE = "ts"; + /** + * The key of preview row per page + */ + private static final String KEY_PREVIEW_ROW = "tr"; + /** + * The key of sort order for gallery comments + */ + private static final String KEY_COMMENTS_SORT = "cs"; + /** + * The key of show gallery comment votes + */ + private static final String KEY_COMMENTS_VOTES = "sc"; + /** + * The key of sort order for gallery tags + */ + private static final String KEY_TAGS_SORT = "to"; + /** + * The key of show gallery page numbers + */ + private static final String KEY_SHOW_GALLERY_INDEX = "pn"; + /** + * The key of the IP:Port of a proxy-enabled Hentai@Home Client + * to load all images + */ + private static final String KEY_HAH_CLIENT_IP_PORT = "hp"; + /** + * The key of the passkey of a proxy-enabled Hentai@Home Client + * to load all images + */ + private static final String KEY_HAH_CLIENT_PASSKEY = "hk"; + /** + * The key of enable Tag Flagging + */ + private static final String KEY_ENABLE_TAG_FLAGGING = "tf"; + /** + * The key of always display the original images instead of the resampled versions + */ + private static final String KEY_ALWAYS_ORIGINAL = "oi"; + /** + * The key of enable the multi-Page Viewer + */ + private static final String KEY_MULTI_PAGE = "qb"; + /** + * The key of multi-Page Viewer Display Style + */ + private static final String KEY_MULTI_PAGE_STYLE = "ms"; + /** + * The key of multi-Page Viewer Thumbnail Pane + */ + private static final String KEY_MULTI_PAGE_THUMB = "mt"; + + /** + * The Cookie key of lofi resolution + */ + public static final String KEY_LOFI_RESOLUTION = "xres"; + + /** + * The Cookie key of show warning + */ + public static final String KEY_CONTENT_WARNING = "nw"; + + /** + * load images through the Hentai@Home Network + */ + public static final String LOAD_FROM_HAH_YES = "y"; + /** + * do not load images through the Hentai@Home Network + */ + public static final String LOAD_FROM_HAH_NO = "n"; + + /** + * Manual Accept, Manual Start + */ + public static final String ARCHIVER_DOWNLOAD_MAMS = "0"; + /** + * >Manual Accept, Auto Start + */ + public static final String ARCHIVER_DOWNLOAD_AAMS = "1"; + /** + * Auto Accept, Manual Start + */ + public static final String ARCHIVER_DOWNLOAD_MAAS = "2"; + /** + * Auto Accept, Auto Start + */ + public static final String ARCHIVER_DOWNLOAD_AAAS = "3"; + + /** + * List View on the front and search pages + */ + public static final String LAYOUT_MODE_LIST = "l"; + /** + * Thumbnail View on the front and search pages + */ + public static final String LAYOUT_MODE_THUMB = "t"; + + public static final int MISC = 0x1; + public static final int DOUJINSHI = 0x2; + public static final int MANGA = 0x4; + public static final int ARTIST_CG = 0x8; + public static final int GAME_CG = 0x10; + public static final int IMAGE_SET = 0x20; + public static final int COSPLAY = 0x40; + public static final int ASIAN_PORN = 0x80; + public static final int NON_H = 0x100; + public static final int WESTERN = 0x200; + + public static final int NAMESPACES_RECLASS = 0x1; + public static final int NAMESPACES_LANGUAGE = 0x2; + public static final int NAMESPACES_PARODY = 0x4; + public static final int NAMESPACES_CHARACTER = 0x8; + public static final int NAMESPACES_GROUP = 0x10; + public static final int NAMESPACES_ARTIST = 0x20; + public static final int NAMESPACES_MALE = 0x40; + public static final int NAMESPACES_FEMALE = 0x80; + + public static final String JAPANESE_TRANSLATED = "1024"; + public static final String JAPANESE_REWRITE = "2048"; + public static final String ENGLISH_ORIGINAL = "1"; + public static final String ENGLISH_TRANSLATED = "1025"; + public static final String ENGLISH_REWRITE = "2049"; + public static final String CHINESE_ORIGINAL = "10"; + public static final String CHINESE_TRANSLATED = "1034"; + public static final String CHINESE_REWRITE = "2058"; + public static final String DUTCH_ORIGINAL = "20"; + public static final String DUTCH_TRANSLATED = "1044"; + public static final String DUTCH_REWRITE = "2068"; + public static final String FRENCH_ORIGINAL = "30"; + public static final String FRENCH_TRANSLATED = "1054"; + public static final String FRENCH_REWRITE = "2078"; + public static final String GERMAN_ORIGINAL = "40"; + public static final String GERMAN_TRANSLATED = "1064"; + public static final String GERMAN_REWRITE = "2088"; + public static final String HUNGARIAN_ORIGINAL = "50"; + public static final String HUNGARIAN_TRANSLATED = "1074"; + public static final String HUNGARIAN_REWRITE = "2098"; + public static final String ITALIAN_ORIGINAL = "60"; + public static final String ITALIAN_TRANSLATED = "1084"; + public static final String ITALIAN_REWRITE = "2108"; + public static final String KOREAN_ORIGINAL = "70"; + public static final String KOREAN_TRANSLATED = "1094"; + public static final String KOREAN_REWRITE = "2118"; + public static final String POLISH_ORIGINAL = "80"; + public static final String POLISH_TRANSLATED = "1104"; + public static final String POLISH_REWRITE = "2128"; + public static final String PORTUGUESE_ORIGINAL = "90"; + public static final String PORTUGUESE_TRANSLATED = "1114"; + public static final String PORTUGUESE_REWRITE = "2138"; + public static final String RUSSIAN_ORIGINAL = "100"; + public static final String RUSSIAN_TRANSLATED = "1124"; + public static final String RUSSIAN_REWRITE = "2148"; + public static final String SPANISH_ORIGINAL = "110"; + public static final String SPANISH_TRANSLATED = "1134"; + public static final String SPANISH_REWRITE = "2158"; + public static final String THAI_ORIGINAL = "120"; + public static final String THAI_TRANSLATED = "1144"; + public static final String THAI_REWRITE = "2168"; + public static final String VIETNAMESE_ORIGINAL = "130"; + public static final String VIETNAMESE_TRANSLATED = "1154"; + public static final String VIETNAMESE_REWRITE = "2178"; + public static final String NA_ORIGINAL = "254"; + public static final String NA_TRANSLATED = "1278"; + public static final String NA_REWRITE = "2302"; + public static final String OTHER_ORIGINAL = "255"; + public static final String OTHER_TRANSLATED = "1279"; + public static final String OTHER_REWRITE = "2303"; + + /** + * 25 results per page for the index/search page and torrent search pages + */ + public static final String RESULT_COUNT_25 = "0"; + /** + * 50 results per page for the index/search page and torrent search pages + */ + public static final String RESULT_COUNT_50 = "1"; + /** + * 100 results per page for the index/search page and torrent search pages + */ + public static final String RESULT_COUNT_100 = "2"; + /** + * 200 results per page for the index/search page and torrent search pages + */ + public static final String RESULT_COUNT_200 = "3"; + + /** + * On mouse-over + */ + public static final String MOUSE_OVER_YES = "m"; + /** + * On page load + */ + public static final String MOUSE_OVER_NO = "p"; + + /** + * Preview normal size + */ + public static final String PREVIEW_SIZE_NORMAL = "m"; + /** + * Preview large size + */ + public static final String PREVIEW_SIZE_LARGE = "l"; + + /** + * 4 row preview per page + */ + public static final String PREVIEW_ROW_4 = "2"; + /** + * 10 row preview per page + */ + public static final String PREVIEW_ROW_10 = "5"; + /** + * 20 row preview per page + */ + public static final String PREVIEW_ROW_20 = "10"; + /** + * 40 row preview per page + */ + public static final String PREVIEW_ROW_40 = "20"; + + /** + * Oldest comments first + */ + public static final String COMMENTS_SORT_OLDEST_FIRST = "a"; + /** + * Recent comments first + */ + public static final String COMMENTS_SORT_RECENT_FIRST = "d"; + /** + * By highest score + */ + public static final String COMMENTS_SORT_HIGHEST_SCORE_FIRST = "s"; + + /** + * Show gallery comment votes On score hover or click + */ + public static final String COMMENTS_VOTES_POP = "0"; + /** + * Always show gallery comment votes + */ + public static final String COMMENTS_VOTES_ALWAYS = "1"; + + /** + * Sort order for gallery tags alphabetically + */ + public static final String TAGS_SORT_ALPHABETICAL = "a"; + /** + * Sort order for gallery tags by tag power + */ + public static final String TAGS_SORT_POWER = "p"; + + /** + * Show gallery page numbers + */ + public static final String SHOW_GALLERY_INDEX_YES = "1"; + /** + * Do not show gallery page numbers + */ + public static final String SHOW_GALLERY_INDEX_NO = "0"; + + /** + * Enable Tag Flagging + */ + public static final String ENABLE_TAG_FLAGGING_YES = "y"; + /** + * Do not enable Tag Flagging + */ + public static final String ENABLE_TAG_FLAGGING_NO = "n"; + + /** + * Always display the original images + */ + public static final String ALWAYS_ORIGINAL_YES = "y"; + /** + * Do not Always display the original images + */ + public static final String ALWAYS_ORIGINAL_NO = "n"; + + /** + * Enable the Multi-Page Viewe + */ + public static final String MULTI_PAGE_YES = "y"; + /** + * Do not enable the Multi-Page Viewe + */ + public static final String MULTI_PAGE_NO = "n"; + + /** + * Align left, only scale if image is larger than browser width + */ + public static final String MULTI_PAGE_STYLE_N = "n"; + /** + * Align center, only scale if image is larger than browser width + */ + public static final String MULTI_PAGE_STYLE_C = "c"; + /** + * Align center, Always scale images to fit browser width + */ + public static final String MULTI_PAGE_STYLE_Y = "y"; + + /** + * Show Multi-Page Viewer Thumbnail Pane + */ + public static final String MULTI_PAGE_THUMB_SHOW = "n"; + /** + * Hide Multi-Page Viewer Thumbnail Pane + */ + public static final String MULTI_PAGE_THUMB_HIDE = "y"; + + /** + * 460x for lofi resolution + */ + public static final String LOFI_RESOLUTION_460X = "1"; + + /** + * 780X for lofi resolution + */ + public static final String LOFI_RESOLUTION_780X = "2"; + + /** + * 980X for lofi resolution + */ + public static final String LOFI_RESOLUTION_980X = "3"; + + /** + * show warning + */ + private static final String CONTENT_WARNING_SHOW = "0"; + + /** + * not show warning + */ + private static final String CONTENT_WARNING_NOT_SHOW = "1"; + + /** + * Load images through the Hentai@Home Network
+ * key: {@link #KEY_LOAD_FROM_HAH}
+ * value: {@link #LOAD_FROM_HAH_YES}, {@link #LOAD_FROM_HAH_NO} + */ + public String loadFromHAH = LOAD_FROM_HAH_YES; + + /** + * Scale width
+ * key: {@link #KEY_SCALE_WIDTH}
+ * value: 0 for no limit + */ + public int scaleWidth = 0; + + /** + * Scale height
+ * key: {@link #KEY_SCALE_HEIGHT}
+ * value: 0 for no limit + */ + public int scaleHeight = 0; + + /** + * The default behavior for downloading an archiver
+ * key: {@link #KEY_ARCHIVER_DOWNLOAD}
+ * value: {@link #ARCHIVER_DOWNLOAD_MAMS}, {@link #ARCHIVER_DOWNLOAD_AAMS}, + * {@link #ARCHIVER_DOWNLOAD_MAAS}, {@link #ARCHIVER_DOWNLOAD_AAAS} + */ + public String archiverDownload = ARCHIVER_DOWNLOAD_MAMS; + + /** + * Display mode used on the front and search pages
+ * false for list, true for thumb
+ * key: {@link #KEY_LAYOUT_MODE}
+ * value: {@link #LAYOUT_MODE_LIST}, {@link #LAYOUT_MODE_THUMB} + */ + public String layoutMode = LAYOUT_MODE_LIST; + + /** + * Default categories on the front page
+ * key: {@link #KEY_DEFAULT_CATEGORIES}
+ * value: the value of categories, for multiple use & operation, + * 0 for none + */ + public int defaultCategories = 0; + + /** + * Certain namespaces excluded from a default tag search
+ * key: {@link #KEY_EXCLUDED_NAMESPACES}
+ * value: the value of namespaces, for multiple use & operation, + * 0 for none + */ + public int excludedNamespaces = 0; + + /** + * Certain languages excluded from list and searches
+ * key: {@link #KEY_EXCLUDED_LANGUAGES}
+ * value: {@link #JAPANESE_TRANSLATED}, {@link #JAPANESE_REWRITE} ... + * For multiple languages, use x to combine them, like 1x1024x2048 + */ + public String excludedLanguages = ""; + + /** + * How many results would you like per page for the index/search page + * and torrent search pages
+ * key: {@link #KEY_RESULT_COUNT}
+ * value: {@link #RESULT_COUNT_25}, {@link #RESULT_COUNT_50}, + * {@link #RESULT_COUNT_100}, {@link #RESULT_COUNT_200}
+ * Require Hath Perk:Paging Enlargement + */ + public String resultCount = RESULT_COUNT_25; + + /** + * mouse-over thumb
+ * key: {@link #KEY_MOUSE_OVER}
+ * value: {@link #MOUSE_OVER_YES}, {@link #MOUSE_OVER_NO} + */ + public String mouseOver = MOUSE_OVER_YES; + + /** + * Default preview mode
+ * key: {@link #KEY_PREVIEW_SIZE}
+ * value: {@link #PREVIEW_SIZE_NORMAL}, {@link #PREVIEW_SIZE_LARGE} + */ + public String previewSize = PREVIEW_SIZE_NORMAL; + + /** + * Preview row
+ * key: {@link #KEY_PREVIEW_ROW}
+ * value: {@link #PREVIEW_ROW_4}, {@link #PREVIEW_ROW_10}, + * {@link #PREVIEW_ROW_20}, {@link #PREVIEW_ROW_40} + */ + public String previewRow = PREVIEW_ROW_4; + + /** + * Sort order for gallery comments
+ * key: {@link #KEY_COMMENTS_SORT}
+ * value: {@link #COMMENTS_SORT_OLDEST_FIRST}, {@link #COMMENTS_SORT_RECENT_FIRST}, + * {@link #COMMENTS_SORT_HIGHEST_SCORE_FIRST} + */ + public String commentSort = COMMENTS_SORT_OLDEST_FIRST; + + /** + * Show gallery comment votes mode
+ * key: {@link #KEY_COMMENTS_VOTES}
+ * value: {@link #COMMENTS_VOTES_POP}, {@link #COMMENTS_VOTES_ALWAYS} + */ + public String commentVotes = COMMENTS_VOTES_POP; + + + /** + * Sort order for gallery tags
+ * key: {@link #KEY_TAGS_SORT}
+ * value: {@link #TAGS_SORT_ALPHABETICAL}, {@link #TAGS_SORT_POWER} + */ + public String tagSort = TAGS_SORT_ALPHABETICAL; + + /** + * Show gallery page numbers
+ * key: {@link #KEY_SHOW_GALLERY_INDEX}
+ * value: {@link #SHOW_GALLERY_INDEX_YES}, {@link #SHOW_GALLERY_INDEX_NO} + */ + public String showGalleryIndex = SHOW_GALLERY_INDEX_YES; + + /** + * The IP of a proxy-enabled Hentai@Home Client + * to load all images
+ * key: {@link #KEY_HAH_CLIENT_IP_PORT}
+ */ + public String hahClientIp = ""; + + /** + * The PORT of a proxy-enabled Hentai@Home Client + * to load all images
+ * key: {@link #KEY_HAH_CLIENT_IP_PORT}
+ */ + public int hahClientPort = -1; + + /** + * The passkey of a proxy-enabled Hentai@Home Client + * to load all images
+ * key: {@link #KEY_HAH_CLIENT_PASSKEY}
+ */ + public String hahClientPasskey = ""; + + /** + * Enable tag flagging + * key: {@link #KEY_ENABLE_TAG_FLAGGING}
+ * value: {@link #ENABLE_TAG_FLAGGING_YES}, {@link #ENABLE_TAG_FLAGGING_NO}
+ * Bronze Star or Hath Perk: Tag Flagging Required + */ + public String enableTagFlagging = ENABLE_TAG_FLAGGING_NO; + + /** + * Always display the original images instead of the resampled versions
+ * key: {@link #KEY_ALWAYS_ORIGINAL}
+ * value: {@link #ALWAYS_ORIGINAL_YES}, {@link #ALWAYS_ORIGINAL_NO}
+ * Silver Star or Hath Perk: Source Nexus Required + */ + public String alwaysOriginal = ALWAYS_ORIGINAL_NO; + + /** + * Enable the multi-Page Viewer
+ * key: {@link #KEY_MULTI_PAGE}
+ * value: {@link #MULTI_PAGE_YES}, {@link #MULTI_PAGE_NO}
+ * Gold Star or Hath Perk: Multi-Page Viewer Required + */ + public String multiPage = MULTI_PAGE_NO; + + /** + * Multi-Page Viewer Display Style
+ * key: {@link #KEY_MULTI_PAGE_STYLE}
+ * value: {@link #MULTI_PAGE_STYLE_N}, {@link #MULTI_PAGE_STYLE_C}, + * {@link #MULTI_PAGE_STYLE_Y}
+ * Gold Star or Hath Perk: Multi-Page Viewer Required + */ + public String multiPageStyle = MULTI_PAGE_STYLE_N; + + /** + * Multi-Page Viewer Thumbnail Pane
+ * key: {@link #KEY_MULTI_PAGE_THUMB}
+ * value: {@link #MULTI_PAGE_THUMB_SHOW}, {@link #MULTI_PAGE_THUMB_HIDE}
+ * Gold Star or Hath Perk: Multi-Page Viewer Required + */ + public String multiPageThumb = MULTI_PAGE_THUMB_SHOW; + + /** + * Lofi resolution + * key: {@link #KEY_LOFI_RESOLUTION}
+ * value: {@link #LOFI_RESOLUTION_460X}, {@link #LOFI_RESOLUTION_780X}, + * {@link #LOFI_RESOLUTION_980X} + */ + public String lofiResolution = LOFI_RESOLUTION_980X; + + /** + * Show content warning + * key: {@link #KEY_CONTENT_WARNING}
+ * value: {@link #CONTENT_WARNING_SHOW}, {@link #CONTENT_WARNING_NOT_SHOW} + */ + public String contentWarning = CONTENT_WARNING_NOT_SHOW; + + private String mUconfig; + + private boolean mDirty = true; + + @Override + public EhConfig clone() { + try { + return (EhConfig) super.clone(); + } catch (CloneNotSupportedException e) { + throw new IllegalStateException(e); + } + } + + public void setDirty() { + mDirty = true; + } + + private void updateUconfig() { + String hahClientIpPort = (TextUtils.isEmpty(hahClientIp) || hahClientPort == -1) ? + "" : hahClientIp + "%3A" + hahClientPort; + mUconfig = KEY_LOAD_FROM_HAH + "_" + loadFromHAH + "-" + + KEY_SCALE_WIDTH + "_" + scaleWidth + "-" + + KEY_SCALE_HEIGHT + "_" + scaleHeight + "-" + + KEY_ARCHIVER_DOWNLOAD + "_" + archiverDownload + "-" + + KEY_LAYOUT_MODE + "_" + layoutMode + "-" + + KEY_DEFAULT_CATEGORIES + "_" + defaultCategories + "-" + + KEY_EXCLUDED_NAMESPACES + "_" + excludedNamespaces + "-" + + KEY_EXCLUDED_LANGUAGES + "_" + excludedLanguages + "-" + + KEY_RESULT_COUNT + "_" + resultCount + "-" + + KEY_MOUSE_OVER + "_" + mouseOver + "-" + + KEY_PREVIEW_SIZE + "_" + previewSize + "-" + + KEY_PREVIEW_ROW + "_" + previewRow + "-" + + KEY_COMMENTS_SORT + "_" + commentSort + "-" + + KEY_COMMENTS_VOTES + "_" + commentVotes + "-" + + KEY_TAGS_SORT + "_" + tagSort + "-" + + KEY_SHOW_GALLERY_INDEX + "_" + showGalleryIndex + "-" + + KEY_HAH_CLIENT_IP_PORT + "_" + hahClientIpPort + "-" + + KEY_HAH_CLIENT_PASSKEY + "_" + hahClientPasskey + "-" + + KEY_ENABLE_TAG_FLAGGING + "_" + enableTagFlagging + "-" + + KEY_ALWAYS_ORIGINAL + "_" + alwaysOriginal + "-" + + KEY_MULTI_PAGE + "_" + multiPage + "-" + + KEY_MULTI_PAGE_STYLE + "_" + multiPageStyle + "-" + + KEY_MULTI_PAGE_THUMB + "_" + multiPageThumb; + } + + /* + public void fillCookie(Cookie cookie) { + if (mDirty) { + mDirty = false; + updateUconfig(); + } + + cookie.put(KEY_UCONFIG, mUconfig); + cookie.put(KEY_LOFI_RESOLUTION, lofiResolution); + cookie.put(KEY_CONTENT_WARNING, contentWarning); + } + */ +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.java b/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.java new file mode 100644 index 000000000..f4989ec3b --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.java @@ -0,0 +1,84 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +import com.hippo.okhttp.CookieDB; +import com.hippo.okhttp.CookieDBStore; + +import java.util.ArrayList; +import java.util.List; + +import okhttp3.Cookie; +import okhttp3.HttpUrl; + +public class EhCookieStore extends CookieDBStore { + + private static final String KEY_IPD_MEMBER_ID = "ipb_member_id"; + private static final String KEY_IPD_PASS_HASH = "ipb_pass_hash"; + private static final String KEY_YAY = "yay"; + + public void cleanUpForSignIn() { + remove(EhUrl.HOST_E, KEY_IPD_MEMBER_ID); + remove(EhUrl.HOST_E, KEY_IPD_PASS_HASH); + remove(EhUrl.HOST_EX, KEY_IPD_MEMBER_ID); + remove(EhUrl.HOST_EX, KEY_IPD_PASS_HASH); + remove(EhUrl.HOST_EX, KEY_YAY); + } + + public boolean hasSignedIn() { + return contain(EhUrl.HOST_EX, KEY_IPD_MEMBER_ID) && + contain(EhUrl.HOST_EX, KEY_IPD_PASS_HASH); + } + + private Cookie newCookie(Cookie cookie, String newDomain) { + Cookie.Builder builder = new Cookie.Builder(); + builder.name(cookie.name()); + builder.value(cookie.value()); + if (cookie.persistent()) { + builder.expiresAt(cookie.expiresAt()); + } + if (cookie.hostOnly()) { + builder.hostOnlyDomain(newDomain); + } else { + builder.domain(newDomain); + } + builder.path(cookie.path()); + if (cookie.secure()) { + builder.secure(); + } + if (cookie.httpOnly()) { + builder.httpOnly(); + } + return builder.build(); + } + + @Override + public void saveFromResponse(HttpUrl url, List cookies) { + List cs = new ArrayList<>(cookies.size() + 2); + + for (Cookie cookie: cookies) { + cs.add(cookie); + if (EhUrl.HOST_E.equals(cookie.domain()) && + (KEY_IPD_MEMBER_ID.equals(cookie.name()) || + KEY_IPD_PASS_HASH.equals(cookie.name()))) { + cs.add(newCookie(cookie, CookieDB.cookiesDomain(EhUrl.HOST_EX))); + } + } + + super.saveFromResponse(url, cs); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhEngine.java b/app/src/main/java/com/hippo/ehviewer/client/EhEngine.java new file mode 100644 index 000000000..47e82cbbd --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhEngine.java @@ -0,0 +1,85 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +import android.util.Log; + +import com.hippo.ehviewer.client.parser.SignInParser; +import com.hippo.network.StatusCodeException; + +import okhttp3.Call; +import okhttp3.FormBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class EhEngine { + + private static final String TAG = EhEngine.class.getSimpleName(); + + public static final String UNKNOWN = "Unknown"; + + private static void throwException(Call call, int code, String body, Exception e) throws Exception { + if (call.isCanceled()) { + throw new CancelledException(); + } + + if (e instanceof EhException) { + if (!UNKNOWN.equals(e.getMessage())) { + throw e; + } + } + + if (!body.contains("<")) { + throw new EhException(body); + } + + if (code >= 400) { + throw new StatusCodeException(code); + } + } + + public static Call prepareSignIn(OkHttpClient okHttpClient, String username, String password) { + FormBody.Builder builder = new FormBody.Builder() + .add("UserName", username) + .add("PassWord", password) + .add("submit", "Log me in") + .add("CookieDate", "1") + .add("temporary_https", "off"); + + String url = EhUrl.API_SIGN_IN; + Log.d(TAG, url); + Request request = new EhRequestBuilder(url) + .post(builder.build()) + .build(); + return okHttpClient.newCall(request); + } + + public static String doSignIn(Call call) throws Exception { + String body = null; + int code = -1; + try { + Response response = call.execute(); + code = response.code(); + body = response.body().string(); + return SignInParser.parse(body); + } catch (Exception e) { + throwException(call, code, body, e); + throw e; + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhException.java b/app/src/main/java/com/hippo/ehviewer/client/EhException.java new file mode 100644 index 000000000..fa090b06e --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhException.java @@ -0,0 +1,24 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +public class EhException extends Exception { + + public EhException(String detailMessage) { + super(detailMessage); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhRequest.java b/app/src/main/java/com/hippo/ehviewer/client/EhRequest.java new file mode 100644 index 000000000..cb75480cf --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhRequest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +public class EhRequest { + + int method; + Object[] args; + EhClient.Callback callback; + + EhClient.Task task; + + private boolean mCancel = false; + + public EhRequest setMethod(int method) { + this.method = method; + return this; + } + + public EhRequest setArgs(Object... args) { + this.args = args; + return this; + } + + public EhRequest setCallback(EhClient.Callback callback) { + this.callback = callback; + return this; + } + + public void cancel() { + if (!mCancel) { + mCancel = true; + if (task != null) { + task.stop(); + task = null; + } + } + } + + public boolean isCancelled() { + return mCancel; + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.java b/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.java new file mode 100644 index 000000000..78a533ef7 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +import com.hippo.okhttp.ChromeRequestBuilder; + +public class EhRequestBuilder extends ChromeRequestBuilder { + + public EhRequestBuilder(String url) { + super(url); + } + + + +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhUrl.java b/app/src/main/java/com/hippo/ehviewer/client/EhUrl.java new file mode 100644 index 000000000..d8b15f256 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhUrl.java @@ -0,0 +1,30 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +public class EhUrl { + + public static final String HOST_EX = "exhentai.org"; + public static final String HOST_E = "e-hentai.org"; + + public static final String API_SIGN_IN = "http://forums.e-hentai.org/index.php?act=Login&CODE=01"; + + + + + +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhUtils.java b/app/src/main/java/com/hippo/ehviewer/client/EhUtils.java new file mode 100644 index 000000000..54afdacbc --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/EhUtils.java @@ -0,0 +1,30 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client; + +import android.content.Context; + +import com.hippo.ehviewer.EhApplication; +import com.hippo.ehviewer.Settings; + +public class EhUtils { + + public static boolean hasSignedIn(Context context) { + return EhApplication.getEhCookieStore(context).hasSignedIn() && + Settings.getDisplayName() != null; + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/client/parser/SignInParser.java b/app/src/main/java/com/hippo/ehviewer/client/parser/SignInParser.java new file mode 100644 index 000000000..99df7c1af --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/client/parser/SignInParser.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.client.parser; + +import com.hippo.ehviewer.client.EhEngine; +import com.hippo.ehviewer.client.EhException; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class SignInParser { + + private static final Pattern NAME_PATTERN = Pattern.compile("

You are now logged in as: (.+?)<"); + private static final Pattern ERROR_PATTERN = Pattern.compile( + "(?:

The error returned was:

\\s*

(.+?)

)" + + "|(?:(.+?))"); + + public static String parse(String body) throws Exception { + Matcher m = NAME_PATTERN.matcher(body); + if (m.find()) { + return m.group(1); + } else { + m = ERROR_PATTERN.matcher(body); + if (m.find()) { + throw new EhException(m.group(1) == null ? m.group(2) : m.group(1)); + } else { + throw new EhException(EhEngine.UNKNOWN); + } + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/MainActivity.java b/app/src/main/java/com/hippo/ehviewer/ui/MainActivity.java new file mode 100644 index 000000000..82df376db --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/MainActivity.java @@ -0,0 +1,100 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.ui; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.NavigationView; +import android.support.v4.widget.DrawerLayout; +import android.view.MenuItem; + +import com.hippo.ehviewer.R; +import com.hippo.ehviewer.Settings; +import com.hippo.ehviewer.client.EhUtils; +import com.hippo.ehviewer.ui.scene.GalleryListScene; +import com.hippo.ehviewer.ui.scene.LoginScene; +import com.hippo.ehviewer.ui.scene.WarningScene; +import com.hippo.scene.StageActivity; + +public final class MainActivity extends StageActivity + implements NavigationView.OnNavigationItemSelectedListener { + + private DrawerLayout mDrawerLayout; + + @Override + public int getContainerViewId() { + return R.id.fragment_container; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mDrawerLayout = (DrawerLayout) findViewById(R.id.draw_view); + + NavigationView navView = (NavigationView) findViewById(R.id.nav_view); + navView.setNavigationItemSelectedListener(this); + + if (savedInstanceState == null) { + onInit(); + } + } + + public void setDrawerLayoutEnable(boolean enable) { + if (enable) { + mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); + } else { + mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + } + } + + private void onInit() { + if (Settings.getShowWarning()) { + setDrawerLayoutEnable(false); + startScene(WarningScene.class); + } else if (!EhUtils.hasSignedIn(this)) { + setDrawerLayoutEnable(false); + startScene(LoginScene.class); + } else { + setDrawerLayoutEnable(true); + startScene(GalleryListScene.class); + } + } + + @Override + public boolean onNavigationItemSelected(MenuItem item) { + // Handle navigation view item clicks here. + int id = item.getItemId(); + + if (id == R.id.nav_homepage) { + + } else if (id == R.id.nav_whats_hot) { + + } else if (id == R.id.nav_favourite) { + + } else if (id == R.id.nav_history) { + + } else if (id == R.id.nav_download) { + + } else if (id == R.id.nav_settings) { + + } + + return true; + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/BaseScene.java b/app/src/main/java/com/hippo/ehviewer/ui/scene/BaseScene.java new file mode 100644 index 000000000..ac4b42158 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/BaseScene.java @@ -0,0 +1,32 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.ui.scene; + +import android.support.v4.app.FragmentActivity; + +import com.hippo.ehviewer.ui.MainActivity; +import com.hippo.scene.SceneFragment; + +public class BaseScene extends SceneFragment { + + public void setDrawerLayoutEnable(boolean enable) { + FragmentActivity activity = getActivity(); + if (activity instanceof MainActivity) { + ((MainActivity) activity).setDrawerLayoutEnable(enable); + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java new file mode 100644 index 000000000..2f42329d5 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.java @@ -0,0 +1,42 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.ui.scene; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.hippo.ehviewer.R; + +public class GalleryListScene extends BaseScene { + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + + View view = inflater.inflate(R.layout.scene_gallery_list, container, false); + + + + + + return view; + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/LoginScene.java b/app/src/main/java/com/hippo/ehviewer/ui/scene/LoginScene.java new file mode 100644 index 000000000..40c9919c3 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/LoginScene.java @@ -0,0 +1,227 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.ui.scene; + +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Paint; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.TextInputLayout; +import android.support.v7.app.AlertDialog; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import com.hippo.ehviewer.EhApplication; +import com.hippo.ehviewer.R; +import com.hippo.ehviewer.Settings; +import com.hippo.ehviewer.client.EhClient; +import com.hippo.ehviewer.client.EhCookieStore; +import com.hippo.ehviewer.client.EhRequest; +import com.hippo.utils.ExceptionUtils; + +public final class LoginScene extends BaseScene implements EditText.OnEditorActionListener, + View.OnClickListener { + + private View mProgress; + private TextInputLayout mUsernameLayout; + private TextInputLayout mPasswordLayout; + private EditText mUsername; + private EditText mPassword; + private View mRegister; + private View mSignIn; + private TextView mSignInViaWebview; + private TextView mSignInViaCookies; + + private EhClient mClient; + private EhCookieStore mCookieStore; + private EhRequest mSignInRequest; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mClient = EhApplication.getEhClient(getContext()); + mCookieStore = EhApplication.getEhCookieStore(getContext()); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.scene_login, container, false); + + View loginForm = view.findViewById(R.id.login_form); + mProgress = view.findViewById(R.id.progress); + mUsernameLayout = (TextInputLayout) loginForm.findViewById(R.id.username_layout); + mUsername = mUsernameLayout.getEditText(); + mPasswordLayout = (TextInputLayout) loginForm.findViewById(R.id.password_layout); + mPassword = mPasswordLayout.getEditText(); + mRegister = loginForm.findViewById(R.id.register); + mSignIn = loginForm.findViewById(R.id.sign_in); + mSignInViaWebview = (TextView) loginForm.findViewById(R.id.sign_in_via_webview); + mSignInViaCookies = (TextView) loginForm.findViewById(R.id.sign_in_via_cookies); + + mSignInViaWebview.setPaintFlags(mSignInViaWebview.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + mSignInViaCookies.setPaintFlags(mSignInViaCookies.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + + mPassword.setOnEditorActionListener(this); + + mRegister.setOnClickListener(this); + mSignIn.setOnClickListener(this); + mSignInViaWebview.setOnClickListener(this); + mSignInViaCookies.setOnClickListener(this); + + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (mSignInRequest != null) { + mSignInRequest.cancel(); + mSignInRequest = null; + } + } + + @Override + public void onClick(View v) { + if (mRegister == v) { + + } else if (mSignIn == v) { + signIn(); + } else if (mSignInViaWebview == v) { + + } else if (mSignInViaCookies == v) { + + } + } + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (v == mPassword) { + if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) { + signIn(); + return true; + } + } + + return false; + } + + private void signIn() { + if (mSignInRequest != null) { + return; + } + + String username = mUsername.getText().toString(); + String password = mPassword.getText().toString(); + + if (username.isEmpty()) { + mUsernameLayout.setError(getString(R.string.error_username_cannot_empty)); + return; + } else { + mUsernameLayout.setError(null); + } + + if (password.isEmpty()) { + mPasswordLayout.setError(getString(R.string.error_password_cannot_empty)); + return; + } else { + mPasswordLayout.setError(null); + } + + // Hide ime keyboard + View view = getActivity().getCurrentFocus(); + if (view != null) { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + mProgress.setAlpha(0.0f); + mProgress.setVisibility(View.VISIBLE); + mProgress.animate().alpha(1.0f).setDuration(500).start(); + + // Clean up for sign in + mCookieStore.cleanUpForSignIn(); + + mSignInRequest = new EhRequest() + .setMethod(EhClient.METHOD_SIGN_IN) + .setArgs(username, password) + .setCallback(new EhClient.Callback() { + @Override + public void onSuccess(String result) { + mSignInRequest = null; + Settings.putDisplayName(result); + + if (mCookieStore.hasSignedIn()) { + // Has signed in + redirectTo(); + } else { + mProgress.setVisibility(View.GONE); + whetherToSkip(); + } + } + + @Override + public void onFailure(Exception e) { + mSignInRequest = null; + mProgress.setVisibility(View.GONE); + Toast.makeText(getContext(), ExceptionUtils.getReadableString(getContext(), e), + Toast.LENGTH_SHORT).show(); + whetherToSkip(); + } + + @Override + public void onCancel() { + mSignInRequest = null; + // User close this Scene + } + }); + mClient.execute(mSignInRequest); + } + + private void redirectTo() { + startScene(GalleryListScene.class); + finish(); + // Enable drawer + setDrawerLayoutEnable(true); + } + + private void whetherToSkip() { + new AlertDialog.Builder(getContext()) + .setTitle(R.string.skip_signing_in) + .setMessage(R.string.skip_signing_in_plain) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == AlertDialog.BUTTON_POSITIVE) { + redirectTo(); + } + } + }).show(); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/WarningScene.java b/app/src/main/java/com/hippo/ehviewer/ui/scene/WarningScene.java new file mode 100644 index 000000000..4decd19dc --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/WarningScene.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.ui.scene; + +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.hippo.ehviewer.R; +import com.hippo.ehviewer.Settings; +import com.hippo.ehviewer.client.EhUtils; +import com.hippo.vector.VectorDrawable; +import com.hippo.widget.SimpleImageView; + +public final class WarningScene extends BaseScene implements View.OnClickListener { + + private View mCancel; + private View mOk; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.scene_warning, container, false); + + SimpleImageView alert = (SimpleImageView) view.findViewById(R.id.icon_alert); + mCancel = view.findViewById(R.id.cancel); + mOk = view.findViewById(R.id.ok); + + Drawable drawable = VectorDrawable.create(getContext(), R.drawable.ic_alert); + alert.setDrawable(drawable); + + mCancel.setOnClickListener(this); + mOk.setOnClickListener(this); + + return view; + } + + @Override + public void onClick(View v) { + if (mCancel == v) { + finishStage(); + } else if (mOk == v) { + // Never show this warning anymore + Settings.putShowWarning(false); + + if (EhUtils.hasSignedIn(getContext())) { + startScene(GalleryListScene.class); + finish(); + // Enable drawer + setDrawerLayoutEnable(true); + } else { + startScene(LoginScene.class); + finish(); + } + } + } +} diff --git a/app/src/main/java/com/hippo/network/StatusCodeException.java b/app/src/main/java/com/hippo/network/StatusCodeException.java new file mode 100644 index 000000000..d495fcb40 --- /dev/null +++ b/app/src/main/java/com/hippo/network/StatusCodeException.java @@ -0,0 +1,90 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.network; + +import android.content.Context; +import android.content.res.Resources; +import android.util.SparseArray; + +import com.hippo.ehviewer.R; + +public class StatusCodeException extends Exception { + + private static final SparseArray ERROR_MESSAGE_ARRAY = new SparseArray<>(24); + private static final String DEFAULT_ERROR_MESSAGE = "Error response code"; + + private final int mResponseCode; + private final String mMessage; + + public static void initialize(Context context) { + Resources resources = context.getApplicationContext().getResources(); + + ERROR_MESSAGE_ARRAY.append(400, resources.getString(R.string.error_status_code_400)); + ERROR_MESSAGE_ARRAY.append(401, resources.getString(R.string.error_status_code_401)); + ERROR_MESSAGE_ARRAY.append(402, resources.getString(R.string.error_status_code_402)); + ERROR_MESSAGE_ARRAY.append(403, resources.getString(R.string.error_status_code_403)); + ERROR_MESSAGE_ARRAY.append(404, resources.getString(R.string.error_status_code_404)); + ERROR_MESSAGE_ARRAY.append(405, resources.getString(R.string.error_status_code_405)); + ERROR_MESSAGE_ARRAY.append(406, resources.getString(R.string.error_status_code_406)); + ERROR_MESSAGE_ARRAY.append(407, resources.getString(R.string.error_status_code_407)); + ERROR_MESSAGE_ARRAY.append(408, resources.getString(R.string.error_status_code_408)); + ERROR_MESSAGE_ARRAY.append(409, resources.getString(R.string.error_status_code_409)); + ERROR_MESSAGE_ARRAY.append(410, resources.getString(R.string.error_status_code_410)); + ERROR_MESSAGE_ARRAY.append(411, resources.getString(R.string.error_status_code_411)); + ERROR_MESSAGE_ARRAY.append(412, resources.getString(R.string.error_status_code_412)); + ERROR_MESSAGE_ARRAY.append(413, resources.getString(R.string.error_status_code_413)); + ERROR_MESSAGE_ARRAY.append(414, resources.getString(R.string.error_status_code_414)); + ERROR_MESSAGE_ARRAY.append(415, resources.getString(R.string.error_status_code_415)); + ERROR_MESSAGE_ARRAY.append(416, resources.getString(R.string.error_status_code_416)); + ERROR_MESSAGE_ARRAY.append(417, resources.getString(R.string.error_status_code_417)); + + ERROR_MESSAGE_ARRAY.append(500, resources.getString(R.string.error_status_code_500)); + ERROR_MESSAGE_ARRAY.append(501, resources.getString(R.string.error_status_code_501)); + ERROR_MESSAGE_ARRAY.append(502, resources.getString(R.string.error_status_code_502)); + ERROR_MESSAGE_ARRAY.append(503, resources.getString(R.string.error_status_code_503)); + ERROR_MESSAGE_ARRAY.append(504, resources.getString(R.string.error_status_code_504)); + ERROR_MESSAGE_ARRAY.append(505, resources.getString(R.string.error_status_code_505)); + } + + public StatusCodeException(int responseCode) { + mResponseCode = responseCode; + mMessage = ERROR_MESSAGE_ARRAY.get(responseCode, DEFAULT_ERROR_MESSAGE); + } + + public StatusCodeException(int responseCode, String message) { + mResponseCode = responseCode; + mMessage = message; + } + + public int getResponseCode() { + return mResponseCode; + } + + public boolean isIdentifiedResponseCode() { + return !DEFAULT_ERROR_MESSAGE.equals(mMessage); + } + + @Override + public String getMessage() { + return mMessage; + } + + @Override + public String getLocalizedMessage() { + return getMessage(); + } +} diff --git a/app/src/main/java/com/hippo/okhttp/ChromeRequestBuilder.java b/app/src/main/java/com/hippo/okhttp/ChromeRequestBuilder.java new file mode 100644 index 000000000..e63302720 --- /dev/null +++ b/app/src/main/java/com/hippo/okhttp/ChromeRequestBuilder.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.okhttp; + +import java.net.MalformedURLException; +import java.net.URL; + +import okhttp3.Request; + +public class ChromeRequestBuilder extends Request.Builder { + + private static final String CHROME_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36"; + + public ChromeRequestBuilder(String url) { + URL u; + try { + u = new URL(url); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } + + url(u); + + addHeader("User-Agent", CHROME_USER_AGENT); + } +} diff --git a/app/src/main/java/com/hippo/okhttp/CookieDB.java b/app/src/main/java/com/hippo/okhttp/CookieDB.java new file mode 100644 index 000000000..e9ad0ef8b --- /dev/null +++ b/app/src/main/java/com/hippo/okhttp/CookieDB.java @@ -0,0 +1,158 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.okhttp; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import com.hippo.okhttp.dao.DaoMaster; +import com.hippo.okhttp.dao.DaoSession; +import com.hippo.okhttp.dao.OkHttp3CookieDao; +import com.hippo.okhttp.dao.OkHttp3CookieRaw; + +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import okhttp3.Cookie; + +public class CookieDB { + + private static DaoSession sDaoSession; + + public static class DBOpenHelper extends DaoMaster.OpenHelper { + + public DBOpenHelper(Context context, String name, SQLiteDatabase.CursorFactory factory) { + super(context, name, factory); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + } + } + + public static void initialize(Context context) { + DBOpenHelper helper = new DBOpenHelper( + context.getApplicationContext(), "okhttp3-cookie.db", null); + + SQLiteDatabase db = helper.getWritableDatabase(); + DaoMaster daoMaster = new DaoMaster(db); + + sDaoSession = daoMaster.newSession(); + } + + public static String cookiesDomain(URL url) { + return cookiesDomain(url.getHost()); + } + + public static String cookiesDomain(String domain) { + if (TextUtils.isEmpty(domain)) { + return ""; + } + + int count = 0; + for (int i = domain.length() - 1; i >= 0; i--) { + char ch = domain.charAt(i); + if (ch == '.') { + count++; + } + + if (count == 2) { + return domain.substring(i + 1); + } + } + + return domain; + } + + public static Map> getAllCookies() { + OkHttp3CookieDao dao = sDaoSession.getOkHttp3CookieDao(); + List list = dao.queryBuilder().list(); + + long now = System.currentTimeMillis(); + Map> result = new HashMap<>(); + for (OkHttp3CookieRaw raw : list) { + // Check expired + if (!raw.getPersistent() || raw.getExpiresAt() <= now) { + dao.delete(raw); + continue; + } + + // Avoid null error + String name = raw.getName(); + String value = raw.getValue(); + String domain = raw.getDomain(); + if (name == null || value == null || domain == null) { + dao.delete(raw); + continue; + } + + Cookie.Builder builder = new Cookie.Builder(); + builder.name(name); + builder.value(value); + builder.expiresAt(raw.getExpiresAt()); + if (raw.getHostOnly()) { + builder.hostOnlyDomain(domain); + } else { + builder.domain(raw.getDomain()); + } + builder.path(raw.getPath()); + if (raw.getSecure()) { + builder.secure(); + } + if (raw.getHttpOnly()) { + builder.httpOnly(); + } + + String cookiesDomain = cookiesDomain(domain); + + List cookies = result.get(cookiesDomain); + if (cookies == null) { + cookies = new ArrayList<>(); + result.put(cookiesDomain, cookies); + } + + cookies.add(new CookieWithID(raw.getId(), builder.build())); + } + + return result; + } + + public static void removeCookie(long id) { + sDaoSession.getOkHttp3CookieDao().deleteByKey(id); + } + + public static long addCookie(Cookie cookie) { + OkHttp3CookieDao dao = sDaoSession.getOkHttp3CookieDao(); + + OkHttp3CookieRaw raw = new OkHttp3CookieRaw(); + raw.setName(cookie.name()); + raw.setValue(cookie.value()); + raw.setExpiresAt(cookie.expiresAt()); + raw.setDomain(cookie.domain()); + raw.setPath(cookie.path()); + raw.setSecure(cookie.secure()); + raw.setHttpOnly(cookie.httpOnly()); + raw.setPersistent(cookie.persistent()); + raw.setHostOnly(cookie.hostOnly()); + + return dao.insert(raw); + } +} diff --git a/app/src/main/java/com/hippo/okhttp/CookieDBStore.java b/app/src/main/java/com/hippo/okhttp/CookieDBStore.java new file mode 100644 index 000000000..1db49a167 --- /dev/null +++ b/app/src/main/java/com/hippo/okhttp/CookieDBStore.java @@ -0,0 +1,176 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.okhttp; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import okhttp3.Cookie; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; +import okhttp3.Request; + +public class CookieDBStore implements CookieJar { + + private final Map> map; + + public CookieDBStore() { + map = CookieDB.getAllCookies(); + } + + private boolean hasExpired(Cookie cookie) { + return cookie.expiresAt() < System.currentTimeMillis(); + } + + private static CookieWithID removeCookie(List list, Cookie cookie) { + for (int i = 0, n = list.size(); i < n; i++) { + CookieWithID cwi = list.get(i); + if (cwi.cookie.name().equals(cookie.name())) { + list.remove(i); + return cwi; + } + } + + return null; + } + + private static CookieWithID removeCookie(List list, String name) { + for (int i = 0, n = list.size(); i < n; i++) { + CookieWithID cwi = list.get(i); + if (cwi.cookie.name().equals(name)) { + list.remove(i); + return cwi; + } + } + + return null; + } + + private boolean containInternal(String domain, String name) { + List cookies = map.get(domain); + if (cookies != null) { + for (int i = 0, n = cookies.size(); i < n; i++) { + CookieWithID cwi = cookies.get(i); + if (cwi.cookie.name().equals(name)) { + return true; + } + } + } + return false; + } + + private synchronized void removeInternal(String domain, Cookie cookie) { + List cookies = map.get(domain); + if (cookies != null) { + // Remove from list + CookieWithID cwi = removeCookie(cookies, cookie); + if (cwi != null) { + // Remove from DB + CookieDB.removeCookie(cwi.id); + } + } + } + + private synchronized void removeInternal(String domain, String name) { + List cookies = map.get(domain); + if (cookies != null) { + // Remove from list + CookieWithID cwi = removeCookie(cookies, name); + if (cwi != null) { + // Remove from DB + CookieDB.removeCookie(cwi.id); + } + } + } + + private synchronized void addInternal(String domain, Cookie cookie) { + // Check expired + if (hasExpired(cookie)) { + removeInternal(domain, cookie); + return; + } + + List cookies = map.get(domain); + if (cookies == null) { + cookies = new ArrayList<>(); + map.put(domain, cookies); + } else { + // Remove from list + CookieWithID cwi = removeCookie(cookies, cookie); + if (cwi != null) { + // Remove from DB + CookieDB.removeCookie(cwi.id); + } + } + + // Add to DB + long id = CookieDB.addCookie(cookie); + // Add to list + cookies.add(new CookieWithID(id, cookie)); + } + + public boolean contain(String url, String name) { + return containInternal(CookieDB.cookiesDomain(url), name); + } + + public void remove(String url, String name) { + removeInternal(CookieDB.cookiesDomain(url), name); + } + + public void add(Cookie cookie) { + addInternal(CookieDB.cookiesDomain(cookie.domain()), cookie); + } + + public List get(HttpUrl url) { + List result = new ArrayList<>(); + + String domain = CookieDB.cookiesDomain(url.url()); + List cookies = map.get(domain); + if (cookies != null) { + for (Iterator i = cookies.iterator(); i.hasNext();) { + CookieWithID cwi = i.next(); + Cookie cookie = cwi.cookie; + // Check expired + if (hasExpired(cookie)) { + // Remove from list + i.remove(); + // Remove from DB + CookieDB.removeCookie(cwi.id); + } else if (cookie.matches(url)) { + result.add(cookie); + } + } + } + + return Collections.unmodifiableList(result); + } + + @Override + public void saveFromResponse(HttpUrl url, List cookies) { + for (Cookie cookie: cookies) { + add(cookie); + } + } + + @Override + public List loadForRequest(HttpUrl url, Request request) { + return get(url); + } +} diff --git a/app/src/main/java/com/hippo/okhttp/CookieWithID.java b/app/src/main/java/com/hippo/okhttp/CookieWithID.java new file mode 100644 index 000000000..dd65282ad --- /dev/null +++ b/app/src/main/java/com/hippo/okhttp/CookieWithID.java @@ -0,0 +1,30 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.okhttp; + +import okhttp3.Cookie; + +public class CookieWithID { + + public long id; + public Cookie cookie; + + public CookieWithID(long id, Cookie cookie) { + this.id = id; + this.cookie = cookie; + } +} diff --git a/app/src/main/java/com/hippo/scene/SceneFragment.java b/app/src/main/java/com/hippo/scene/SceneFragment.java new file mode 100644 index 000000000..75d93dc8d --- /dev/null +++ b/app/src/main/java/com/hippo/scene/SceneFragment.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.scene; + +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; + +public class SceneFragment extends Fragment { + + public void startScene(Class clazz) { + FragmentActivity activity = getActivity(); + if (activity instanceof StageActivity) { + ((StageActivity) activity).startScene(clazz); + } + } + + public void finish() { + FragmentActivity activity = getActivity(); + if (activity instanceof StageActivity) { + ((StageActivity) activity).finishScene(this); + } + } + + public void finishStage() { + FragmentActivity activity = getActivity(); + if (activity != null) { + activity.finish(); + } + } +} diff --git a/app/src/main/java/com/hippo/scene/StageActivity.java b/app/src/main/java/com/hippo/scene/StageActivity.java new file mode 100644 index 000000000..80f61ca3b --- /dev/null +++ b/app/src/main/java/com/hippo/scene/StageActivity.java @@ -0,0 +1,163 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.scene; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; + +import com.hippo.ehviewer.R; +import com.hippo.yorozuya.IdIntGenerator; + +import java.util.ArrayList; +import java.util.Collections; + +public abstract class StageActivity extends AppCompatActivity { + + private static final String TAG = StageActivity.class.getSimpleName(); + + private static final String KEY_STAGE_ACTIVITY_SCENE_TAG_LIST = "stage_activity_scene_tag_list"; + + // TODO ArrayList or LinkedList + private ArrayList mSceneTagList = new ArrayList<>(); + private IdIntGenerator mIdGenerator = new IdIntGenerator(); + + public abstract int getContainerViewId(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + private SceneFragment newSceneInstance(Class clazz) { + try { + return clazz.newInstance(); + } catch (InstantiationException e) { + throw new IllegalStateException("Can't instance " + clazz.getName(), e); + } catch (IllegalAccessException e) { + throw new IllegalStateException("The constructor of " + + clazz.getName() + " is not visible", e); + } catch (ClassCastException e) { + throw new IllegalStateException(clazz.getName() + " can not cast to scene", e); + } + } + + public void startScene(Class clazz) { + startScene(clazz, null); + } + + public void startScene(Class clazz, Bundle args) { + FragmentManager fragmentManager = getSupportFragmentManager(); + + // Old scene + Fragment old = null; + if (mSceneTagList.size() > 0) { + // Get last tag + String tag = mSceneTagList.get(mSceneTagList.size() - 1); + old = fragmentManager.findFragmentByTag(tag); + } + + // New scene + SceneFragment scene = newSceneInstance(clazz); + scene.setArguments(args); + // New scene tag + String newTag = Integer.toString(mIdGenerator.nextId()); + mSceneTagList.add(newTag); + + FragmentTransaction transaction = fragmentManager.beginTransaction(); + transaction.setCustomAnimations(R.anim.fragment_translate_in, R.anim.fragment_translate_out); + if (old != null) { + transaction.detach(old); + } + transaction.add(getContainerViewId(), scene, newTag); + transaction.commit(); + } + + public void finishScene(SceneFragment scene) { + finishScene(scene.getTag()); + } + + + private void finishScene(String tag) { + FragmentManager fragmentManager = getSupportFragmentManager(); + + // Get scene + Fragment scene = fragmentManager.findFragmentByTag(tag); + if (scene == null) { + Log.e(TAG, "finishScene: Can't find scene by tag: " + tag); + return; + } + + // FIXME It will fail if mIdGenerator.nextId() called more than Integer.MAX_VALUE times + // Get scene index + int index = Collections.binarySearch(mSceneTagList, tag); + if (index < 0) { + Log.e(TAG, "finishScene: Can't find the tag in tag list: " + tag); + return; + } + + if (mSceneTagList.size() == 1) { + // It is the last fragment, finish Activity now + Log.i(TAG, "finishScene: It is the last scene, finish activity now"); + finish(); + return; + } + + Fragment next = null; + // It is first fragment, show the next one + if (index == mSceneTagList.size() - 1) { + next = fragmentManager.findFragmentByTag(mSceneTagList.get(index - 1)); + } + + FragmentTransaction transaction = fragmentManager.beginTransaction(); + transaction.setCustomAnimations(R.anim.fragment_translate_in, R.anim.fragment_translate_out); + transaction.remove(scene); + if (next != null && next.isDetached()) { + transaction.attach(next); + } + transaction.commit(); + + // Remove tag + mSceneTagList.remove(index); + } + + @Override + public void onBackPressed() { + int size = mSceneTagList.size(); + if (size > 0) { + finishScene(mSceneTagList.get(size - 1)); + } else { + finish(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putStringArrayList(KEY_STAGE_ACTIVITY_SCENE_TAG_LIST, mSceneTagList); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + mSceneTagList = savedInstanceState.getStringArrayList(KEY_STAGE_ACTIVITY_SCENE_TAG_LIST); + } +} diff --git a/app/src/main/java/com/hippo/utils/ExceptionUtils.java b/app/src/main/java/com/hippo/utils/ExceptionUtils.java new file mode 100644 index 000000000..580e8eaff --- /dev/null +++ b/app/src/main/java/com/hippo/utils/ExceptionUtils.java @@ -0,0 +1,63 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.utils; + + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.hippo.ehviewer.R; +import com.hippo.ehviewer.client.EhException; +import com.hippo.network.StatusCodeException; + +import org.apache.http.conn.ConnectTimeoutException; + +import java.net.ProtocolException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; + +public final class ExceptionUtils { + + private static final String TAG = ExceptionUtils.class.getSimpleName(); + + @NonNull + public static String getReadableString(@NonNull Context context, @NonNull Exception e) { + if (e instanceof ConnectTimeoutException || + e instanceof SocketTimeoutException) { + return context.getString(R.string.error_timeout); + } else if (e instanceof UnknownHostException) { + return context.getString(R.string.error_unknown_host); + } else if (e instanceof StatusCodeException) { + StatusCodeException sce = (StatusCodeException) e; + StringBuilder sb = new StringBuilder(); + sb.append(context.getString(R.string.error_bad_status_code, sce.getResponseCode())); + if (sce.isIdentifiedResponseCode()) { + sb.append(", ").append(sce.getMessage()); + } + return sb.toString(); + } else if (e instanceof ProtocolException && e.getMessage().startsWith("Too many follow-up requests:")) { + return context.getString(R.string.error_redirection); + } else if (e instanceof ProtocolException || e instanceof SocketException) { + return context.getString(R.string.error_socket); + } else if (e instanceof EhException) { + return e.getMessage(); + } else { + return context.getString(R.string.error_unknown); + } + } +} diff --git a/app/src/main/java/com/hippo/widget/MaxSizeContainer.java b/app/src/main/java/com/hippo/widget/MaxSizeContainer.java new file mode 100644 index 000000000..ee8012b5e --- /dev/null +++ b/app/src/main/java/com/hippo/widget/MaxSizeContainer.java @@ -0,0 +1,93 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import com.hippo.ehviewer.R; +import com.hippo.yorozuya.AssertUtils; + +public class MaxSizeContainer extends ViewGroup { + + private int mMaxWidth; + private int mMaxHeight; + + public MaxSizeContainer(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public MaxSizeContainer(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxSizeContainer); + mMaxWidth = a.getDimensionPixelOffset(R.styleable.MaxSizeContainer_maxWidth, -1); + mMaxHeight = a.getDimensionPixelOffset(R.styleable.MaxSizeContainer_maxHeight, -1); + a.recycle(); + } + + private int getMeasureSpec(int measureSpec, int max) { + if (max < 0) { + return measureSpec; + } + + int size = MeasureSpec.getSize(measureSpec); + int mode = MeasureSpec.getMode(measureSpec); + + switch (mode) { + case MeasureSpec.AT_MOST: + size = Math.min(size, max); + break; + case MeasureSpec.UNSPECIFIED: + size = max; + mode = MeasureSpec.AT_MOST; + break; + } + return MeasureSpec.makeMeasureSpec(size, mode); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + AssertUtils.assertEquals("getChildCount() must be 1", 1, getChildCount()); + + View child = getChildAt(0); + if (child.getVisibility() != GONE) { + child.measure(getMeasureSpec(widthMeasureSpec, mMaxWidth), + getMeasureSpec(heightMeasureSpec, mMaxHeight)); + setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight()); + } else { + setMeasuredDimension(0, 0); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + AssertUtils.assertEquals("getChildCount() must be 1", 1, getChildCount()); + + View child = getChildAt(0); + if (child.getVisibility() != GONE) { + child.layout(0, 0, r - l, b - t); + } + } +} diff --git a/app/src/main/java/com/hippo/widget/ProgressView.java b/app/src/main/java/com/hippo/widget/ProgressView.java new file mode 100644 index 000000000..221d4747d --- /dev/null +++ b/app/src/main/java/com/hippo/widget/ProgressView.java @@ -0,0 +1,317 @@ +/* + * Copyright 2015 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; + +import com.hippo.anani.PathInterpolator; +import com.hippo.ehviewer.R; +import com.hippo.yorozuya.ViewUtils; + +import java.util.ArrayList; + +// Base on android.graphics.drawable.MaterialProgressDrawable in L preview +public class ProgressView extends View { + + private static final Interpolator TRIM_START_INTERPOLATOR; + private static final Interpolator TRIM_END_INTERPOLATOR; + private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); + + static { + Path trimStartPath = new Path(); + trimStartPath.moveTo(0.0f, 0.0f); + trimStartPath.lineTo(0.5f, 0.0f); + trimStartPath.cubicTo(0.7f, 0.0f, 0.6f, 1f, 1f, 1f); + TRIM_START_INTERPOLATOR = PathInterpolator.build(trimStartPath); + + Path trimEndPath = new Path(); + trimEndPath.moveTo(0.0f, 0.0f); + trimEndPath.cubicTo(0.2f, 0.0f, 0.1f, 1f, 0.5f, 1f); + trimEndPath.lineTo(1f, 1f); + TRIM_END_INTERPOLATOR = PathInterpolator.build(trimEndPath); + } + + private final ArrayList mAnimators = new ArrayList<>(); + private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF mRectF = new RectF(); + + private boolean mIndeterminate; + + private float mTrimStart = 0.0f; + private float mTrimEnd = 0.0f; + private float mTrimOffset = 0.0f; + private float mTrimRotation = 0.0f; + + // It is a trick to avoid first sight stuck. Get it from ProgressBar + private boolean mShouldStartAnimationDrawable = false; + + public ProgressView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public ProgressView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ProgressView); + int color = a.getColor(R.styleable.ProgressView_color, Color.BLACK); + mPaint.setColor(color); + mPaint.setStrokeCap(Paint.Cap.SQUARE); + mPaint.setStrokeJoin(Paint.Join.MITER); + mPaint.setStyle(Paint.Style.STROKE); + mIndeterminate = a.getBoolean(R.styleable.ProgressView_indeterminate, true); + setProgress(a.getFloat(R.styleable.ProgressView_progress, 0f)); + a.recycle(); + + setupAnimators(); + } + + private void setupAnimators() { + ObjectAnimator trimStart = ObjectAnimator.ofFloat(this, "trimStart", 0.0f, 0.75f); + trimStart.setDuration(1333L); + trimStart.setInterpolator(TRIM_START_INTERPOLATOR); + trimStart.setRepeatCount(Animation.INFINITE); + + ObjectAnimator trimEnd = ObjectAnimator.ofFloat(this, "trimEnd", 0.0f, 0.75f); + trimEnd.setDuration(1333L); + trimEnd.setInterpolator(TRIM_END_INTERPOLATOR); + trimEnd.setRepeatCount(Animation.INFINITE); + + ObjectAnimator trimOffset = ObjectAnimator.ofFloat(this, "trimOffset", 0.0f, 0.25f); + trimOffset.setDuration(1333L); + trimOffset.setInterpolator(LINEAR_INTERPOLATOR); + trimOffset.setRepeatCount(Animation.INFINITE); + + ObjectAnimator trimRotation = ObjectAnimator.ofFloat(this, "trimRotation", 0.0f, 720.0f); + trimRotation.setDuration(6665L); + trimRotation.setInterpolator(LINEAR_INTERPOLATOR); + trimRotation.setRepeatCount(Animation.INFINITE); + + mAnimators.add(trimStart); + mAnimators.add(trimEnd); + mAnimators.add(trimOffset); + mAnimators.add(trimRotation); + } + + private void startAnimation() { + mShouldStartAnimationDrawable = true; + postInvalidate(); + } + + private void startAnimationActually() { + ArrayList animators = mAnimators; + int N = animators.size(); + for (int i = 0; i < N; i++) { + Animator animator = animators.get(i); + if (!animator.isRunning()) { + animator.start(); + } + } + } + + private void stopAnimation() { + mShouldStartAnimationDrawable = false; + ArrayList animators = mAnimators; + int N = animators.size(); + for (int i = 0; i < N; i++) { + animators.get(i).cancel(); + } + } + + public boolean isRunning() { + ArrayList animators = mAnimators; + int N = animators.size(); + for (int i = 0; i < N; i++) { + Animator animator = animators.get(i); + if (animator.isRunning()) { + return true; + } + } + return false; + } + + @SuppressWarnings("unused") + public float getTrimStart() { + return mTrimStart; + } + + @SuppressWarnings("unused") + public void setTrimStart(float trimStart) { + mTrimStart = trimStart; + invalidate(); + } + + @SuppressWarnings("unused") + public float getTrimEnd() { + return mTrimEnd; + } + + @SuppressWarnings("unused") + public void setTrimEnd(float trimEnd) { + mTrimEnd = trimEnd; + invalidate(); + } + + @SuppressWarnings("unused") + public float getTrimOffset() { + return mTrimOffset; + } + + @SuppressWarnings("unused") + public void setTrimOffset(float trimOffset) { + mTrimOffset = trimOffset; + invalidate(); + } + + @SuppressWarnings("unused") + public float getTrimRotation() { + return mTrimRotation; + } + + @SuppressWarnings("unused") + public void setTrimRotation(float trimRotation) { + mTrimRotation = trimRotation; + invalidate(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (mIndeterminate && getVisibility() == VISIBLE) { + startAnimation(); + } + } + + @Override + protected void onDetachedFromWindow() { + if (mIndeterminate) { + stopAnimation(); + } + // This should come after stopAnimation(), otherwise an invalidate message remains in the + // queue, which can prevent the entire view hierarchy from being GC'ed during a rotation + super.onDetachedFromWindow(); + } + + + @Override + public void setVisibility(int v) { + if (getVisibility() != v) { + super.setVisibility(v); + + if (mIndeterminate) { + if (v == GONE || v == INVISIBLE) { + stopAnimation(); + } else if (ViewCompat.isAttachedToWindow(this)) { + startAnimation(); + } + } + } + } + + @Override + protected void onVisibilityChanged(@NonNull View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + + if (mIndeterminate && ViewCompat.isAttachedToWindow(this)) { + if (visibility == GONE || visibility == INVISIBLE) { + stopAnimation(); + } else if (ViewCompat.isAttachedToWindow(this)) { + startAnimation(); + } + } + } + + public void setColor(int color) { + mPaint.setColor(color); + invalidate(); + } + + public void setIndeterminate(boolean indeterminate) { + if (mIndeterminate != indeterminate) { + mIndeterminate = indeterminate; + if (indeterminate) { + if (isShown() && ViewCompat.isAttachedToWindow(this)) { + startAnimation(); + } + } else { + stopAnimation(); + } + } + } + + public boolean isIndeterminate() { + return mIndeterminate; + } + + public void setProgress(float progress) { + if (!mIndeterminate) { + mTrimStart = 0f; + mTrimEnd = progress; + mTrimOffset = 0f; + mTrimRotation = 0f; + invalidate(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(ViewUtils.getSuitableSize(getSuggestedMinimumWidth(), widthMeasureSpec), + ViewUtils.getSuitableSize(getSuggestedMinimumHeight(), heightMeasureSpec)); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + mPaint.setStrokeWidth(Math.min(w, h) / 12.0f); + mRectF.set(0, 0, w, h); + mRectF.inset(w / 48.0f * 5.0f, h / 48.0f * 5.0f); + } + + @Override + protected void onDraw(Canvas canvas) { + int saved = canvas.save(); + canvas.rotate(mTrimRotation, mRectF.centerX(), mRectF.centerY()); + + float startAngle = (mTrimStart + mTrimOffset) * 360.0f - 90; + float sweepAngle = (mTrimEnd - mTrimStart) * 360.0f; + canvas.drawArc(mRectF, startAngle, sweepAngle, false, mPaint); + + canvas.restoreToCount(saved); + + if (mShouldStartAnimationDrawable) { + mShouldStartAnimationDrawable = false; + startAnimationActually(); + } + } +} diff --git a/app/src/main/java/com/hippo/widget/SimpleImageView.java b/app/src/main/java/com/hippo/widget/SimpleImageView.java new file mode 100644 index 000000000..03a6e595e --- /dev/null +++ b/app/src/main/java/com/hippo/widget/SimpleImageView.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2015 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.support.annotation.DrawableRes; +import android.support.annotation.NonNull; +import android.util.AttributeSet; +import android.view.View; + +import com.hippo.yorozuya.ViewUtils; + +public class SimpleImageView extends View implements Drawable.Callback { + + private Drawable mDrawable; + + private static final int[] LAYOUT_ATTRS = new int[] { + android.R.attr.src + }; + + public SimpleImageView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public SimpleImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); + setDrawable(a.getDrawable(0)); + a.recycle(); + + // Mostly drawable is VectorDrawable, it has cache already + setWillNotCacheDrawing(true); + } + + @SuppressWarnings("deprecation") + public void setDrawable(@DrawableRes int resId) { + setDrawable(getContext().getResources().getDrawable(resId)); + } + + public void setDrawable(Drawable drawable) { + if (mDrawable != null) { + mDrawable.setCallback(null); + } + mDrawable = drawable; + if (drawable != null) { + drawable.setCallback(this); + if (drawable.isStateful()) { + drawable.setState(getDrawableState()); + } + } + requestLayout(); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + Drawable d = mDrawable; + if (d != null && d.isStateful()) { + d.setState(getDrawableState()); + } + } + + @Override + public void invalidateDrawable(@NonNull Drawable who) { + invalidate(); + } + + @Override + protected int getSuggestedMinimumWidth() { + int superMinWidth = super.getSuggestedMinimumWidth(); + return (mDrawable == null) ? superMinWidth : + Math.max(superMinWidth, mDrawable.getMinimumWidth() + getPaddingLeft() + getPaddingRight()); + } + + @Override + protected int getSuggestedMinimumHeight() { + int superMinHeight = super.getSuggestedMinimumHeight(); + return (mDrawable == null) ? superMinHeight : + Math.max(superMinHeight, mDrawable.getMinimumHeight() + getPaddingTop() + getPaddingBottom()); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(ViewUtils.getSuitableSize(getSuggestedMinimumWidth(), widthMeasureSpec), + ViewUtils.getSuitableSize(getSuggestedMinimumHeight(), heightMeasureSpec)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + int width = right - left; + int height = bottom - top; + if (mDrawable != null) { + mDrawable.setBounds(getPaddingLeft(), getPaddingTop(), + width - getPaddingRight(), height - getPaddingBottom()); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (mDrawable != null) { + mDrawable.draw(canvas); + } + } +} diff --git a/app/src/main/res/anim/fragment_translate_in.xml b/app/src/main/res/anim/fragment_translate_in.xml new file mode 100644 index 000000000..003ed18d8 --- /dev/null +++ b/app/src/main/res/anim/fragment_translate_in.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/anim/fragment_translate_out.xml b/app/src/main/res/anim/fragment_translate_out.xml new file mode 100644 index 000000000..63a0f7cea --- /dev/null +++ b/app/src/main/res/anim/fragment_translate_out.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_download_x24.png b/app/src/main/res/drawable-hdpi/ic_download_x24.png new file mode 100644 index 000000000..96d89cb11 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_download_x24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_favourite_x24.png b/app/src/main/res/drawable-hdpi/ic_favourite_x24.png new file mode 100644 index 000000000..26b6c1eaa Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_favourite_x24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_history_x24.png b/app/src/main/res/drawable-hdpi/ic_history_x24.png new file mode 100644 index 000000000..4e68a5f0e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_history_x24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_homepage_x24.png b/app/src/main/res/drawable-hdpi/ic_homepage_x24.png new file mode 100644 index 000000000..22d5e8387 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_homepage_x24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_settings_x24.png b/app/src/main/res/drawable-hdpi/ic_settings_x24.png new file mode 100644 index 000000000..92e7ba5cd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_settings_x24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_whats_hot_x24.png b/app/src/main/res/drawable-hdpi/ic_whats_hot_x24.png new file mode 100644 index 000000000..53e580750 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_whats_hot_x24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_download_x24.png b/app/src/main/res/drawable-mdpi/ic_download_x24.png new file mode 100644 index 000000000..b3e41c7a1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_download_x24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_favourite_x24.png b/app/src/main/res/drawable-mdpi/ic_favourite_x24.png new file mode 100644 index 000000000..5fcf460a0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_favourite_x24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_history_x24.png b/app/src/main/res/drawable-mdpi/ic_history_x24.png new file mode 100644 index 000000000..eedaecc39 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_history_x24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_homepage_x24.png b/app/src/main/res/drawable-mdpi/ic_homepage_x24.png new file mode 100644 index 000000000..4dd2af96a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_homepage_x24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_settings_x24.png b/app/src/main/res/drawable-mdpi/ic_settings_x24.png new file mode 100644 index 000000000..874df7563 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_settings_x24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_whats_hot_x24.png b/app/src/main/res/drawable-mdpi/ic_whats_hot_x24.png new file mode 100644 index 000000000..b3cd25057 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_whats_hot_x24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_download_x24.png b/app/src/main/res/drawable-xhdpi/ic_download_x24.png new file mode 100644 index 000000000..7a36a7ae9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_download_x24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_favourite_x24.png b/app/src/main/res/drawable-xhdpi/ic_favourite_x24.png new file mode 100644 index 000000000..ae6cd2ca2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_favourite_x24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_history_x24.png b/app/src/main/res/drawable-xhdpi/ic_history_x24.png new file mode 100644 index 000000000..1b44122db Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_history_x24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_homepage_x24.png b/app/src/main/res/drawable-xhdpi/ic_homepage_x24.png new file mode 100644 index 000000000..4b9e18bcd Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_homepage_x24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_settings_x24.png b/app/src/main/res/drawable-xhdpi/ic_settings_x24.png new file mode 100644 index 000000000..0992ed732 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_settings_x24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_whats_hot_x24.png b/app/src/main/res/drawable-xhdpi/ic_whats_hot_x24.png new file mode 100644 index 000000000..81b111d5c Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_whats_hot_x24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_download_x24.png b/app/src/main/res/drawable-xxhdpi/ic_download_x24.png new file mode 100644 index 000000000..4f188204b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_download_x24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_favourite_x24.png b/app/src/main/res/drawable-xxhdpi/ic_favourite_x24.png new file mode 100644 index 000000000..1e2968eef Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_favourite_x24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_history_x24.png b/app/src/main/res/drawable-xxhdpi/ic_history_x24.png new file mode 100644 index 000000000..de7b9d661 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_history_x24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_homepage_x24.png b/app/src/main/res/drawable-xxhdpi/ic_homepage_x24.png new file mode 100644 index 000000000..9969bb120 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_homepage_x24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_x24.png b/app/src/main/res/drawable-xxhdpi/ic_settings_x24.png new file mode 100644 index 000000000..dc26f0abe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_x24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_whats_hot_x24.png b/app/src/main/res/drawable-xxhdpi/ic_whats_hot_x24.png new file mode 100644 index 000000000..85bb9facf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_whats_hot_x24.png differ diff --git a/app/src/main/res/drawable/ic_alert.xml b/app/src/main/res/drawable/ic_alert.xml new file mode 100644 index 000000000..cb8f4a9f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..98aa01f70 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,41 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_simple_fragment.xml b/app/src/main/res/layout/activity_simple_fragment.xml new file mode 100644 index 000000000..f28a9f194 --- /dev/null +++ b/app/src/main/res/layout/activity_simple_fragment.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/layout/nav_header_main.xml b/app/src/main/res/layout/nav_header_main.xml new file mode 100644 index 000000000..521e39abd --- /dev/null +++ b/app/src/main/res/layout/nav_header_main.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/layout/scene_gallery_list.xml b/app/src/main/res/layout/scene_gallery_list.xml new file mode 100644 index 000000000..6cd84bb92 --- /dev/null +++ b/app/src/main/res/layout/scene_gallery_list.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/layout/scene_login.xml b/app/src/main/res/layout/scene_login.xml new file mode 100644 index 000000000..6b7769d98 --- /dev/null +++ b/app/src/main/res/layout/scene_login.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/scene_warning.xml b/app/src/main/res/layout/scene_warning.xml new file mode 100644 index 000000000..afb2ba56f --- /dev/null +++ b/app/src/main/res/layout/scene_warning.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/nav_drawer_main.xml b/app/src/main/res/menu/nav_drawer_main.xml new file mode 100644 index 000000000..9551570ef --- /dev/null +++ b/app/src/main/res/menu/nav_drawer_main.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8a893abd9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..f0f4e7773 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..d2796e909 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..4fbfd0a0d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b90f8e51f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values-v21/themes.xml b/app/src/main/res/values-v21/themes.xml new file mode 100644 index 000000000..85dd461d6 --- /dev/null +++ b/app/src/main/res/values-v21/themes.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 000000000..2d1c78bfa --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,49 @@ + + + + + + EhViewer + + 主页 + 热门 + 收藏 + 历史 + 下载 + 设置 + + 用户名 + 密码 + + 登陆 + 注册 + 通过网页登陆 + 通过 Cookie 登陆 + + 错误的状态码:%d + 超时 + 未知主机 + 太多重定向 + 网络错误 + 奇怪的错误 + + 用户名不可为空 + 密码不可为空 + + 本应用中内容来自互联网,部分内容可能对您的生理及心理造成难以恢复的伤害。本应用作者不会对由本应用造成的后果负责。您是否愿意继续使用该应用? + + diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml new file mode 100644 index 000000000..340e204c7 --- /dev/null +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -0,0 +1,29 @@ + + + + + + EhViewer + + 主頁 + 熱門 + 收藏 + 歷史 + 下載 + 設置 + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 000000000..dd6914d0a --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,29 @@ + + + + + + EhViewer + + 主頁 + 熱門 + 收藏 + 歷史 + 下載 + 設定 + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 000000000..c4e135b09 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..e111970a6 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + #fff44336 + #ff009688 + #ff00796b + #ff9c27b0 + #ffe040fb + + @color/teal_500 + @color/teal_700 + @color/purple_a200 + + #80ffffff + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 000000000..edb175eb8 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,34 @@ + + + + + 16dp + + 260dp + + 40dp + + + 24sp + 22sp + 20sp + 18sp + 16sp + 14sp + 12sp + + diff --git a/app/src/main/res/values/pathdata.xml b/app/src/main/res/values/pathdata.xml new file mode 100644 index 000000000..c2f6d292d --- /dev/null +++ b/app/src/main/res/values/pathdata.xml @@ -0,0 +1,22 @@ + + + + + + M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..6868fb42f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,80 @@ + + + + + + EhViewer + + Homepage + What\'s hot + Favourite + History + Download + Settings + + Username + Password + + Sign in + Register + Sign in via WebView + Sign in via cookies + + Bad Request + Unauthorized + Payment Required + Forbidden + Not Found + Method Not Allowed + Not Acceptable + Proxy Authentication Required + Request Timeout + Conflict + Gone + Length Required + Precondition Failed + Request Entity Too Large + Request-URI Too Long + Unsupported Media Type + Requested Range Not Satisfiable + Expectation Failed + + Internal Server Error + Not Implemented + Bad Gateway + Service Unavailable + Gateway Timeout + HTTP Version Not Supported + + Bad status code: %d + Timeout + Unknown host + Too many redirections + Network error + Weird + + Username cannot be an empty string + Password cannot be an empty string + + The content of this application is from the Internet. Some of it may do physical or mental harm to you. You have learnt the risks above and would like to undertake them. Do you want to use this application? + + Skip signing in + Sign in failed. But perhaps it is a mistake due to website update. Do you want to skip signing in. + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..af03dd239 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..ee30064dd --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/app/src/test/java/com/hippo/ehviewer/ExampleUnitTest.java b/app/src/test/java/com/hippo/ehviewer/ExampleUnitTest.java new file mode 100644 index 000000000..75266471c --- /dev/null +++ b/app/src/test/java/com/hippo/ehviewer/ExampleUnitTest.java @@ -0,0 +1,15 @@ +package com.hippo.ehviewer; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * To work on unit tests, switch the Test Artifact in the Build Variants view. + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/beerbelly b/beerbelly new file mode 160000 index 000000000..6163029f6 --- /dev/null +++ b/beerbelly @@ -0,0 +1 @@ +Subproject commit 6163029f62204bd090e75ba9dc87b62485383572 diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..eee534492 --- /dev/null +++ b/build.gradle @@ -0,0 +1,23 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:1.3.1' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/conaco b/conaco new file mode 160000 index 000000000..248ce509c --- /dev/null +++ b/conaco @@ -0,0 +1 @@ +Subproject commit 248ce509cf16c3581bd31fb86246dc5526318c1b diff --git a/daogenerator/.gitignore b/daogenerator/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/daogenerator/.gitignore @@ -0,0 +1 @@ +/build diff --git a/daogenerator/build.gradle b/daogenerator/build.gradle new file mode 100644 index 000000000..d986a8a8a --- /dev/null +++ b/daogenerator/build.gradle @@ -0,0 +1,27 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'java' + +task executeDaoGenerator(type:JavaExec) { + main = 'com.hippo.ehviewer.daogenerator.Main' + classpath = sourceSets.main.runtimeClasspath +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile('de.greenrobot:greendao-generator:2.0.0') +} diff --git a/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/CookieDaoGenerator.java b/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/CookieDaoGenerator.java new file mode 100644 index 000000000..77bc6a4dd --- /dev/null +++ b/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/CookieDaoGenerator.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.daogenerator; + +import java.io.File; + +import de.greenrobot.daogenerator.DaoGenerator; +import de.greenrobot.daogenerator.Entity; +import de.greenrobot.daogenerator.Schema; + +public class CookieDaoGenerator { + + private static final String PACKAGE = "com.hippo.okhttp.dao"; + private static final String OUT_DIR = "../app/src/main/java-gen"; + private static final String DELETE_DIR = "../app/src/main/java-gen/com/hippo/okhttp/dao"; + + public static void generate() throws Exception { + Utilities.deleteContents(new File(DELETE_DIR)); + File outDir = new File(OUT_DIR); + outDir.delete(); + outDir.mkdirs(); + + Schema schema = new Schema(1, PACKAGE); + addOkHttp3Cookie(schema); + new DaoGenerator().generateAll(schema, OUT_DIR); + } + + private static void addOkHttp3Cookie(Schema schema) { + Entity entity = schema.addEntity("OkHttp3CookieRaw"); + entity.setTableName("OK_HTTP_3_COOKIE"); + entity.setClassNameDao("OkHttp3CookieDao"); + + entity.addIdProperty(); + entity.addStringProperty("name"); + entity.addStringProperty("value"); + entity.addLongProperty("expiresAt"); + entity.addStringProperty("domain"); + entity.addStringProperty("path"); + entity.addBooleanProperty("secure"); + entity.addBooleanProperty("httpOnly"); + entity.addBooleanProperty("persistent"); + entity.addBooleanProperty("hostOnly"); + } +} diff --git a/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/Main.java b/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/Main.java new file mode 100644 index 000000000..eda23e0a7 --- /dev/null +++ b/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/Main.java @@ -0,0 +1,24 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.daogenerator; + +public class Main { + + public static void main(String args[]) throws Exception { + CookieDaoGenerator.generate(); + } +} diff --git a/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/Utilities.java b/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/Utilities.java new file mode 100644 index 000000000..8d443ac34 --- /dev/null +++ b/daogenerator/src/main/java/com/hippo/ehviewer/daogenerator/Utilities.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.ehviewer.daogenerator; + +import java.io.File; + +public final class Utilities { + + public static boolean deleteContents(File dir) { + File[] files = dir.listFiles(); + boolean success = true; + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + success &= deleteContents(file); + } + if (!file.delete()) { + success = false; + } + } + } + return success; + } +} diff --git a/easyrecyclerview b/easyrecyclerview new file mode 160000 index 000000000..afe6b3201 --- /dev/null +++ b/easyrecyclerview @@ -0,0 +1 @@ +Subproject commit afe6b320130ee4878d8002488965093546ba6938 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..1d3591c8a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,18 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..05ef575b0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..5d4205be0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Jan 18 22:31:05 CST 2016 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.9-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..9d82f7891 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..8a0b282aa --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hotspot b/hotspot new file mode 160000 index 000000000..83971eeca --- /dev/null +++ b/hotspot @@ -0,0 +1 @@ +Subproject commit 83971eeca14145edc5b00e4da818dbfc54b50a10 diff --git a/okhttp3/.gitignore b/okhttp3/.gitignore new file mode 100644 index 000000000..a0cc187d4 --- /dev/null +++ b/okhttp3/.gitignore @@ -0,0 +1,4 @@ +/build + +# Intellij +*.iml diff --git a/okhttp3/build.gradle b/okhttp3/build.gradle new file mode 100644 index 000000000..53deabbfd --- /dev/null +++ b/okhttp3/build.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 23 + buildToolsVersion "23.0.2" + + defaultConfig { + minSdkVersion 1 + targetSdkVersion 23 + versionCode 1 + versionName "1.0" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + testCompile 'junit:junit:4.12' + compile 'com.squareup.okio:okio:1.6.0' +} diff --git a/okhttp3/proguard-rules.pro b/okhttp3/proguard-rules.pro new file mode 100644 index 000000000..468280303 --- /dev/null +++ b/okhttp3/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in E:\Android\android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/okhttp3/src/androidTest/java/com/squareup/okhttp3/ApplicationTest.java b/okhttp3/src/androidTest/java/com/squareup/okhttp3/ApplicationTest.java new file mode 100644 index 000000000..eac90949c --- /dev/null +++ b/okhttp3/src/androidTest/java/com/squareup/okhttp3/ApplicationTest.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.squareup.okhttp3; + +import android.app.Application; +import android.test.ApplicationTestCase; + +/** + * Testing Fundamentals + */ +public class ApplicationTest extends ApplicationTestCase { + public ApplicationTest() { + super(Application.class); + } +} \ No newline at end of file diff --git a/okhttp3/src/main/AndroidManifest.xml b/okhttp3/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f0fc0347c --- /dev/null +++ b/okhttp3/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/okhttp3/src/main/java/okhttp3/Address.java b/okhttp3/src/main/java/okhttp3/Address.java new file mode 100644 index 000000000..cec154262 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Address.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.Proxy; +import java.net.ProxySelector; +import java.util.List; +import javax.net.SocketFactory; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSocketFactory; +import okhttp3.internal.Util; + +import static okhttp3.internal.Util.equal; + +/** + * A specification for a connection to an origin server. For simple connections, this is the + * server's hostname and port. If an explicit proxy is requested (or {@linkplain Proxy#NO_PROXY no + * proxy} is explicitly requested), this also includes that proxy information. For secure + * connections the address also includes the SSL socket factory, hostname verifier, and certificate + * pinner. + * + *

HTTP requests that share the same {@code Address} may also share the same {@link Connection}. + */ +public final class Address { + final HttpUrl url; + final Dns dns; + final SocketFactory socketFactory; + final Authenticator proxyAuthenticator; + final List protocols; + final List connectionSpecs; + final ProxySelector proxySelector; + final Proxy proxy; + final SSLSocketFactory sslSocketFactory; + final HostnameVerifier hostnameVerifier; + final CertificatePinner certificatePinner; + + public Address(String uriHost, int uriPort, Dns dns, SocketFactory socketFactory, + SSLSocketFactory sslSocketFactory, HostnameVerifier hostnameVerifier, + CertificatePinner certificatePinner, Authenticator proxyAuthenticator, Proxy proxy, + List protocols, List connectionSpecs, ProxySelector proxySelector) { + this.url = new HttpUrl.Builder() + .scheme(sslSocketFactory != null ? "https" : "http") + .host(uriHost) + .port(uriPort) + .build(); + + if (dns == null) throw new IllegalArgumentException("dns == null"); + this.dns = dns; + + if (socketFactory == null) throw new IllegalArgumentException("socketFactory == null"); + this.socketFactory = socketFactory; + + if (proxyAuthenticator == null) { + throw new IllegalArgumentException("proxyAuthenticator == null"); + } + this.proxyAuthenticator = proxyAuthenticator; + + if (protocols == null) throw new IllegalArgumentException("protocols == null"); + this.protocols = Util.immutableList(protocols); + + if (connectionSpecs == null) throw new IllegalArgumentException("connectionSpecs == null"); + this.connectionSpecs = Util.immutableList(connectionSpecs); + + if (proxySelector == null) throw new IllegalArgumentException("proxySelector == null"); + this.proxySelector = proxySelector; + + this.proxy = proxy; + this.sslSocketFactory = sslSocketFactory; + this.hostnameVerifier = hostnameVerifier; + this.certificatePinner = certificatePinner; + } + + /** + * Returns a URL with the hostname and port of the origin server. The path, query, and fragment of + * this URL are always empty, since they are not significant for planning a route. + */ + public HttpUrl url() { + return url; + } + + /** Returns the service that will be used to resolve IP addresses for hostnames. */ + public Dns dns() { + return dns; + } + + /** Returns the socket factory for new connections. */ + public SocketFactory socketFactory() { + return socketFactory; + } + + /** Returns the client's proxy authenticator. */ + public Authenticator proxyAuthenticator() { + return proxyAuthenticator; + } + + /** + * Returns the protocols the client supports. This method always returns a non-null list that + * contains minimally {@link Protocol#HTTP_1_1}. + */ + public List protocols() { + return protocols; + } + + public List connectionSpecs() { + return connectionSpecs; + } + + /** + * Returns this address's proxy selector. Only used if the proxy is null. If none of this + * selector's proxies are reachable, a direct connection will be attempted. + */ + public ProxySelector proxySelector() { + return proxySelector; + } + + /** + * Returns this address's explicitly-specified HTTP proxy, or null to delegate to the {@linkplain + * #proxySelector proxy selector}. + */ + public Proxy proxy() { + return proxy; + } + + /** Returns the SSL socket factory, or null if this is not an HTTPS address. */ + public SSLSocketFactory sslSocketFactory() { + return sslSocketFactory; + } + + /** Returns the hostname verifier, or null if this is not an HTTPS address. */ + public HostnameVerifier hostnameVerifier() { + return hostnameVerifier; + } + + /** Returns this address's certificate pinner, or null if this is not an HTTPS address. */ + public CertificatePinner certificatePinner() { + return certificatePinner; + } + + @Override public boolean equals(Object other) { + if (other instanceof Address) { + Address that = (Address) other; + return this.url.equals(that.url) + && this.dns.equals(that.dns) + && this.proxyAuthenticator.equals(that.proxyAuthenticator) + && this.protocols.equals(that.protocols) + && this.connectionSpecs.equals(that.connectionSpecs) + && this.proxySelector.equals(that.proxySelector) + && equal(this.proxy, that.proxy) + && equal(this.sslSocketFactory, that.sslSocketFactory) + && equal(this.hostnameVerifier, that.hostnameVerifier) + && equal(this.certificatePinner, that.certificatePinner); + } + return false; + } + + @Override public int hashCode() { + int result = 17; + result = 31 * result + url.hashCode(); + result = 31 * result + dns.hashCode(); + result = 31 * result + proxyAuthenticator.hashCode(); + result = 31 * result + protocols.hashCode(); + result = 31 * result + connectionSpecs.hashCode(); + result = 31 * result + proxySelector.hashCode(); + result = 31 * result + (proxy != null ? proxy.hashCode() : 0); + result = 31 * result + (sslSocketFactory != null ? sslSocketFactory.hashCode() : 0); + result = 31 * result + (hostnameVerifier != null ? hostnameVerifier.hashCode() : 0); + result = 31 * result + (certificatePinner != null ? certificatePinner.hashCode() : 0); + return result; + } +} diff --git a/okhttp3/src/main/java/okhttp3/Authenticator.java b/okhttp3/src/main/java/okhttp3/Authenticator.java new file mode 100644 index 000000000..ede539805 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Authenticator.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; + +/** + * Responds to an authentication challenge from either a remote web server or a proxy server. + * Implementations may either attempt to satisfy the challenge by returning a request that includes + * an authorization header, or they may refuse the challenge by returning null. In this case the + * unauthenticated response will be returned to the caller that triggered it. + * + *

When authentication is requested by an origin server, the response code is 401 and the + * implementation should respond with a new request that sets the "Authorization" header. + *

   {@code
+ *
+ *    String credential = Credentials.basic(...)
+ *    return response.request().newBuilder()
+ *        .header("Authorization", credential)
+ *        .build();
+ * }
+ * + *

Whn authentication is requested by a proxy server, the response code is 407 and the + * implementation should respond with a new request that sets the "Proxy-Authorization" header. + *

   {@code
+ *
+ *    String credential = Credentials.basic(...)
+ *    return response.request().newBuilder()
+ *        .header("Proxy-Authorization", credential)
+ *        .build();
+ * }
+ * + *

Applications may configure OkHttp with an authenticator for origin servers, or proxy servers, + * or both. + */ +public interface Authenticator { + /** An authenticator that knows no credentials and makes no attempt to authenticate. */ + Authenticator NONE = new Authenticator() { + @Override public Request authenticate(Route route, Response response) { + return null; + } + }; + + /** + * Returns a request that includes a credential to satisfy an authentication challenge in {@code + * response}. Returns null if the challenge cannot be satisfied. + */ + Request authenticate(Route route, Response response) throws IOException; +} diff --git a/okhttp3/src/main/java/okhttp3/Cache.java b/okhttp3/src/main/java/okhttp3/Cache.java new file mode 100644 index 000000000..3717417b6 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Cache.java @@ -0,0 +1,743 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3; + +import java.io.Closeable; +import java.io.File; +import java.io.Flushable; +import java.io.IOException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import okhttp3.internal.DiskLruCache; +import okhttp3.internal.InternalCache; +import okhttp3.internal.Util; +import okhttp3.internal.http.CacheRequest; +import okhttp3.internal.http.CacheStrategy; +import okhttp3.internal.http.HttpMethod; +import okhttp3.internal.http.OkHeaders; +import okhttp3.internal.http.StatusLine; +import okhttp3.internal.io.FileSystem; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.ByteString; +import okio.ForwardingSink; +import okio.ForwardingSource; +import okio.Okio; +import okio.Sink; +import okio.Source; + +/** + * Caches HTTP and HTTPS responses to the filesystem so they may be reused, saving time and + * bandwidth. + * + *

Cache Optimization

+ * + *

To measure cache effectiveness, this class tracks three statistics: + *

    + *
  • {@linkplain #requestCount() Request Count:} the number of HTTP + * requests issued since this cache was created. + *
  • {@linkplain #networkCount() Network Count:} the number of those + * requests that required network use. + *
  • {@linkplain #hitCount() Hit Count:} the number of those requests + * whose responses were served by the cache. + *
+ * + * Sometimes a request will result in a conditional cache hit. If the cache contains a stale copy of + * the response, the client will issue a conditional {@code GET}. The server will then send either + * the updated response if it has changed, or a short 'not modified' response if the client's copy + * is still valid. Such responses increment both the network count and hit count. + * + *

The best way to improve the cache hit rate is by configuring the web server to return + * cacheable responses. Although this client honors all HTTP/1.1 (RFC 7234) cache headers, it doesn't cache + * partial responses. + * + *

Force a Network Response

+ * + *

In some situations, such as after a user clicks a 'refresh' button, it may be necessary to + * skip the cache, and fetch data directly from the server. To force a full refresh, add the {@code + * no-cache} directive:

   {@code
+ *
+ *   Request request = new Request.Builder()
+ *       .cacheControl(new CacheControl.Builder().noCache().build())
+ *       .url("http://publicobject.com/helloworld.txt")
+ *       .build();
+ * }
+ * + * If it is only necessary to force a cached response to be validated by the server, use the more + * efficient {@code max-age=0} directive instead:
   {@code
+ *
+ *   Request request = new Request.Builder()
+ *       .cacheControl(new CacheControl.Builder()
+ *           .maxAge(0, TimeUnit.SECONDS)
+ *           .build())
+ *       .url("http://publicobject.com/helloworld.txt")
+ *       .build();
+ * }
+ * + *

Force a Cache Response

+ * + *

Sometimes you'll want to show resources if they are available immediately, but not otherwise. + * This can be used so your application can show something while waiting for the latest data + * to be downloaded. To restrict a request to locally-cached resources, add the {@code + * only-if-cached} directive:

   {@code
+ *
+ *     Request request = new Request.Builder()
+ *         .cacheControl(new CacheControl.Builder()
+ *             .onlyIfCached()
+ *             .build())
+ *         .url("http://publicobject.com/helloworld.txt")
+ *         .build();
+ *     Response forceCacheResponse = client.newCall(request).execute();
+ *     if (forceCacheResponse.code() != 504) {
+ *       // The resource was cached! Show it.
+ *     } else {
+ *       // The resource was not cached.
+ *     }
+ * }
+ * This technique works even better in situations where a stale response is better than no response. + * To permit stale cached responses, use the {@code max-stale} directive with the maximum staleness + * in seconds:
   {@code
+ *
+ *   Request request = new Request.Builder()
+ *       .cacheControl(new CacheControl.Builder()
+ *           .maxStale(365, TimeUnit.DAYS)
+ *           .build())
+ *       .url("http://publicobject.com/helloworld.txt")
+ *       .build();
+ * }
+ * + *

The {@link CacheControl} class can configure request caching directives and parse response + * caching directives. It even offers convenient constants {@link CacheControl#FORCE_NETWORK} and + * {@link CacheControl#FORCE_CACHE} that address the use cases above. + */ +public final class Cache implements Closeable, Flushable { + private static final int VERSION = 201105; + private static final int ENTRY_METADATA = 0; + private static final int ENTRY_BODY = 1; + private static final int ENTRY_COUNT = 2; + + final InternalCache internalCache = new InternalCache() { + @Override public Response get(Request request) throws IOException { + return Cache.this.get(request); + } + + @Override public CacheRequest put(Response response) throws IOException { + return Cache.this.put(response); + } + + @Override public void remove(Request request) throws IOException { + Cache.this.remove(request); + } + + @Override public void update(Response cached, Response network) throws IOException { + Cache.this.update(cached, network); + } + + @Override public void trackConditionalCacheHit() { + Cache.this.trackConditionalCacheHit(); + } + + @Override public void trackResponse(CacheStrategy cacheStrategy) { + Cache.this.trackResponse(cacheStrategy); + } + }; + + private final DiskLruCache cache; + + /* read and write statistics, all guarded by 'this' */ + private int writeSuccessCount; + private int writeAbortCount; + private int networkCount; + private int hitCount; + private int requestCount; + + public Cache(File directory, long maxSize) { + this(directory, maxSize, FileSystem.SYSTEM); + } + + Cache(File directory, long maxSize, FileSystem fileSystem) { + this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize); + } + + private static String urlToKey(Request request) { + return Util.md5Hex(request.url().toString()); + } + + Response get(Request request) { + String key = urlToKey(request); + DiskLruCache.Snapshot snapshot; + Entry entry; + try { + snapshot = cache.get(key); + if (snapshot == null) { + return null; + } + } catch (IOException e) { + // Give up because the cache cannot be read. + return null; + } + + try { + entry = new Entry(snapshot.getSource(ENTRY_METADATA)); + } catch (IOException e) { + Util.closeQuietly(snapshot); + return null; + } + + Response response = entry.response(snapshot); + + if (!entry.matches(request, response)) { + Util.closeQuietly(response.body()); + return null; + } + + return response; + } + + private CacheRequest put(Response response) throws IOException { + String requestMethod = response.request().method(); + + if (HttpMethod.invalidatesCache(response.request().method())) { + try { + remove(response.request()); + } catch (IOException ignored) { + // The cache cannot be written. + } + return null; + } + if (!requestMethod.equals("GET")) { + // Don't cache non-GET responses. We're technically allowed to cache + // HEAD requests and some POST requests, but the complexity of doing + // so is high and the benefit is low. + return null; + } + + if (OkHeaders.hasVaryAll(response)) { + return null; + } + + Entry entry = new Entry(response); + DiskLruCache.Editor editor = null; + try { + editor = cache.edit(urlToKey(response.request())); + if (editor == null) { + return null; + } + entry.writeTo(editor); + return new CacheRequestImpl(editor); + } catch (IOException e) { + abortQuietly(editor); + return null; + } + } + + private void remove(Request request) throws IOException { + cache.remove(urlToKey(request)); + } + + private void update(Response cached, Response network) { + Entry entry = new Entry(network); + DiskLruCache.Snapshot snapshot = ((CacheResponseBody) cached.body()).snapshot; + DiskLruCache.Editor editor = null; + try { + editor = snapshot.edit(); // Returns null if snapshot is not current. + if (editor != null) { + entry.writeTo(editor); + editor.commit(); + } + } catch (IOException e) { + abortQuietly(editor); + } + } + + private void abortQuietly(DiskLruCache.Editor editor) { + // Give up because the cache cannot be written. + try { + if (editor != null) { + editor.abort(); + } + } catch (IOException ignored) { + } + } + + /** + * Initialize the cache. This will include reading the journal files from the storage and building + * up the necessary in-memory cache information. + * + *

The initialization time may vary depending on the journal file size and the current actual + * cache size. The application needs to be aware of calling this function during the + * initialization phase and preferably in a background worker thread. + * + *

Note that if the application chooses to not call this method to initialize the cache. By + * default, the okhttp will perform lazy initialization upon the first usage of the cache. + */ + public void initialize() throws IOException { + cache.initialize(); + } + + /** + * Closes the cache and deletes all of its stored values. This will delete all files in the cache + * directory including files that weren't created by the cache. + */ + public void delete() throws IOException { + cache.delete(); + } + + /** + * Deletes all values stored in the cache. In-flight writes to the cache will complete normally, + * but the corresponding responses will not be stored. + */ + public void evictAll() throws IOException { + cache.evictAll(); + } + + /** + * Returns an iterator over the URLs in this cache. This iterator doesn't throw {@code + * ConcurrentModificationException}, but if new responses are added while iterating, their URLs + * will not be returned. If existing responses are evicted during iteration, they will be absent + * (unless they were already returned). + * + *

The iterator supports {@linkplain Iterator#remove}. Removing a URL from the iterator evicts + * the corresponding response from the cache. Use this to evict selected responses. + */ + public Iterator urls() throws IOException { + return new Iterator() { + final Iterator delegate = cache.snapshots(); + + String nextUrl; + boolean canRemove; + + @Override public boolean hasNext() { + if (nextUrl != null) return true; + + canRemove = false; // Prevent delegate.remove() on the wrong item! + while (delegate.hasNext()) { + DiskLruCache.Snapshot snapshot = delegate.next(); + try { + BufferedSource metadata = Okio.buffer(snapshot.getSource(ENTRY_METADATA)); + nextUrl = metadata.readUtf8LineStrict(); + return true; + } catch (IOException ignored) { + // We couldn't read the metadata for this snapshot; possibly because the host filesystem + // has disappeared! Skip it. + } finally { + snapshot.close(); + } + } + + return false; + } + + @Override public String next() { + if (!hasNext()) throw new NoSuchElementException(); + String result = nextUrl; + nextUrl = null; + canRemove = true; + return result; + } + + @Override public void remove() { + if (!canRemove) throw new IllegalStateException("remove() before next()"); + delegate.remove(); + } + }; + } + + public synchronized int writeAbortCount() { + return writeAbortCount; + } + + public synchronized int writeSuccessCount() { + return writeSuccessCount; + } + + public long size() throws IOException { + return cache.size(); + } + + public long maxSize() { + return cache.getMaxSize(); + } + + @Override public void flush() throws IOException { + cache.flush(); + } + + @Override public void close() throws IOException { + cache.close(); + } + + public File directory() { + return cache.getDirectory(); + } + + public boolean isClosed() { + return cache.isClosed(); + } + + private synchronized void trackResponse(CacheStrategy cacheStrategy) { + requestCount++; + + if (cacheStrategy.networkRequest != null) { + // If this is a conditional request, we'll increment hitCount if/when it hits. + networkCount++; + } else if (cacheStrategy.cacheResponse != null) { + // This response uses the cache and not the network. That's a cache hit. + hitCount++; + } + } + + private synchronized void trackConditionalCacheHit() { + hitCount++; + } + + public synchronized int networkCount() { + return networkCount; + } + + public synchronized int hitCount() { + return hitCount; + } + + public synchronized int requestCount() { + return requestCount; + } + + private final class CacheRequestImpl implements CacheRequest { + private final DiskLruCache.Editor editor; + private Sink cacheOut; + private boolean done; + private Sink body; + + public CacheRequestImpl(final DiskLruCache.Editor editor) throws IOException { + this.editor = editor; + this.cacheOut = editor.newSink(ENTRY_BODY); + this.body = new ForwardingSink(cacheOut) { + @Override public void close() throws IOException { + synchronized (Cache.this) { + if (done) { + return; + } + done = true; + writeSuccessCount++; + } + super.close(); + editor.commit(); + } + }; + } + + @Override public void abort() { + synchronized (Cache.this) { + if (done) { + return; + } + done = true; + writeAbortCount++; + } + Util.closeQuietly(cacheOut); + try { + editor.abort(); + } catch (IOException ignored) { + } + } + + @Override public Sink body() { + return body; + } + } + + private static final class Entry { + private final String url; + private final Headers varyHeaders; + private final String requestMethod; + private final Protocol protocol; + private final int code; + private final String message; + private final Headers responseHeaders; + private final Handshake handshake; + + /** + * Reads an entry from an input stream. A typical entry looks like this: + *

{@code
+     *   http://google.com/foo
+     *   GET
+     *   2
+     *   Accept-Language: fr-CA
+     *   Accept-Charset: UTF-8
+     *   HTTP/1.1 200 OK
+     *   3
+     *   Content-Type: image/png
+     *   Content-Length: 100
+     *   Cache-Control: max-age=600
+     * }
+ * + *

A typical HTTPS file looks like this: + *

{@code
+     *   https://google.com/foo
+     *   GET
+     *   2
+     *   Accept-Language: fr-CA
+     *   Accept-Charset: UTF-8
+     *   HTTP/1.1 200 OK
+     *   3
+     *   Content-Type: image/png
+     *   Content-Length: 100
+     *   Cache-Control: max-age=600
+     *
+     *   AES_256_WITH_MD5
+     *   2
+     *   base64-encoded peerCertificate[0]
+     *   base64-encoded peerCertificate[1]
+     *   -1
+     *   TLSv1.2
+     * }
+ * The file is newline separated. The first two lines are the URL and the request method. Next + * is the number of HTTP Vary request header lines, followed by those lines. + * + *

Next is the response status line, followed by the number of HTTP response header lines, + * followed by those lines. + * + *

HTTPS responses also contain SSL session information. This begins with a blank line, and + * then a line containing the cipher suite. Next is the length of the peer certificate chain. + * These certificates are base64-encoded and appear each on their own line. The next line + * contains the length of the local certificate chain. These certificates are also + * base64-encoded and appear each on their own line. A length of -1 is used to encode a null + * array. The last line is optional. If present, it contains the TLS version. + */ + public Entry(Source in) throws IOException { + try { + BufferedSource source = Okio.buffer(in); + url = source.readUtf8LineStrict(); + requestMethod = source.readUtf8LineStrict(); + Headers.Builder varyHeadersBuilder = new Headers.Builder(); + int varyRequestHeaderLineCount = readInt(source); + for (int i = 0; i < varyRequestHeaderLineCount; i++) { + varyHeadersBuilder.addLenient(source.readUtf8LineStrict()); + } + varyHeaders = varyHeadersBuilder.build(); + + StatusLine statusLine = StatusLine.parse(source.readUtf8LineStrict()); + protocol = statusLine.protocol; + code = statusLine.code; + message = statusLine.message; + Headers.Builder responseHeadersBuilder = new Headers.Builder(); + int responseHeaderLineCount = readInt(source); + for (int i = 0; i < responseHeaderLineCount; i++) { + responseHeadersBuilder.addLenient(source.readUtf8LineStrict()); + } + responseHeaders = responseHeadersBuilder.build(); + + if (isHttps()) { + String blank = source.readUtf8LineStrict(); + if (blank.length() > 0) { + throw new IOException("expected \"\" but was \"" + blank + "\""); + } + String cipherSuiteString = source.readUtf8LineStrict(); + CipherSuite cipherSuite = CipherSuite.forJavaName(cipherSuiteString); + List peerCertificates = readCertificateList(source); + List localCertificates = readCertificateList(source); + TlsVersion tlsVersion = !source.exhausted() + ? TlsVersion.forJavaName(source.readUtf8LineStrict()) + : null; + handshake = Handshake.get(tlsVersion, cipherSuite, peerCertificates, localCertificates); + } else { + handshake = null; + } + } finally { + in.close(); + } + } + + public Entry(Response response) { + this.url = response.request().url().toString(); + this.varyHeaders = OkHeaders.varyHeaders(response); + this.requestMethod = response.request().method(); + this.protocol = response.protocol(); + this.code = response.code(); + this.message = response.message(); + this.responseHeaders = response.headers(); + this.handshake = response.handshake(); + } + + public void writeTo(DiskLruCache.Editor editor) throws IOException { + BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA)); + + sink.writeUtf8(url); + sink.writeByte('\n'); + sink.writeUtf8(requestMethod); + sink.writeByte('\n'); + sink.writeDecimalLong(varyHeaders.size()); + sink.writeByte('\n'); + for (int i = 0, size = varyHeaders.size(); i < size; i++) { + sink.writeUtf8(varyHeaders.name(i)); + sink.writeUtf8(": "); + sink.writeUtf8(varyHeaders.value(i)); + sink.writeByte('\n'); + } + + sink.writeUtf8(new StatusLine(protocol, code, message).toString()); + sink.writeByte('\n'); + sink.writeDecimalLong(responseHeaders.size()); + sink.writeByte('\n'); + for (int i = 0, size = responseHeaders.size(); i < size; i++) { + sink.writeUtf8(responseHeaders.name(i)); + sink.writeUtf8(": "); + sink.writeUtf8(responseHeaders.value(i)); + sink.writeByte('\n'); + } + + if (isHttps()) { + sink.writeByte('\n'); + sink.writeUtf8(handshake.cipherSuite().javaName()); + sink.writeByte('\n'); + writeCertList(sink, handshake.peerCertificates()); + writeCertList(sink, handshake.localCertificates()); + // The handshake’s TLS version is null on HttpsURLConnection and on older cached responses. + if (handshake.tlsVersion() != null) { + sink.writeUtf8(handshake.tlsVersion().javaName()); + sink.writeByte('\n'); + } + } + sink.close(); + } + + private boolean isHttps() { + return url.startsWith("https://"); + } + + private List readCertificateList(BufferedSource source) throws IOException { + int length = readInt(source); + if (length == -1) return Collections.emptyList(); // OkHttp v1.2 used -1 to indicate null. + + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + List result = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + String line = source.readUtf8LineStrict(); + Buffer bytes = new Buffer(); + bytes.write(ByteString.decodeBase64(line)); + result.add(certificateFactory.generateCertificate(bytes.inputStream())); + } + return result; + } catch (CertificateException e) { + throw new IOException(e.getMessage()); + } + } + + private void writeCertList(BufferedSink sink, List certificates) + throws IOException { + try { + sink.writeDecimalLong(certificates.size()); + sink.writeByte('\n'); + for (int i = 0, size = certificates.size(); i < size; i++) { + byte[] bytes = certificates.get(i).getEncoded(); + String line = ByteString.of(bytes).base64(); + sink.writeUtf8(line); + sink.writeByte('\n'); + } + } catch (CertificateEncodingException e) { + throw new IOException(e.getMessage()); + } + } + + public boolean matches(Request request, Response response) { + return url.equals(request.url().toString()) + && requestMethod.equals(request.method()) + && OkHeaders.varyMatches(response, varyHeaders, request); + } + + public Response response(DiskLruCache.Snapshot snapshot) { + String contentType = responseHeaders.get("Content-Type"); + String contentLength = responseHeaders.get("Content-Length"); + Request cacheRequest = new Request.Builder() + .url(url) + .method(requestMethod, null) + .headers(varyHeaders) + .build(); + return new Response.Builder() + .request(cacheRequest) + .protocol(protocol) + .code(code) + .message(message) + .headers(responseHeaders) + .body(new CacheResponseBody(snapshot, contentType, contentLength)) + .handshake(handshake) + .build(); + } + } + + private static int readInt(BufferedSource source) throws IOException { + try { + long result = source.readDecimalLong(); + String line = source.readUtf8LineStrict(); + if (result < 0 || result > Integer.MAX_VALUE || !line.isEmpty()) { + throw new IOException("expected an int but was \"" + result + line + "\""); + } + return (int) result; + } catch (NumberFormatException e) { + throw new IOException(e.getMessage()); + } + } + + private static class CacheResponseBody extends ResponseBody { + private final DiskLruCache.Snapshot snapshot; + private final BufferedSource bodySource; + private final String contentType; + private final String contentLength; + + public CacheResponseBody(final DiskLruCache.Snapshot snapshot, + String contentType, String contentLength) { + this.snapshot = snapshot; + this.contentType = contentType; + this.contentLength = contentLength; + + Source source = snapshot.getSource(ENTRY_BODY); + bodySource = Okio.buffer(new ForwardingSource(source) { + @Override public void close() throws IOException { + snapshot.close(); + super.close(); + } + }); + } + + @Override public MediaType contentType() { + return contentType != null ? MediaType.parse(contentType) : null; + } + + @Override public long contentLength() { + try { + return contentLength != null ? Long.parseLong(contentLength) : -1; + } catch (NumberFormatException e) { + return -1; + } + } + + @Override public BufferedSource source() { + return bodySource; + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/CacheControl.java b/okhttp3/src/main/java/okhttp3/CacheControl.java new file mode 100644 index 000000000..d8eb44614 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/CacheControl.java @@ -0,0 +1,354 @@ +package okhttp3; + +import java.util.concurrent.TimeUnit; +import okhttp3.internal.http.HeaderParser; + +/** + * A Cache-Control header with cache directives from a server or client. These directives set policy + * on what responses can be stored, and which requests can be satisfied by those stored responses. + * + *

See RFC 2616, + * 14.9. + */ +public final class CacheControl { + /** + * Cache control request directives that require network validation of responses. Note that such + * requests may be assisted by the cache via conditional GET requests. + */ + public static final CacheControl FORCE_NETWORK = new Builder().noCache().build(); + + /** + * Cache control request directives that uses the cache only, even if the cached response is + * stale. If the response isn't available in the cache or requires server validation, the call + * will fail with a {@code 504 Unsatisfiable Request}. + */ + public static final CacheControl FORCE_CACHE = new Builder() + .onlyIfCached() + .maxStale(Integer.MAX_VALUE, TimeUnit.SECONDS) + .build(); + + private final boolean noCache; + private final boolean noStore; + private final int maxAgeSeconds; + private final int sMaxAgeSeconds; + private final boolean isPrivate; + private final boolean isPublic; + private final boolean mustRevalidate; + private final int maxStaleSeconds; + private final int minFreshSeconds; + private final boolean onlyIfCached; + private final boolean noTransform; + + String headerValue; // Lazily computed, null if absent. + + private CacheControl(boolean noCache, boolean noStore, int maxAgeSeconds, int sMaxAgeSeconds, + boolean isPrivate, boolean isPublic, boolean mustRevalidate, int maxStaleSeconds, + int minFreshSeconds, boolean onlyIfCached, boolean noTransform, String headerValue) { + this.noCache = noCache; + this.noStore = noStore; + this.maxAgeSeconds = maxAgeSeconds; + this.sMaxAgeSeconds = sMaxAgeSeconds; + this.isPrivate = isPrivate; + this.isPublic = isPublic; + this.mustRevalidate = mustRevalidate; + this.maxStaleSeconds = maxStaleSeconds; + this.minFreshSeconds = minFreshSeconds; + this.onlyIfCached = onlyIfCached; + this.noTransform = noTransform; + this.headerValue = headerValue; + } + + private CacheControl(Builder builder) { + this.noCache = builder.noCache; + this.noStore = builder.noStore; + this.maxAgeSeconds = builder.maxAgeSeconds; + this.sMaxAgeSeconds = -1; + this.isPrivate = false; + this.isPublic = false; + this.mustRevalidate = false; + this.maxStaleSeconds = builder.maxStaleSeconds; + this.minFreshSeconds = builder.minFreshSeconds; + this.onlyIfCached = builder.onlyIfCached; + this.noTransform = builder.noTransform; + } + + /** + * In a response, this field's name "no-cache" is misleading. It doesn't prevent us from caching + * the response; it only means we have to validate the response with the origin server before + * returning it. We can do this with a conditional GET. + * + *

In a request, it means do not use a cache to satisfy the request. + */ + public boolean noCache() { + return noCache; + } + + /** If true, this response should not be cached. */ + public boolean noStore() { + return noStore; + } + + /** + * The duration past the response's served date that it can be served without validation. + */ + public int maxAgeSeconds() { + return maxAgeSeconds; + } + + /** + * The "s-maxage" directive is the max age for shared caches. Not to be confused with "max-age" + * for non-shared caches, As in Firefox and Chrome, this directive is not honored by this cache. + */ + public int sMaxAgeSeconds() { + return sMaxAgeSeconds; + } + + public boolean isPrivate() { + return isPrivate; + } + + public boolean isPublic() { + return isPublic; + } + + public boolean mustRevalidate() { + return mustRevalidate; + } + + public int maxStaleSeconds() { + return maxStaleSeconds; + } + + public int minFreshSeconds() { + return minFreshSeconds; + } + + /** + * This field's name "only-if-cached" is misleading. It actually means "do not use the network". + * It is set by a client who only wants to make a request if it can be fully satisfied by the + * cache. Cached responses that would require validation (ie. conditional gets) are not permitted + * if this header is set. + */ + public boolean onlyIfCached() { + return onlyIfCached; + } + + public boolean noTransform() { + return noTransform; + } + + /** + * Returns the cache directives of {@code headers}. This honors both Cache-Control and Pragma + * headers if they are present. + */ + public static CacheControl parse(Headers headers) { + boolean noCache = false; + boolean noStore = false; + int maxAgeSeconds = -1; + int sMaxAgeSeconds = -1; + boolean isPrivate = false; + boolean isPublic = false; + boolean mustRevalidate = false; + int maxStaleSeconds = -1; + int minFreshSeconds = -1; + boolean onlyIfCached = false; + boolean noTransform = false; + + boolean canUseHeaderValue = true; + String headerValue = null; + + for (int i = 0, size = headers.size(); i < size; i++) { + String name = headers.name(i); + String value = headers.value(i); + + if (name.equalsIgnoreCase("Cache-Control")) { + if (headerValue != null) { + // Multiple cache-control headers means we can't use the raw value. + canUseHeaderValue = false; + } else { + headerValue = value; + } + } else if (name.equalsIgnoreCase("Pragma")) { + // Might specify additional cache-control params. We invalidate just in case. + canUseHeaderValue = false; + } else { + continue; + } + + int pos = 0; + while (pos < value.length()) { + int tokenStart = pos; + pos = HeaderParser.skipUntil(value, pos, "=,;"); + String directive = value.substring(tokenStart, pos).trim(); + String parameter; + + if (pos == value.length() || value.charAt(pos) == ',' || value.charAt(pos) == ';') { + pos++; // consume ',' or ';' (if necessary) + parameter = null; + } else { + pos++; // consume '=' + pos = HeaderParser.skipWhitespace(value, pos); + + // quoted string + if (pos < value.length() && value.charAt(pos) == '\"') { + pos++; // consume '"' open quote + int parameterStart = pos; + pos = HeaderParser.skipUntil(value, pos, "\""); + parameter = value.substring(parameterStart, pos); + pos++; // consume '"' close quote (if necessary) + + // unquoted string + } else { + int parameterStart = pos; + pos = HeaderParser.skipUntil(value, pos, ",;"); + parameter = value.substring(parameterStart, pos).trim(); + } + } + + if ("no-cache".equalsIgnoreCase(directive)) { + noCache = true; + } else if ("no-store".equalsIgnoreCase(directive)) { + noStore = true; + } else if ("max-age".equalsIgnoreCase(directive)) { + maxAgeSeconds = HeaderParser.parseSeconds(parameter, -1); + } else if ("s-maxage".equalsIgnoreCase(directive)) { + sMaxAgeSeconds = HeaderParser.parseSeconds(parameter, -1); + } else if ("private".equalsIgnoreCase(directive)) { + isPrivate = true; + } else if ("public".equalsIgnoreCase(directive)) { + isPublic = true; + } else if ("must-revalidate".equalsIgnoreCase(directive)) { + mustRevalidate = true; + } else if ("max-stale".equalsIgnoreCase(directive)) { + maxStaleSeconds = HeaderParser.parseSeconds(parameter, Integer.MAX_VALUE); + } else if ("min-fresh".equalsIgnoreCase(directive)) { + minFreshSeconds = HeaderParser.parseSeconds(parameter, -1); + } else if ("only-if-cached".equalsIgnoreCase(directive)) { + onlyIfCached = true; + } else if ("no-transform".equalsIgnoreCase(directive)) { + noTransform = true; + } + } + } + + if (!canUseHeaderValue) { + headerValue = null; + } + return new CacheControl(noCache, noStore, maxAgeSeconds, sMaxAgeSeconds, isPrivate, isPublic, + mustRevalidate, maxStaleSeconds, minFreshSeconds, onlyIfCached, noTransform, headerValue); + } + + @Override public String toString() { + String result = headerValue; + return result != null ? result : (headerValue = headerValue()); + } + + private String headerValue() { + StringBuilder result = new StringBuilder(); + if (noCache) result.append("no-cache, "); + if (noStore) result.append("no-store, "); + if (maxAgeSeconds != -1) result.append("max-age=").append(maxAgeSeconds).append(", "); + if (sMaxAgeSeconds != -1) result.append("s-maxage=").append(sMaxAgeSeconds).append(", "); + if (isPrivate) result.append("private, "); + if (isPublic) result.append("public, "); + if (mustRevalidate) result.append("must-revalidate, "); + if (maxStaleSeconds != -1) result.append("max-stale=").append(maxStaleSeconds).append(", "); + if (minFreshSeconds != -1) result.append("min-fresh=").append(minFreshSeconds).append(", "); + if (onlyIfCached) result.append("only-if-cached, "); + if (noTransform) result.append("no-transform, "); + if (result.length() == 0) return ""; + result.delete(result.length() - 2, result.length()); + return result.toString(); + } + + /** Builds a {@code Cache-Control} request header. */ + public static final class Builder { + boolean noCache; + boolean noStore; + int maxAgeSeconds = -1; + int maxStaleSeconds = -1; + int minFreshSeconds = -1; + boolean onlyIfCached; + boolean noTransform; + + /** Don't accept an unvalidated cached response. */ + public Builder noCache() { + this.noCache = true; + return this; + } + + /** Don't store the server's response in any cache. */ + public Builder noStore() { + this.noStore = true; + return this; + } + + /** + * Sets the maximum age of a cached response. If the cache response's age exceeds {@code + * maxAge}, it will not be used and a network request will be made. + * + * @param maxAge a non-negative integer. This is stored and transmitted with {@link + * TimeUnit#SECONDS} precision; finer precision will be lost. + */ + public Builder maxAge(int maxAge, TimeUnit timeUnit) { + if (maxAge < 0) throw new IllegalArgumentException("maxAge < 0: " + maxAge); + long maxAgeSecondsLong = timeUnit.toSeconds(maxAge); + this.maxAgeSeconds = maxAgeSecondsLong > Integer.MAX_VALUE + ? Integer.MAX_VALUE + : (int) maxAgeSecondsLong; + return this; + } + + /** + * Accept cached responses that have exceeded their freshness lifetime by up to {@code + * maxStale}. If unspecified, stale cache responses will not be used. + * + * @param maxStale a non-negative integer. This is stored and transmitted with {@link + * TimeUnit#SECONDS} precision; finer precision will be lost. + */ + public Builder maxStale(int maxStale, TimeUnit timeUnit) { + if (maxStale < 0) throw new IllegalArgumentException("maxStale < 0: " + maxStale); + long maxStaleSecondsLong = timeUnit.toSeconds(maxStale); + this.maxStaleSeconds = maxStaleSecondsLong > Integer.MAX_VALUE + ? Integer.MAX_VALUE + : (int) maxStaleSecondsLong; + return this; + } + + /** + * Sets the minimum number of seconds that a response will continue to be fresh for. If the + * response will be stale when {@code minFresh} have elapsed, the cached response will not be + * used and a network request will be made. + * + * @param minFresh a non-negative integer. This is stored and transmitted with {@link + * TimeUnit#SECONDS} precision; finer precision will be lost. + */ + public Builder minFresh(int minFresh, TimeUnit timeUnit) { + if (minFresh < 0) throw new IllegalArgumentException("minFresh < 0: " + minFresh); + long minFreshSecondsLong = timeUnit.toSeconds(minFresh); + this.minFreshSeconds = minFreshSecondsLong > Integer.MAX_VALUE + ? Integer.MAX_VALUE + : (int) minFreshSecondsLong; + return this; + } + + /** + * Only accept the response if it is in the cache. If the response isn't cached, a {@code 504 + * Unsatisfiable Request} response will be returned. + */ + public Builder onlyIfCached() { + this.onlyIfCached = true; + return this; + } + + /** Don't accept a transformed response. */ + public Builder noTransform() { + this.noTransform = true; + return this; + } + + public CacheControl build() { + return new CacheControl(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Call.java b/okhttp3/src/main/java/okhttp3/Call.java new file mode 100644 index 000000000..3baaf647d --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Call.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; + +/** + * A call is a request that has been prepared for execution. A call can be canceled. As this object + * represents a single request/response pair (stream), it cannot be executed twice. + */ +public interface Call { + /** Returns the original request that initiated this call. */ + Request request(); + + /** + * Invokes the request immediately, and blocks until the response can be processed or is in + * error. + * + *

The caller may read the response body with the response's {@link Response#body} method. To + * facilitate connection recycling, callers should always {@link ResponseBody#close() close the + * response body}. + * + *

Note that transport-layer success (receiving a HTTP response code, headers and body) does + * not necessarily indicate application-layer success: {@code response} may still indicate an + * unhappy HTTP response code like 404 or 500. + * + * @throws IOException if the request could not be executed due to cancellation, a connectivity + * problem or timeout. Because networks can fail during an exchange, it is possible that the + * remote server accepted the request before the failure. + * @throws IllegalStateException when the call has already been executed. + */ + Response execute() throws IOException; + + /** + * Schedules the request to be executed at some point in the future. + * + *

The {@link OkHttpClient#dispatcher dispatcher} defines when the request will run: usually + * immediately unless there are several other requests currently being executed. + * + *

This client will later call back {@code responseCallback} with either an HTTP response or a + * failure exception. If you {@link #cancel} a request before it completes the callback will not + * be invoked. + * + * @throws IllegalStateException when the call has already been executed. + */ + void enqueue(Callback responseCallback); + + /** Cancels the request, if possible. Requests that are already complete cannot be canceled. */ + void cancel(); + + /** + * Returns true if this call has been either {@linkplain #execute() executed} or {@linkplain + * #enqueue(Callback) enqueued}. It is an error to execute a call more than once. + */ + boolean isExecuted(); + + boolean isCanceled(); + + interface Factory { + Call newCall(Request request); + } +} diff --git a/okhttp3/src/main/java/okhttp3/Callback.java b/okhttp3/src/main/java/okhttp3/Callback.java new file mode 100644 index 000000000..42a8a109b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Callback.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; + +public interface Callback { + /** + * Called when the request could not be executed due to cancellation, a connectivity problem or + * timeout. Because networks can fail during an exchange, it is possible that the remote server + * accepted the request before the failure. + */ + void onFailure(Call call, IOException e); + + /** + * Called when the HTTP response was successfully returned by the remote server. The callback may + * proceed to read the response body with {@link Response#body}. The response is still live until + * its response body is closed with {@code response.body().close()}. The recipient of the callback + * may even consume the response body on another thread. + * + *

Note that transport-layer success (receiving a HTTP response code, headers and body) does + * not necessarily indicate application-layer success: {@code response} may still indicate an + * unhappy HTTP response code like 404 or 500. + */ + void onResponse(Call call, Response response) throws IOException; +} diff --git a/okhttp3/src/main/java/okhttp3/CertificatePinner.java b/okhttp3/src/main/java/okhttp3/CertificatePinner.java new file mode 100644 index 000000000..4c56d6c14 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/CertificatePinner.java @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.net.ssl.SSLPeerUnverifiedException; +import okhttp3.internal.Util; +import okio.ByteString; + +import static java.util.Collections.unmodifiableSet; + +/** + * Constrains which certificates are trusted. Pinning certificates defends against attacks on + * certificate authorities. It also prevents connections through man-in-the-middle certificate + * authorities either known or unknown to the application's user. + * + *

This class currently pins a certificate's Subject Public Key Info as described on Adam Langley's Weblog. Pins are base-64 SHA-1 hashes, consistent + * with the format Chromium uses for static certificates. See + * Chromium's pinsets for hostnames that are pinned in that + * browser. + * + *

Setting up Certificate Pinning

+ * + *

The easiest way to pin a host is turn on pinning with a broken configuration and read the + * expected configuration when the connection fails. Be sure to do this on a trusted network, and + * without man-in-the-middle tools like Charles or Fiddler. + * + *

For example, to pin {@code https://publicobject.com}, start with a broken + * configuration:

   {@code
+ *
+ *     String hostname = "publicobject.com";
+ *     CertificatePinner certificatePinner = new CertificatePinner.Builder()
+ *         .add(hostname, "sha1/AAAAAAAAAAAAAAAAAAAAAAAAAAA=")
+ *         .build();
+ *     OkHttpClient client = new OkHttpClient();
+ *     client.setCertificatePinner(certificatePinner);
+ *
+ *     Request request = new Request.Builder()
+ *         .url("https://" + hostname)
+ *         .build();
+ *     client.newCall(request).execute();
+ * }
+ * + * As expected, this fails with a certificate pinning exception:
   {@code
+ *
+ * javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
+ *   Peer certificate chain:
+ *     sha1/DmxUShsZuNiqPQsX2Oi9uv2sCnw=: CN=publicobject.com, OU=PositiveSSL
+ *     sha1/SXxoaOSEzPC6BgGmxAt/EAcsajw=: CN=COMODO RSA Domain Validation Secure Server CA
+ *     sha1/blhOM3W9V/bVQhsWAcLYwPU6n24=: CN=COMODO RSA Certification Authority
+ *     sha1/T5x9IXmcrQ7YuQxXnxoCmeeQ84c=: CN=AddTrust External CA Root
+ *   Pinned certificates for publicobject.com:
+ *     sha1/AAAAAAAAAAAAAAAAAAAAAAAAAAA=
+ *   at okhttp3.CertificatePinner.check(CertificatePinner.java)
+ *   at okhttp3.Connection.upgradeToTls(Connection.java)
+ *   at okhttp3.Connection.connect(Connection.java)
+ *   at okhttp3.Connection.connectAndSetOwner(Connection.java)
+ * }
+ * + * Follow up by pasting the public key hashes from the exception into the + * certificate pinner's configuration:
   {@code
+ *
+ *     CertificatePinner certificatePinner = new CertificatePinner.Builder()
+ *       .add("publicobject.com", "sha1/DmxUShsZuNiqPQsX2Oi9uv2sCnw=")
+ *       .add("publicobject.com", "sha1/SXxoaOSEzPC6BgGmxAt/EAcsajw=")
+ *       .add("publicobject.com", "sha1/blhOM3W9V/bVQhsWAcLYwPU6n24=")
+ *       .add("publicobject.com", "sha1/T5x9IXmcrQ7YuQxXnxoCmeeQ84c=")
+ *       .build();
+ * }
+ * + * Pinning is per-hostname and/or per-wildcard pattern. To pin both {@code publicobject.com} and + * {@code www.publicobject.com}, you must configure both hostnames. + * + *

Wildcard pattern rules: + *

    + *
  1. Asterisk {@code *} is only permitted in the left-most domain name label and must be the + * only character in that label (i.e., must match the whole left-most label). For example, + * {@code *.example.com} is permitted, while {@code *a.example.com}, {@code a*.example.com}, + * {@code a*b.example.com}, {@code a.*.example.com} are not permitted. + *
  2. Asterisk {@code *} cannot match across domain name labels. For example, + * {@code *.example.com} matches {@code test.example.com} but does not match + * {@code sub.test.example.com}. + *
  3. Wildcard patterns for single-label domain names are not permitted. + *
+ * + * If hostname pinned directly and via wildcard pattern, both direct and wildcard pins will be used. + * For example: {@code *.example.com} pinned with {@code pin1} and {@code a.example.com} pinned with + * {@code pin2}, to check {@code a.example.com} both {@code pin1} and {@code pin2} will be used. + * + *

Warning: Certificate Pinning is Dangerous!

+ * + *

Pinning certificates limits your server team's abilities to update their TLS certificates. By + * pinning certificates, you take on additional operational complexity and limit your ability to + * migrate between certificate authorities. Do not use certificate pinning without the blessing of + * your server's TLS administrator! + * + *

Note about self-signed certificates

+ * + *

{@link CertificatePinner} can not be used to pin self-signed certificate if such certificate + * is not accepted by {@link javax.net.ssl.TrustManager}. + * + * @see OWASP: + * Certificate and Public Key Pinning + */ +public final class CertificatePinner { + public static final CertificatePinner DEFAULT = new Builder().build(); + + private final Map> hostnameToPins; + + private CertificatePinner(Builder builder) { + hostnameToPins = Util.immutableMap(builder.hostnameToPins); + } + + /** + * Confirms that at least one of the certificates pinned for {@code hostname} is in {@code + * peerCertificates}. Does nothing if there are no certificates pinned for {@code hostname}. + * OkHttp calls this after a successful TLS handshake, but before the connection is used. + * + * @throws SSLPeerUnverifiedException if {@code peerCertificates} don't match the certificates + * pinned for {@code hostname}. + */ + public void check(String hostname, List peerCertificates) + throws SSLPeerUnverifiedException { + + Set pins = findMatchingPins(hostname); + + if (pins == null) return; + + for (int i = 0, size = peerCertificates.size(); i < size; i++) { + X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(i); + if (pins.contains(sha1(x509Certificate))) return; // Success! + } + + // If we couldn't find a matching pin, format a nice exception. + StringBuilder message = new StringBuilder() + .append("Certificate pinning failure!") + .append("\n Peer certificate chain:"); + for (int i = 0, size = peerCertificates.size(); i < size; i++) { + X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(i); + message.append("\n ").append(pin(x509Certificate)) + .append(": ").append(x509Certificate.getSubjectDN().getName()); + } + message.append("\n Pinned certificates for ").append(hostname).append(":"); + for (ByteString pin : pins) { + message.append("\n sha1/").append(pin.base64()); + } + throw new SSLPeerUnverifiedException(message.toString()); + } + + /** @deprecated replaced with {@link #check(String, List)}. */ + public void check(String hostname, Certificate... peerCertificates) + throws SSLPeerUnverifiedException { + check(hostname, Arrays.asList(peerCertificates)); + } + + /** + * Returns list of matching certificates' pins for the hostname or {@code null} if hostname does + * not have pinned certificates. + */ + Set findMatchingPins(String hostname) { + Set directPins = hostnameToPins.get(hostname); + Set wildcardPins = null; + + int indexOfFirstDot = hostname.indexOf('.'); + int indexOfLastDot = hostname.lastIndexOf('.'); + + // Skip hostnames with one dot symbol for wildcard pattern search + // example.com will be skipped + // a.example.com won't be skipped + if (indexOfFirstDot != indexOfLastDot) { + // a.example.com -> search for wildcard pattern *.example.com + wildcardPins = hostnameToPins.get("*." + hostname.substring(indexOfFirstDot + 1)); + } + + if (directPins == null && wildcardPins == null) return null; + + if (directPins != null && wildcardPins != null) { + Set pins = new LinkedHashSet<>(); + pins.addAll(directPins); + pins.addAll(wildcardPins); + return pins; + } + + if (directPins != null) return directPins; + + return wildcardPins; + } + + /** + * Returns the SHA-1 of {@code certificate}'s public key. This uses the mechanism Moxie + * Marlinspike describes in Android + * Pinning. + */ + public static String pin(Certificate certificate) { + if (!(certificate instanceof X509Certificate)) { + throw new IllegalArgumentException("Certificate pinning requires X509 certificates"); + } + return "sha1/" + sha1((X509Certificate) certificate).base64(); + } + + private static ByteString sha1(X509Certificate x509Certificate) { + return Util.sha1(ByteString.of(x509Certificate.getPublicKey().getEncoded())); + } + + /** Builds a configured certificate pinner. */ + public static final class Builder { + private final Map> hostnameToPins = new LinkedHashMap<>(); + + /** + * Pins certificates for {@code hostname}. + * + * @param hostname lower-case host name or wildcard pattern such as {@code *.example.com}. + * @param pins SHA-1 hashes. Each pin is a SHA-1 hash of a certificate's Subject Public Key + * Info, base64-encoded and prefixed with {@code sha1/}. + */ + public Builder add(String hostname, String... pins) { + if (hostname == null) throw new IllegalArgumentException("hostname == null"); + + Set hostPins = new LinkedHashSet<>(); + Set previousPins = hostnameToPins.put(hostname, unmodifiableSet(hostPins)); + if (previousPins != null) { + hostPins.addAll(previousPins); + } + + for (String pin : pins) { + if (!pin.startsWith("sha1/")) { + throw new IllegalArgumentException("pins must start with 'sha1/': " + pin); + } + ByteString decodedPin = ByteString.decodeBase64(pin.substring("sha1/".length())); + if (decodedPin == null) { + throw new IllegalArgumentException("pins must be base64: " + pin); + } + hostPins.add(decodedPin); + } + + return this; + } + + public CertificatePinner build() { + return new CertificatePinner(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Challenge.java b/okhttp3/src/main/java/okhttp3/Challenge.java new file mode 100644 index 000000000..f4bb369c4 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Challenge.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import okhttp3.internal.Util; + +/** An RFC 2617 challenge. */ +public final class Challenge { + private final String scheme; + private final String realm; + + public Challenge(String scheme, String realm) { + this.scheme = scheme; + this.realm = realm; + } + + /** Returns the authentication scheme, like {@code Basic}. */ + public String scheme() { + return scheme; + } + + /** Returns the protection space. */ + public String realm() { + return realm; + } + + @Override public boolean equals(Object o) { + return o instanceof Challenge + && Util.equal(scheme, ((Challenge) o).scheme) + && Util.equal(realm, ((Challenge) o).realm); + } + + @Override public int hashCode() { + int result = 29; + result = 31 * result + (realm != null ? realm.hashCode() : 0); + result = 31 * result + (scheme != null ? scheme.hashCode() : 0); + return result; + } + + @Override public String toString() { + return scheme + " realm=\"" + realm + "\""; + } +} diff --git a/okhttp3/src/main/java/okhttp3/CipherSuite.java b/okhttp3/src/main/java/okhttp3/CipherSuite.java new file mode 100644 index 000000000..6697cad84 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/CipherSuite.java @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import static java.lang.Integer.MAX_VALUE; + +/** + * TLS cipher + * suites. + * + *

Not all cipher suites are supported on all platforms. As newer cipher suites + * are created (for stronger privacy, better performance, etc.) they will be adopted by the platform + * and then exposed here. Cipher suites that are not available on either Android (through API level + * 20) or Java (through JDK 8) are omitted for brevity. + * + *

See also NativeCrypto.java + * from conscrypt, which lists the cipher suites supported by Android. + */ +public enum CipherSuite { + // Last updated 2014-11-11 using cipher suites from Android 21 and Java 8. + + // TLS_NULL_WITH_NULL_NULL("TLS_NULL_WITH_NULL_NULL", 0x0000, 5246, MAX_VALUE, MAX_VALUE), + TLS_RSA_WITH_NULL_MD5("SSL_RSA_WITH_NULL_MD5", 0x0001, 5246, 6, 10), + TLS_RSA_WITH_NULL_SHA("SSL_RSA_WITH_NULL_SHA", 0x0002, 5246, 6, 10), + TLS_RSA_EXPORT_WITH_RC4_40_MD5("SSL_RSA_EXPORT_WITH_RC4_40_MD5", 0x0003, 4346, 6, 10), + TLS_RSA_WITH_RC4_128_MD5("SSL_RSA_WITH_RC4_128_MD5", 0x0004, 5246, 6, 10), + TLS_RSA_WITH_RC4_128_SHA("SSL_RSA_WITH_RC4_128_SHA", 0x0005, 5246, 6, 10), + // TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5("SSL_RSA_EXPORT_WITH_RC2_CBC_40_MD5", 0x0006, 4346, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_IDEA_CBC_SHA("TLS_RSA_WITH_IDEA_CBC_SHA", 0x0007, 5469, MAX_VALUE, MAX_VALUE), + TLS_RSA_EXPORT_WITH_DES40_CBC_SHA("SSL_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x0008, 4346, 6, 10), + TLS_RSA_WITH_DES_CBC_SHA("SSL_RSA_WITH_DES_CBC_SHA", 0x0009, 5469, 6, 10), + TLS_RSA_WITH_3DES_EDE_CBC_SHA("SSL_RSA_WITH_3DES_EDE_CBC_SHA", 0x000a, 5246, 6, 10), + // TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA("SSL_DH_DSS_EXPORT_WITH_DES40_CBC_SHA", 0x000b, 4346, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_DES_CBC_SHA("TLS_DH_DSS_WITH_DES_CBC_SHA", 0x000c, 5469, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA("TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA", 0x000d, 5246, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA("SSL_DH_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x000e, 4346, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_DES_CBC_SHA("TLS_DH_RSA_WITH_DES_CBC_SHA", 0x000f, 5469, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA("TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA", 0x0010, 5246, MAX_VALUE, MAX_VALUE), + TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA("SSL_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA", 0x0011, 4346, 6, 10), + TLS_DHE_DSS_WITH_DES_CBC_SHA("SSL_DHE_DSS_WITH_DES_CBC_SHA", 0x0012, 5469, 6, 10), + TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA("SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA", 0x0013, 5246, 6, 10), + TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA("SSL_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x0014, 4346, 6, 10), + TLS_DHE_RSA_WITH_DES_CBC_SHA("SSL_DHE_RSA_WITH_DES_CBC_SHA", 0x0015, 5469, 6, 10), + TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA("SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA", 0x0016, 5246, 6, 10), + TLS_DH_anon_EXPORT_WITH_RC4_40_MD5("SSL_DH_anon_EXPORT_WITH_RC4_40_MD5", 0x0017, 4346, 6, 10), + TLS_DH_anon_WITH_RC4_128_MD5("SSL_DH_anon_WITH_RC4_128_MD5", 0x0018, 5246, 6, 10), + TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA("SSL_DH_anon_EXPORT_WITH_DES40_CBC_SHA", 0x0019, 4346, 6, 10), + TLS_DH_anon_WITH_DES_CBC_SHA("SSL_DH_anon_WITH_DES_CBC_SHA", 0x001a, 5469, 6, 10), + TLS_DH_anon_WITH_3DES_EDE_CBC_SHA("SSL_DH_anon_WITH_3DES_EDE_CBC_SHA", 0x001b, 5246, 6, 10), + TLS_KRB5_WITH_DES_CBC_SHA("TLS_KRB5_WITH_DES_CBC_SHA", 0x001e, 2712, 6, MAX_VALUE), + TLS_KRB5_WITH_3DES_EDE_CBC_SHA("TLS_KRB5_WITH_3DES_EDE_CBC_SHA", 0x001f, 2712, 6, MAX_VALUE), + TLS_KRB5_WITH_RC4_128_SHA("TLS_KRB5_WITH_RC4_128_SHA", 0x0020, 2712, 6, MAX_VALUE), + // TLS_KRB5_WITH_IDEA_CBC_SHA("TLS_KRB5_WITH_IDEA_CBC_SHA", 0x0021, 2712, MAX_VALUE, MAX_VALUE), + TLS_KRB5_WITH_DES_CBC_MD5("TLS_KRB5_WITH_DES_CBC_MD5", 0x0022, 2712, 6, MAX_VALUE), + TLS_KRB5_WITH_3DES_EDE_CBC_MD5("TLS_KRB5_WITH_3DES_EDE_CBC_MD5", 0x0023, 2712, 6, MAX_VALUE), + TLS_KRB5_WITH_RC4_128_MD5("TLS_KRB5_WITH_RC4_128_MD5", 0x0024, 2712, 6, MAX_VALUE), + // TLS_KRB5_WITH_IDEA_CBC_MD5("TLS_KRB5_WITH_IDEA_CBC_MD5", 0x0025, 2712, MAX_VALUE, MAX_VALUE), + TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA("TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA", 0x0026, 2712, 6, MAX_VALUE), + // TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA("TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA", 0x0027, 2712, MAX_VALUE, MAX_VALUE), + TLS_KRB5_EXPORT_WITH_RC4_40_SHA("TLS_KRB5_EXPORT_WITH_RC4_40_SHA", 0x0028, 2712, 6, MAX_VALUE), + TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5("TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5", 0x0029, 2712, 6, MAX_VALUE), + // TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5("TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5", 0x002a, 2712, MAX_VALUE, MAX_VALUE), + TLS_KRB5_EXPORT_WITH_RC4_40_MD5("TLS_KRB5_EXPORT_WITH_RC4_40_MD5", 0x002b, 2712, 6, MAX_VALUE), + // TLS_PSK_WITH_NULL_SHA("TLS_PSK_WITH_NULL_SHA", 0x002c, 4785, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_NULL_SHA("TLS_DHE_PSK_WITH_NULL_SHA", 0x002d, 4785, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_NULL_SHA("TLS_RSA_PSK_WITH_NULL_SHA", 0x002e, 4785, MAX_VALUE, MAX_VALUE), + TLS_RSA_WITH_AES_128_CBC_SHA("TLS_RSA_WITH_AES_128_CBC_SHA", 0x002f, 5246, 6, 10), + // TLS_DH_DSS_WITH_AES_128_CBC_SHA("TLS_DH_DSS_WITH_AES_128_CBC_SHA", 0x0030, 5246, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_AES_128_CBC_SHA("TLS_DH_RSA_WITH_AES_128_CBC_SHA", 0x0031, 5246, MAX_VALUE, MAX_VALUE), + TLS_DHE_DSS_WITH_AES_128_CBC_SHA("TLS_DHE_DSS_WITH_AES_128_CBC_SHA", 0x0032, 5246, 6, 10), + TLS_DHE_RSA_WITH_AES_128_CBC_SHA("TLS_DHE_RSA_WITH_AES_128_CBC_SHA", 0x0033, 5246, 6, 10), + TLS_DH_anon_WITH_AES_128_CBC_SHA("TLS_DH_anon_WITH_AES_128_CBC_SHA", 0x0034, 5246, 6, 10), + TLS_RSA_WITH_AES_256_CBC_SHA("TLS_RSA_WITH_AES_256_CBC_SHA", 0x0035, 5246, 6, 10), + // TLS_DH_DSS_WITH_AES_256_CBC_SHA("TLS_DH_DSS_WITH_AES_256_CBC_SHA", 0x0036, 5246, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_AES_256_CBC_SHA("TLS_DH_RSA_WITH_AES_256_CBC_SHA", 0x0037, 5246, MAX_VALUE, MAX_VALUE), + TLS_DHE_DSS_WITH_AES_256_CBC_SHA("TLS_DHE_DSS_WITH_AES_256_CBC_SHA", 0x0038, 5246, 6, 10), + TLS_DHE_RSA_WITH_AES_256_CBC_SHA("TLS_DHE_RSA_WITH_AES_256_CBC_SHA", 0x0039, 5246, 6, 10), + TLS_DH_anon_WITH_AES_256_CBC_SHA("TLS_DH_anon_WITH_AES_256_CBC_SHA", 0x003a, 5246, 6, 10), + TLS_RSA_WITH_NULL_SHA256("TLS_RSA_WITH_NULL_SHA256", 0x003b, 5246, 7, 21), + TLS_RSA_WITH_AES_128_CBC_SHA256("TLS_RSA_WITH_AES_128_CBC_SHA256", 0x003c, 5246, 7, 21), + TLS_RSA_WITH_AES_256_CBC_SHA256("TLS_RSA_WITH_AES_256_CBC_SHA256", 0x003d, 5246, 7, 21), + // TLS_DH_DSS_WITH_AES_128_CBC_SHA256("TLS_DH_DSS_WITH_AES_128_CBC_SHA256", 0x003e, 5246, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_AES_128_CBC_SHA256("TLS_DH_RSA_WITH_AES_128_CBC_SHA256", 0x003f, 5246, MAX_VALUE, MAX_VALUE), + TLS_DHE_DSS_WITH_AES_128_CBC_SHA256("TLS_DHE_DSS_WITH_AES_128_CBC_SHA256", 0x0040, 5246, 7, 21), + // TLS_RSA_WITH_CAMELLIA_128_CBC_SHA("TLS_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0041, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA("TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA", 0x0042, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA("TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0043, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA("TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA", 0x0044, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA("TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0045, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA("TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA", 0x0046, 5932, MAX_VALUE, MAX_VALUE), + TLS_DHE_RSA_WITH_AES_128_CBC_SHA256("TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", 0x0067, 5246, 7, 21), + // TLS_DH_DSS_WITH_AES_256_CBC_SHA256("TLS_DH_DSS_WITH_AES_256_CBC_SHA256", 0x0068, 5246, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_AES_256_CBC_SHA256("TLS_DH_RSA_WITH_AES_256_CBC_SHA256", 0x0069, 5246, MAX_VALUE, MAX_VALUE), + TLS_DHE_DSS_WITH_AES_256_CBC_SHA256("TLS_DHE_DSS_WITH_AES_256_CBC_SHA256", 0x006a, 5246, 7, 21), + TLS_DHE_RSA_WITH_AES_256_CBC_SHA256("TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", 0x006b, 5246, 7, 21), + TLS_DH_anon_WITH_AES_128_CBC_SHA256("TLS_DH_anon_WITH_AES_128_CBC_SHA256", 0x006c, 5246, 7, 21), + TLS_DH_anon_WITH_AES_256_CBC_SHA256("TLS_DH_anon_WITH_AES_256_CBC_SHA256", 0x006d, 5246, 7, 21), + // TLS_RSA_WITH_CAMELLIA_256_CBC_SHA("TLS_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0084, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA("TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA", 0x0085, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA("TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0086, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA("TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA", 0x0087, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA("TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0088, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA("TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA", 0x0089, 5932, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_RC4_128_SHA("TLS_PSK_WITH_RC4_128_SHA", 0x008a, 4279, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_3DES_EDE_CBC_SHA("TLS_PSK_WITH_3DES_EDE_CBC_SHA", 0x008b, 4279, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_128_CBC_SHA("TLS_PSK_WITH_AES_128_CBC_SHA", 0x008c, 4279, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_256_CBC_SHA("TLS_PSK_WITH_AES_256_CBC_SHA", 0x008d, 4279, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_RC4_128_SHA("TLS_DHE_PSK_WITH_RC4_128_SHA", 0x008e, 4279, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA("TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA", 0x008f, 4279, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_128_CBC_SHA("TLS_DHE_PSK_WITH_AES_128_CBC_SHA", 0x0090, 4279, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_256_CBC_SHA("TLS_DHE_PSK_WITH_AES_256_CBC_SHA", 0x0091, 4279, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_RC4_128_SHA("TLS_RSA_PSK_WITH_RC4_128_SHA", 0x0092, 4279, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA("TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA", 0x0093, 4279, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_AES_128_CBC_SHA("TLS_RSA_PSK_WITH_AES_128_CBC_SHA", 0x0094, 4279, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_AES_256_CBC_SHA("TLS_RSA_PSK_WITH_AES_256_CBC_SHA", 0x0095, 4279, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_SEED_CBC_SHA("TLS_RSA_WITH_SEED_CBC_SHA", 0x0096, 4162, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_SEED_CBC_SHA("TLS_DH_DSS_WITH_SEED_CBC_SHA", 0x0097, 4162, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_SEED_CBC_SHA("TLS_DH_RSA_WITH_SEED_CBC_SHA", 0x0098, 4162, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_SEED_CBC_SHA("TLS_DHE_DSS_WITH_SEED_CBC_SHA", 0x0099, 4162, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_SEED_CBC_SHA("TLS_DHE_RSA_WITH_SEED_CBC_SHA", 0x009a, 4162, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_SEED_CBC_SHA("TLS_DH_anon_WITH_SEED_CBC_SHA", 0x009b, 4162, MAX_VALUE, MAX_VALUE), + TLS_RSA_WITH_AES_128_GCM_SHA256("TLS_RSA_WITH_AES_128_GCM_SHA256", 0x009c, 5288, 8, 21), + TLS_RSA_WITH_AES_256_GCM_SHA384("TLS_RSA_WITH_AES_256_GCM_SHA384", 0x009d, 5288, 8, 21), + TLS_DHE_RSA_WITH_AES_128_GCM_SHA256("TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", 0x009e, 5288, 8, 21), + TLS_DHE_RSA_WITH_AES_256_GCM_SHA384("TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", 0x009f, 5288, 8, 21), + // TLS_DH_RSA_WITH_AES_128_GCM_SHA256("TLS_DH_RSA_WITH_AES_128_GCM_SHA256", 0x00a0, 5288, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_AES_256_GCM_SHA384("TLS_DH_RSA_WITH_AES_256_GCM_SHA384", 0x00a1, 5288, MAX_VALUE, MAX_VALUE), + TLS_DHE_DSS_WITH_AES_128_GCM_SHA256("TLS_DHE_DSS_WITH_AES_128_GCM_SHA256", 0x00a2, 5288, 8, 21), + TLS_DHE_DSS_WITH_AES_256_GCM_SHA384("TLS_DHE_DSS_WITH_AES_256_GCM_SHA384", 0x00a3, 5288, 8, 21), + // TLS_DH_DSS_WITH_AES_128_GCM_SHA256("TLS_DH_DSS_WITH_AES_128_GCM_SHA256", 0x00a4, 5288, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_AES_256_GCM_SHA384("TLS_DH_DSS_WITH_AES_256_GCM_SHA384", 0x00a5, 5288, MAX_VALUE, MAX_VALUE), + TLS_DH_anon_WITH_AES_128_GCM_SHA256("TLS_DH_anon_WITH_AES_128_GCM_SHA256", 0x00a6, 5288, 8, 21), + TLS_DH_anon_WITH_AES_256_GCM_SHA384("TLS_DH_anon_WITH_AES_256_GCM_SHA384", 0x00a7, 5288, 8, 21), + // TLS_PSK_WITH_AES_128_GCM_SHA256("TLS_PSK_WITH_AES_128_GCM_SHA256", 0x00a8, 5487, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_256_GCM_SHA384("TLS_PSK_WITH_AES_256_GCM_SHA384", 0x00a9, 5487, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_128_GCM_SHA256("TLS_DHE_PSK_WITH_AES_128_GCM_SHA256", 0x00aa, 5487, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_256_GCM_SHA384("TLS_DHE_PSK_WITH_AES_256_GCM_SHA384", 0x00ab, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_AES_128_GCM_SHA256("TLS_RSA_PSK_WITH_AES_128_GCM_SHA256", 0x00ac, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_AES_256_GCM_SHA384("TLS_RSA_PSK_WITH_AES_256_GCM_SHA384", 0x00ad, 5487, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_128_CBC_SHA256("TLS_PSK_WITH_AES_128_CBC_SHA256", 0x00ae, 5487, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_256_CBC_SHA384("TLS_PSK_WITH_AES_256_CBC_SHA384", 0x00af, 5487, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_NULL_SHA256("TLS_PSK_WITH_NULL_SHA256", 0x00b0, 5487, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_NULL_SHA384("TLS_PSK_WITH_NULL_SHA384", 0x00b1, 5487, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_128_CBC_SHA256("TLS_DHE_PSK_WITH_AES_128_CBC_SHA256", 0x00b2, 5487, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_256_CBC_SHA384("TLS_DHE_PSK_WITH_AES_256_CBC_SHA384", 0x00b3, 5487, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_NULL_SHA256("TLS_DHE_PSK_WITH_NULL_SHA256", 0x00b4, 5487, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_NULL_SHA384("TLS_DHE_PSK_WITH_NULL_SHA384", 0x00b5, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_AES_128_CBC_SHA256("TLS_RSA_PSK_WITH_AES_128_CBC_SHA256", 0x00b6, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_AES_256_CBC_SHA384("TLS_RSA_PSK_WITH_AES_256_CBC_SHA384", 0x00b7, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_NULL_SHA256("TLS_RSA_PSK_WITH_NULL_SHA256", 0x00b8, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_NULL_SHA384("TLS_RSA_PSK_WITH_NULL_SHA384", 0x00b9, 5487, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00ba, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256("TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256", 0x00bb, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bc, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256("TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256", 0x00bd, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00be, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256("TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256", 0x00bf, 5932, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256("TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c0, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256("TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256", 0x00c1, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256("TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c2, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256("TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256", 0x00c3, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256("TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c4, 5932, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256("TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256", 0x00c5, 5932, MAX_VALUE, MAX_VALUE), + TLS_EMPTY_RENEGOTIATION_INFO_SCSV("TLS_EMPTY_RENEGOTIATION_INFO_SCSV", 0x00ff, 5746, 6, 14), + TLS_ECDH_ECDSA_WITH_NULL_SHA("TLS_ECDH_ECDSA_WITH_NULL_SHA", 0xc001, 4492, 7, 14), + TLS_ECDH_ECDSA_WITH_RC4_128_SHA("TLS_ECDH_ECDSA_WITH_RC4_128_SHA", 0xc002, 4492, 7, 14), + TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA("TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA", 0xc003, 4492, 7, 14), + TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA("TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA", 0xc004, 4492, 7, 14), + TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA("TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA", 0xc005, 4492, 7, 14), + TLS_ECDHE_ECDSA_WITH_NULL_SHA("TLS_ECDHE_ECDSA_WITH_NULL_SHA", 0xc006, 4492, 7, 14), + TLS_ECDHE_ECDSA_WITH_RC4_128_SHA("TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", 0xc007, 4492, 7, 14), + TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA("TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", 0xc008, 4492, 7, 14), + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", 0xc009, 4492, 7, 14), + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", 0xc00a, 4492, 7, 14), + TLS_ECDH_RSA_WITH_NULL_SHA("TLS_ECDH_RSA_WITH_NULL_SHA", 0xc00b, 4492, 7, 14), + TLS_ECDH_RSA_WITH_RC4_128_SHA("TLS_ECDH_RSA_WITH_RC4_128_SHA", 0xc00c, 4492, 7, 14), + TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA("TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA", 0xc00d, 4492, 7, 14), + TLS_ECDH_RSA_WITH_AES_128_CBC_SHA("TLS_ECDH_RSA_WITH_AES_128_CBC_SHA", 0xc00e, 4492, 7, 14), + TLS_ECDH_RSA_WITH_AES_256_CBC_SHA("TLS_ECDH_RSA_WITH_AES_256_CBC_SHA", 0xc00f, 4492, 7, 14), + TLS_ECDHE_RSA_WITH_NULL_SHA("TLS_ECDHE_RSA_WITH_NULL_SHA", 0xc010, 4492, 7, 14), + TLS_ECDHE_RSA_WITH_RC4_128_SHA("TLS_ECDHE_RSA_WITH_RC4_128_SHA", 0xc011, 4492, 7, 14), + TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA("TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", 0xc012, 4492, 7, 14), + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", 0xc013, 4492, 7, 14), + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", 0xc014, 4492, 7, 14), + TLS_ECDH_anon_WITH_NULL_SHA("TLS_ECDH_anon_WITH_NULL_SHA", 0xc015, 4492, 7, 14), + TLS_ECDH_anon_WITH_RC4_128_SHA("TLS_ECDH_anon_WITH_RC4_128_SHA", 0xc016, 4492, 7, 14), + TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA("TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA", 0xc017, 4492, 7, 14), + TLS_ECDH_anon_WITH_AES_128_CBC_SHA("TLS_ECDH_anon_WITH_AES_128_CBC_SHA", 0xc018, 4492, 7, 14), + TLS_ECDH_anon_WITH_AES_256_CBC_SHA("TLS_ECDH_anon_WITH_AES_256_CBC_SHA", 0xc019, 4492, 7, 14), + // TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA("TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA", 0xc01a, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA("TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA", 0xc01b, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA("TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA", 0xc01c, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_WITH_AES_128_CBC_SHA("TLS_SRP_SHA_WITH_AES_128_CBC_SHA", 0xc01d, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA("TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA", 0xc01e, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA("TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA", 0xc01f, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_WITH_AES_256_CBC_SHA("TLS_SRP_SHA_WITH_AES_256_CBC_SHA", 0xc020, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA("TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA", 0xc021, 5054, MAX_VALUE, MAX_VALUE), + // TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA("TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA", 0xc022, 5054, MAX_VALUE, MAX_VALUE), + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", 0xc023, 5289, 7, 21), + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", 0xc024, 5289, 7, 21), + TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256("TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256", 0xc025, 5289, 7, 21), + TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384("TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384", 0xc026, 5289, 7, 21), + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", 0xc027, 5289, 7, 21), + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", 0xc028, 5289, 7, 21), + TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256("TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256", 0xc029, 5289, 7, 21), + TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384("TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384", 0xc02a, 5289, 7, 21), + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", 0xc02b, 5289, 8, 21), + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", 0xc02c, 5289, 8, 21), + TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256("TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256", 0xc02d, 5289, 8, 21), + TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384("TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384", 0xc02e, 5289, 8, 21), + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", 0xc02f, 5289, 8, 21), + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", 0xc030, 5289, 8, 21), + TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256("TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256", 0xc031, 5289, 8, 21), + TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384("TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384", 0xc032, 5289, 8, 21), + // TLS_ECDHE_PSK_WITH_RC4_128_SHA("TLS_ECDHE_PSK_WITH_RC4_128_SHA", 0xc033, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA("TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA", 0xc034, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA("TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA", 0xc035, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA("TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA", 0xc036, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256("TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256", 0xc037, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384("TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384", 0xc038, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_NULL_SHA("TLS_ECDHE_PSK_WITH_NULL_SHA", 0xc039, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_NULL_SHA256("TLS_ECDHE_PSK_WITH_NULL_SHA256", 0xc03a, 5489, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_NULL_SHA384("TLS_ECDHE_PSK_WITH_NULL_SHA384", 0xc03b, 5489, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_ARIA_128_CBC_SHA256("TLS_RSA_WITH_ARIA_128_CBC_SHA256", 0xc03c, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_ARIA_256_CBC_SHA384("TLS_RSA_WITH_ARIA_256_CBC_SHA384", 0xc03d, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256("TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256", 0xc03e, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384("TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384", 0xc03f, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256("TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256", 0xc040, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384("TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384", 0xc041, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256("TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256", 0xc042, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384("TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384", 0xc043, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256("TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256", 0xc044, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384("TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384", 0xc045, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_ARIA_128_CBC_SHA256("TLS_DH_anon_WITH_ARIA_128_CBC_SHA256", 0xc046, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_ARIA_256_CBC_SHA384("TLS_DH_anon_WITH_ARIA_256_CBC_SHA384", 0xc047, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256("TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256", 0xc048, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384("TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384", 0xc049, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256("TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256", 0xc04a, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384("TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384", 0xc04b, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256("TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256", 0xc04c, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384("TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384", 0xc04d, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256("TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256", 0xc04e, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384("TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384", 0xc04f, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_ARIA_128_GCM_SHA256("TLS_RSA_WITH_ARIA_128_GCM_SHA256", 0xc050, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_ARIA_256_GCM_SHA384("TLS_RSA_WITH_ARIA_256_GCM_SHA384", 0xc051, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256("TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256", 0xc052, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384("TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384", 0xc053, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256("TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256", 0xc054, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384("TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384", 0xc055, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256("TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256", 0xc056, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384("TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384", 0xc057, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256("TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256", 0xc058, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384("TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384", 0xc059, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_ARIA_128_GCM_SHA256("TLS_DH_anon_WITH_ARIA_128_GCM_SHA256", 0xc05a, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_ARIA_256_GCM_SHA384("TLS_DH_anon_WITH_ARIA_256_GCM_SHA384", 0xc05b, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256("TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256", 0xc05c, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384("TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384", 0xc05d, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256("TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256", 0xc05e, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384("TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384", 0xc05f, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256("TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256", 0xc060, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384("TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384", 0xc061, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256("TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256", 0xc062, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384("TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384", 0xc063, 6209, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_ARIA_128_CBC_SHA256("TLS_PSK_WITH_ARIA_128_CBC_SHA256", 0xc064, 6209, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_ARIA_256_CBC_SHA384("TLS_PSK_WITH_ARIA_256_CBC_SHA384", 0xc065, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256("TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256", 0xc066, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384("TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384", 0xc067, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256("TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256", 0xc068, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384("TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384", 0xc069, 6209, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_ARIA_128_GCM_SHA256("TLS_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06a, 6209, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_ARIA_256_GCM_SHA384("TLS_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06b, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_ARIA_128_GCM_SHA256("TLS_DHE_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06c, 6209, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_ARIA_256_GCM_SHA384("TLS_DHE_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06d, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256("TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06e, 6209, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384("TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06f, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256("TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256", 0xc070, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384("TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384", 0xc071, 6209, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc072, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384("TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc073, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc074, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384("TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc075, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc076, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384("TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc077, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256("TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc078, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384("TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc079, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07a, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07b, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_DHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07c, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_DHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07d, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07e, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07f, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_CAMELLIA_128_GCM_SHA256("TLS_DHE_DSS_WITH_CAMELLIA_128_GCM_SHA256", 0xc080, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_DSS_WITH_CAMELLIA_256_GCM_SHA384("TLS_DHE_DSS_WITH_CAMELLIA_256_GCM_SHA384", 0xc081, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256("TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256", 0xc082, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384("TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384", 0xc083, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256("TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256", 0xc084, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384("TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384", 0xc085, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc086, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc087, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc088, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc089, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_ECDHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc08a, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_RSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_ECDHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08b, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256("TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc08c, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384("TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08d, 6367, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256("TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc08e, 6367, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384("TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc08f, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_CAMELLIA_128_GCM_SHA256("TLS_DHE_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc090, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_CAMELLIA_256_GCM_SHA384("TLS_DHE_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc091, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256("TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc092, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384("TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc093, 6367, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256("TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc094, 6367, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384("TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc095, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256("TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc096, 6367, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384("TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc097, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256("TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc098, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384("TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc099, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256("TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc09a, 6367, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384("TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc09b, 6367, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_AES_128_CCM("TLS_RSA_WITH_AES_128_CCM", 0xc09c, 6655, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_AES_256_CCM("TLS_RSA_WITH_AES_256_CCM", 0xc09d, 6655, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_AES_128_CCM("TLS_DHE_RSA_WITH_AES_128_CCM", 0xc09e, 6655, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_AES_256_CCM("TLS_DHE_RSA_WITH_AES_256_CCM", 0xc09f, 6655, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_AES_128_CCM_8("TLS_RSA_WITH_AES_128_CCM_8", 0xc0a0, 6655, MAX_VALUE, MAX_VALUE), + // TLS_RSA_WITH_AES_256_CCM_8("TLS_RSA_WITH_AES_256_CCM_8", 0xc0a1, 6655, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_AES_128_CCM_8("TLS_DHE_RSA_WITH_AES_128_CCM_8", 0xc0a2, 6655, MAX_VALUE, MAX_VALUE), + // TLS_DHE_RSA_WITH_AES_256_CCM_8("TLS_DHE_RSA_WITH_AES_256_CCM_8", 0xc0a3, 6655, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_128_CCM("TLS_PSK_WITH_AES_128_CCM", 0xc0a4, 6655, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_256_CCM("TLS_PSK_WITH_AES_256_CCM", 0xc0a5, 6655, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_128_CCM("TLS_DHE_PSK_WITH_AES_128_CCM", 0xc0a6, 6655, MAX_VALUE, MAX_VALUE), + // TLS_DHE_PSK_WITH_AES_256_CCM("TLS_DHE_PSK_WITH_AES_256_CCM", 0xc0a7, 6655, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_128_CCM_8("TLS_PSK_WITH_AES_128_CCM_8", 0xc0a8, 6655, MAX_VALUE, MAX_VALUE), + // TLS_PSK_WITH_AES_256_CCM_8("TLS_PSK_WITH_AES_256_CCM_8", 0xc0a9, 6655, MAX_VALUE, MAX_VALUE), + // TLS_PSK_DHE_WITH_AES_128_CCM_8("TLS_PSK_DHE_WITH_AES_128_CCM_8", 0xc0aa, 6655, MAX_VALUE, MAX_VALUE), + // TLS_PSK_DHE_WITH_AES_256_CCM_8("TLS_PSK_DHE_WITH_AES_256_CCM_8", 0xc0ab, 6655, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_AES_128_CCM("TLS_ECDHE_ECDSA_WITH_AES_128_CCM", 0xc0ac, 7251, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_AES_256_CCM("TLS_ECDHE_ECDSA_WITH_AES_256_CCM", 0xc0ad, 7251, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8("TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8", 0xc0ae, 7251, MAX_VALUE, MAX_VALUE), + // TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8("TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8", 0xc0af, 7251, MAX_VALUE, MAX_VALUE), + ; + + final String javaName; + + /** + * @param javaName the name used by Java APIs for this cipher suite. Different than the IANA name + * for older cipher suites because the prefix is {@code SSL_} instead of {@code TLS_}. + * @param value the integer identifier for this cipher suite. (Documentation only.) + * @param rfc the RFC describing this cipher suite. (Documentation only.) + * @param sinceJavaVersion the first major Java release supporting this cipher suite. + * @param sinceAndroidVersion the first Android SDK version supporting this cipher suite. + */ + private CipherSuite( + String javaName, int value, int rfc, int sinceJavaVersion, int sinceAndroidVersion) { + this.javaName = javaName; + } + + /** + * Returns the Java name of this cipher suite. For some older cipher suites the Java name has the + * prefix {@code SSL_}, causing the Java name to be different from the {@linkplain #name() enum + * name} which is always prefixed {@code TLS_}. + */ + public String javaName() { + return javaName; + } + + public static CipherSuite forJavaName(String javaName) { + return javaName.startsWith("SSL_") + ? valueOf("TLS_" + javaName.substring(4)) + : valueOf(javaName); + } +} diff --git a/okhttp3/src/main/java/okhttp3/Connection.java b/okhttp3/src/main/java/okhttp3/Connection.java new file mode 100644 index 000000000..de3c22d98 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Connection.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.Socket; + +/** + * The sockets and streams of an HTTP, HTTPS, or HTTPS+SPDY connection. May be used for multiple + * HTTP request/response exchanges. Connections may be direct to the origin server or via a proxy. + * + *

Typically instances of this class are created, connected and exercised automatically by the + * HTTP client. Applications may use this class to monitor HTTP connections as members of a + * {@linkplain ConnectionPool connection pool}. + * + *

Do not confuse this class with the misnamed {@code HttpURLConnection}, which isn't so much a + * connection as a single request/response exchange. + * + *

Modern TLS

+ * + *

There are tradeoffs when selecting which options to include when negotiating a secure + * connection to a remote host. Newer TLS options are quite useful: + * + *

    + *
  • Server Name Indication (SNI) enables one IP address to negotiate secure connections for + * multiple domain names. + *
  • Application Layer Protocol Negotiation (ALPN) enables the HTTPS port (443) to be used for + * different HTTP and SPDY protocols. + *
+ * + *

Unfortunately, older HTTPS servers refuse to connect when such options are presented. Rather + * than avoiding these options entirely, this class allows a connection to be attempted with modern + * options and then retried without them should the attempt fail. + * + *

Connection Reuse

+ * + *

Each connection can carry a varying number streams, depending on the underlying protocol being + * used. HTTP/1.x connections can carry either zero or one streams. HTTP/2 connections can carry any + * number of streams, dynamically configured with {@code SETTINGS_MAX_CONCURRENT_STREAMS}. A + * connection currently carrying zero streams is an idle stream. We keep it alive because reusing an + * existing connection is typically faster than establishing a new one. + * + *

When a single logical call requires multiple streams due to redirects or authorization + * challenges, we prefer to use the same physical connection for all streams in the sequence. There + * are potential performance and behavior consequences to this preference. To support this feature, + * this class separates allocations from streams. An allocation is created by a call, + * used for one or more streams, and then released. An allocated connection won't be stolen by other + * calls while a redirect or authorization challenge is being handled. + * + *

When the maximum concurrent streams limit is reduced, some allocations will be rescinded. + * Attempting to create new streams on these allocations will fail. + * + *

Note that an allocation may be released before its stream is completed. This is intended to + * make bookkeeping easier for the caller: releasing the allocation as soon as the terminal stream + * has been found. But only complete the stream once its data stream has been exhausted. + */ +public interface Connection { + /** Returns the route used by this connection. */ + Route route(); + + /** + * Returns the socket that this connection is using. Returns an {@linkplain + * javax.net.ssl.SSLSocket SSL socket} if this connection is HTTPS. If this is an HTTP/2 or SPDY + * connection the socket may be shared by multiple concurrent calls. + */ + Socket socket(); + + /** + * Returns the TLS handshake used to establish this connection, or null if the connection is not + * HTTPS. + */ + Handshake handshake(); + + /** + * Returns the protocol negotiated by this connection, or {@link Protocol#HTTP_1_1} if no protocol + * has been negotiated. This method returns {@link Protocol#HTTP_1_1} even if the remote peer is + * using {@link Protocol#HTTP_1_0}. + */ + Protocol protocol(); +} diff --git a/okhttp3/src/main/java/okhttp3/ConnectionPool.java b/okhttp3/src/main/java/okhttp3/ConnectionPool.java new file mode 100644 index 000000000..db97676d5 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/ConnectionPool.java @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.lang.ref.Reference; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import okhttp3.internal.Internal; +import okhttp3.internal.RouteDatabase; +import okhttp3.internal.Util; +import okhttp3.internal.http.StreamAllocation; +import okhttp3.internal.io.RealConnection; + +import static okhttp3.internal.Util.closeQuietly; + +/** + * Manages reuse of HTTP and SPDY connections for reduced network latency. HTTP requests that share + * the same {@link Address} may share a {@link Connection}. This class implements the policy of + * which connections to keep open for future use. + */ +public final class ConnectionPool { + /** + * Background threads are used to cleanup expired connections. There will be at most a single + * thread running per connection pool. The thread pool executor permits the pool itself to be + * garbage collected. + */ + private static final Executor executor = new ThreadPoolExecutor(0 /* corePoolSize */, + Integer.MAX_VALUE /* maximumPoolSize */, 60L /* keepAliveTime */, TimeUnit.SECONDS, + new SynchronousQueue(), Util.threadFactory("OkHttp ConnectionPool", true)); + + /** The maximum number of idle connections for each address. */ + private final int maxIdleConnections; + private final long keepAliveDurationNs; + private final Runnable cleanupRunnable = new Runnable() { + @Override public void run() { + while (true) { + long waitNanos = cleanup(System.nanoTime()); + if (waitNanos == -1) return; + if (waitNanos > 0) { + long waitMillis = waitNanos / 1000000L; + waitNanos -= (waitMillis * 1000000L); + synchronized (ConnectionPool.this) { + try { + ConnectionPool.this.wait(waitMillis, (int) waitNanos); + } catch (InterruptedException ignored) { + } + } + } + } + } + }; + + private final Deque connections = new ArrayDeque<>(); + final RouteDatabase routeDatabase = new RouteDatabase(); + boolean cleanupRunning; + + /** + * Create a new connection pool with tuning parameters appropriate for a single-user application. + * The tuning parameters in this pool are subject to change in future OkHttp releases. Currently + * this pool holds up to 5 idle connections which will be evicted after 5 minutes of inactivity. + */ + public ConnectionPool() { + this(5, 5, TimeUnit.MINUTES); + } + + public ConnectionPool(int maxIdleConnections, long keepAliveDuration, TimeUnit timeUnit) { + this.maxIdleConnections = maxIdleConnections; + this.keepAliveDurationNs = timeUnit.toNanos(keepAliveDuration); + + // Put a floor on the keep alive duration, otherwise cleanup will spin loop. + if (keepAliveDuration <= 0) { + throw new IllegalArgumentException("keepAliveDuration <= 0: " + keepAliveDuration); + } + } + + /** Returns the number of idle connections in the pool. */ + public synchronized int idleConnectionCount() { + int total = 0; + for (RealConnection connection : connections) { + if (connection.allocations.isEmpty()) total++; + } + return total; + } + + /** + * Returns total number of connections in the pool. Note that prior to OkHttp 2.7 this included + * only idle connections and SPDY connections. Since OkHttp 2.7 this includes all connections, + * both active and inactive. Use {@link #idleConnectionCount()} to count connections not currently + * in use. + */ + public synchronized int connectionCount() { + return connections.size(); + } + + /** Returns a recycled connection to {@code address}, or null if no such connection exists. */ + RealConnection get(Address address, StreamAllocation streamAllocation) { + assert (Thread.holdsLock(this)); + for (RealConnection connection : connections) { + // TODO(jwilson): this is awkward. We're already holding a lock on 'this', and + // connection.allocationLimit() may also lock the FramedConnection. + if (connection.allocations.size() < connection.allocationLimit() + && address.equals(connection.route().address) + && !connection.noNewStreams) { + streamAllocation.acquire(connection); + return connection; + } + } + return null; + } + + void put(RealConnection connection) { + assert (Thread.holdsLock(this)); + if (!cleanupRunning) { + cleanupRunning = true; + executor.execute(cleanupRunnable); + } + connections.add(connection); + } + + /** + * Notify this pool that {@code connection} has become idle. Returns true if the connection has + * been removed from the pool and should be closed. + */ + boolean connectionBecameIdle(RealConnection connection) { + assert (Thread.holdsLock(this)); + if (connection.noNewStreams || maxIdleConnections == 0) { + connections.remove(connection); + return true; + } else { + notifyAll(); // Awake the cleanup thread: we may have exceeded the idle connection limit. + return false; + } + } + + /** Close and remove all idle connections in the pool. */ + public void evictAll() { + List evictedConnections = new ArrayList<>(); + synchronized (this) { + for (Iterator i = connections.iterator(); i.hasNext(); ) { + RealConnection connection = i.next(); + if (connection.allocations.isEmpty()) { + connection.noNewStreams = true; + evictedConnections.add(connection); + i.remove(); + } + } + } + + for (RealConnection connection : evictedConnections) { + closeQuietly(connection.socket()); + } + } + + /** + * Performs maintenance on this pool, evicting the connection that has been idle the longest if + * either it has exceeded the keep alive limit or the idle connections limit. + * + *

Returns the duration in nanos to sleep until the next scheduled call to this method. Returns + * -1 if no further cleanups are required. + */ + long cleanup(long now) { + int inUseConnectionCount = 0; + int idleConnectionCount = 0; + RealConnection longestIdleConnection = null; + long longestIdleDurationNs = Long.MIN_VALUE; + + // Find either a connection to evict, or the time that the next eviction is due. + synchronized (this) { + for (Iterator i = connections.iterator(); i.hasNext(); ) { + RealConnection connection = i.next(); + + // If the connection is in use, keep searching. + if (pruneAndGetAllocationCount(connection, now) > 0) { + inUseConnectionCount++; + continue; + } + + idleConnectionCount++; + + // If the connection is ready to be evicted, we're done. + long idleDurationNs = now - connection.idleAtNanos; + if (idleDurationNs > longestIdleDurationNs) { + longestIdleDurationNs = idleDurationNs; + longestIdleConnection = connection; + } + } + + if (longestIdleDurationNs >= this.keepAliveDurationNs + || idleConnectionCount > this.maxIdleConnections) { + // We've found a connection to evict. Remove it from the list, then close it below (outside + // of the synchronized block). + connections.remove(longestIdleConnection); + } else if (idleConnectionCount > 0) { + // A connection will be ready to evict soon. + return keepAliveDurationNs - longestIdleDurationNs; + } else if (inUseConnectionCount > 0) { + // All connections are in use. It'll be at least the keep alive duration 'til we run again. + return keepAliveDurationNs; + } else { + // No connections, idle or in use. + cleanupRunning = false; + return -1; + } + } + + closeQuietly(longestIdleConnection.socket()); + + // Cleanup again immediately. + return 0; + } + + /** + * Prunes any leaked allocations and then returns the number of remaining live allocations on + * {@code connection}. Allocations are leaked if the connection is tracking them but the + * application code has abandoned them. Leak detection is imprecise and relies on garbage + * collection. + */ + private int pruneAndGetAllocationCount(RealConnection connection, long now) { + List> references = connection.allocations; + for (int i = 0; i < references.size(); ) { + Reference reference = references.get(i); + + if (reference.get() != null) { + i++; + continue; + } + + // We've discovered a leaked allocation. This is an application bug. + Internal.logger.warning("A connection to " + connection.route().address().url() + + " was leaked. Did you forget to close a response body?"); + references.remove(i); + connection.noNewStreams = true; + + // If this was the last allocation, the connection is eligible for immediate eviction. + if (references.isEmpty()) { + connection.idleAtNanos = now - keepAliveDurationNs; + return 0; + } + } + + return references.size(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/ConnectionSpec.java b/okhttp3/src/main/java/okhttp3/ConnectionSpec.java new file mode 100644 index 000000000..8c99932f9 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/ConnectionSpec.java @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.util.Arrays; +import java.util.List; +import javax.net.ssl.SSLSocket; + +import static okhttp3.internal.Util.concat; +import static okhttp3.internal.Util.contains; +import static okhttp3.internal.Util.immutableList; +import static okhttp3.internal.Util.intersect; + +/** + * Specifies configuration for the socket connection that HTTP traffic travels through. For {@code + * https:} URLs, this includes the TLS version and cipher suites to use when negotiating a secure + * connection. + * + *

The TLS versions configured in a connection spec are only be used if they are also enabled in + * the SSL socket. For example, if an SSL socket does not have TLS 1.2 enabled, it will not be used + * even if it is present on the connection spec. The same policy also applies to cipher suites. + * + *

Use {@link Builder#allEnabledTlsVersions()} and {@link Builder#allEnabledCipherSuites} to + * defer all feature selection to the underlying SSL socket. + */ +public final class ConnectionSpec { + + // This is a subset of the cipher suites supported in Chrome 46, current as of 2015-11-05. + // All of these suites are available on Android 5.0; earlier releases support a subset of + // these suites. https://github.com/square/okhttp/issues/330 + private static final CipherSuite[] APPROVED_CIPHER_SUITES = new CipherSuite[] { + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, + + // Note that the following cipher suites are all on HTTP/2's bad cipher suites list. We'll + // continue to include them until better suites are commonly available. For example, none + // of the better cipher suites listed above shipped with Android 4.4 or Java 7. + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA, + CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA, + CipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA, + }; + + /** A modern TLS connection with extensions like SNI and ALPN available. */ + public static final ConnectionSpec MODERN_TLS = new Builder(true) + .cipherSuites(APPROVED_CIPHER_SUITES) + .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) + .supportsTlsExtensions(true) + .build(); + + /** A backwards-compatible fallback connection for interop with obsolete servers. */ + public static final ConnectionSpec COMPATIBLE_TLS = new Builder(MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_0) + .supportsTlsExtensions(true) + .build(); + + /** Unencrypted, unauthenticated connections for {@code http:} URLs. */ + public static final ConnectionSpec CLEARTEXT = new Builder(false).build(); + + private final boolean tls; + private final boolean supportsTlsExtensions; + private final String[] cipherSuites; + private final String[] tlsVersions; + + private ConnectionSpec(Builder builder) { + this.tls = builder.tls; + this.cipherSuites = builder.cipherSuites; + this.tlsVersions = builder.tlsVersions; + this.supportsTlsExtensions = builder.supportsTlsExtensions; + } + + public boolean isTls() { + return tls; + } + + /** + * Returns the cipher suites to use for a connection. Returns {@code null} if all of the SSL + * socket's enabled cipher suites should be used. + */ + public List cipherSuites() { + if (cipherSuites == null) return null; + + CipherSuite[] result = new CipherSuite[cipherSuites.length]; + for (int i = 0; i < cipherSuites.length; i++) { + result[i] = CipherSuite.forJavaName(cipherSuites[i]); + } + return immutableList(result); + } + + /** + * Returns the TLS versions to use when negotiating a connection. Returns {@code null} if all of + * the SSL socket's enabled TLS versions should be used. + */ + public List tlsVersions() { + if (tlsVersions == null) return null; + + TlsVersion[] result = new TlsVersion[tlsVersions.length]; + for (int i = 0; i < tlsVersions.length; i++) { + result[i] = TlsVersion.forJavaName(tlsVersions[i]); + } + return immutableList(result); + } + + public boolean supportsTlsExtensions() { + return supportsTlsExtensions; + } + + /** Applies this spec to {@code sslSocket}. */ + void apply(SSLSocket sslSocket, boolean isFallback) { + ConnectionSpec specToApply = supportedSpec(sslSocket, isFallback); + + if (specToApply.tlsVersions != null) { + sslSocket.setEnabledProtocols(specToApply.tlsVersions); + } + if (specToApply.cipherSuites != null) { + sslSocket.setEnabledCipherSuites(specToApply.cipherSuites); + } + } + + /** + * Returns a copy of this that omits cipher suites and TLS versions not enabled by {@code + * sslSocket}. + */ + private ConnectionSpec supportedSpec(SSLSocket sslSocket, boolean isFallback) { + String[] cipherSuitesIntersection = cipherSuites != null + ? intersect(String.class, cipherSuites, sslSocket.getEnabledCipherSuites()) + : sslSocket.getEnabledCipherSuites(); + String[] tlsVersionsIntersection = tlsVersions != null + ? intersect(String.class, tlsVersions, sslSocket.getEnabledProtocols()) + : sslSocket.getEnabledProtocols(); + + // In accordance with https://tools.ietf.org/html/draft-ietf-tls-downgrade-scsv-00 + // the SCSV cipher is added to signal that a protocol fallback has taken place. + if (isFallback && contains(sslSocket.getSupportedCipherSuites(), "TLS_FALLBACK_SCSV")) { + cipherSuitesIntersection = concat(cipherSuitesIntersection, "TLS_FALLBACK_SCSV"); + } + + return new Builder(this) + .cipherSuites(cipherSuitesIntersection) + .tlsVersions(tlsVersionsIntersection) + .build(); + } + + /** + * Returns {@code true} if the socket, as currently configured, supports this connection spec. In + * order for a socket to be compatible the enabled cipher suites and protocols must intersect. + * + *

For cipher suites, at least one of the {@link #cipherSuites() required cipher suites} must + * match the socket's enabled cipher suites. If there are no required cipher suites the socket + * must have at least one cipher suite enabled. + * + *

For protocols, at least one of the {@link #tlsVersions() required protocols} must match the + * socket's enabled protocols. + */ + public boolean isCompatible(SSLSocket socket) { + if (!tls) { + return false; + } + + if (tlsVersions != null + && !nonEmptyIntersection(tlsVersions, socket.getEnabledProtocols())) { + return false; + } + + if (cipherSuites != null + && !nonEmptyIntersection(cipherSuites, socket.getEnabledCipherSuites())) { + return false; + } + + return true; + } + + /** + * An N*M intersection that terminates if any intersection is found. The sizes of both arguments + * are assumed to be so small, and the likelihood of an intersection so great, that it is not + * worth the CPU cost of sorting or the memory cost of hashing. + */ + private static boolean nonEmptyIntersection(String[] a, String[] b) { + if (a == null || b == null || a.length == 0 || b.length == 0) { + return false; + } + for (String toFind : a) { + if (contains(b, toFind)) { + return true; + } + } + return false; + } + + @Override public boolean equals(Object other) { + if (!(other instanceof ConnectionSpec)) return false; + if (other == this) return true; + + ConnectionSpec that = (ConnectionSpec) other; + if (this.tls != that.tls) return false; + + if (tls) { + if (!Arrays.equals(this.cipherSuites, that.cipherSuites)) return false; + if (!Arrays.equals(this.tlsVersions, that.tlsVersions)) return false; + if (this.supportsTlsExtensions != that.supportsTlsExtensions) return false; + } + + return true; + } + + @Override public int hashCode() { + int result = 17; + if (tls) { + result = 31 * result + Arrays.hashCode(cipherSuites); + result = 31 * result + Arrays.hashCode(tlsVersions); + result = 31 * result + (supportsTlsExtensions ? 0 : 1); + } + return result; + } + + @Override public String toString() { + if (!tls) { + return "ConnectionSpec()"; + } + + String cipherSuitesString = cipherSuites != null ? cipherSuites().toString() : "[all enabled]"; + String tlsVersionsString = tlsVersions != null ? tlsVersions().toString() : "[all enabled]"; + return "ConnectionSpec(" + + "cipherSuites=" + cipherSuitesString + + ", tlsVersions=" + tlsVersionsString + + ", supportsTlsExtensions=" + supportsTlsExtensions + + ")"; + } + + public static final class Builder { + private boolean tls; + private String[] cipherSuites; + private String[] tlsVersions; + private boolean supportsTlsExtensions; + + Builder(boolean tls) { + this.tls = tls; + } + + public Builder(ConnectionSpec connectionSpec) { + this.tls = connectionSpec.tls; + this.cipherSuites = connectionSpec.cipherSuites; + this.tlsVersions = connectionSpec.tlsVersions; + this.supportsTlsExtensions = connectionSpec.supportsTlsExtensions; + } + + public Builder allEnabledCipherSuites() { + if (!tls) throw new IllegalStateException("no cipher suites for cleartext connections"); + this.cipherSuites = null; + return this; + } + + public Builder cipherSuites(CipherSuite... cipherSuites) { + if (!tls) throw new IllegalStateException("no cipher suites for cleartext connections"); + + String[] strings = new String[cipherSuites.length]; + for (int i = 0; i < cipherSuites.length; i++) { + strings[i] = cipherSuites[i].javaName; + } + return cipherSuites(strings); + } + + public Builder cipherSuites(String... cipherSuites) { + if (!tls) throw new IllegalStateException("no cipher suites for cleartext connections"); + + if (cipherSuites.length == 0) { + throw new IllegalArgumentException("At least one cipher suite is required"); + } + + this.cipherSuites = cipherSuites.clone(); // Defensive copy. + return this; + } + + public Builder allEnabledTlsVersions() { + if (!tls) throw new IllegalStateException("no TLS versions for cleartext connections"); + this.tlsVersions = null; + return this; + } + + public Builder tlsVersions(TlsVersion... tlsVersions) { + if (!tls) throw new IllegalStateException("no TLS versions for cleartext connections"); + + String[] strings = new String[tlsVersions.length]; + for (int i = 0; i < tlsVersions.length; i++) { + strings[i] = tlsVersions[i].javaName; + } + + return tlsVersions(strings); + } + + public Builder tlsVersions(String... tlsVersions) { + if (!tls) throw new IllegalStateException("no TLS versions for cleartext connections"); + + if (tlsVersions.length == 0) { + throw new IllegalArgumentException("At least one TLS version is required"); + } + + this.tlsVersions = tlsVersions.clone(); // Defensive copy. + return this; + } + + public Builder supportsTlsExtensions(boolean supportsTlsExtensions) { + if (!tls) throw new IllegalStateException("no TLS extensions for cleartext connections"); + this.supportsTlsExtensions = supportsTlsExtensions; + return this; + } + + public ConnectionSpec build() { + return new ConnectionSpec(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Cookie.java b/okhttp3/src/main/java/okhttp3/Cookie.java new file mode 100644 index 000000000..12c4584b1 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Cookie.java @@ -0,0 +1,562 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import okhttp3.internal.Util; +import okhttp3.internal.http.HttpDate; + +import static okhttp3.internal.Util.UTC; +import static okhttp3.internal.Util.delimiterOffset; +import static okhttp3.internal.Util.domainToAscii; +import static okhttp3.internal.Util.trimSubstring; +import static okhttp3.internal.Util.verifyAsIpAddress; + +/** + * An RFC 6265 Cookie. + * + *

This class doesn't support additional attributes on cookies, like Chromium's Priority=HIGH + * extension. + */ +public final class Cookie { + private static final Pattern YEAR_PATTERN + = Pattern.compile("(\\d{2,4})[^\\d]*"); + private static final Pattern MONTH_PATTERN + = Pattern.compile("(?i)(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec).*"); + private static final Pattern DAY_OF_MONTH_PATTERN + = Pattern.compile("(\\d{1,2})[^\\d]*"); + private static final Pattern TIME_PATTERN + = Pattern.compile("(\\d{1,2}):(\\d{1,2}):(\\d{1,2})[^\\d]*"); + + private final String name; + private final String value; + private final long expiresAt; + private final String domain; + private final String path; + private final boolean secure; + private final boolean httpOnly; + + private final boolean persistent; // True if 'expires' or 'max-age' is present. + private final boolean hostOnly; // True unless 'domain' is present. + + private Cookie(String name, String value, long expiresAt, String domain, String path, + boolean secure, boolean httpOnly, boolean hostOnly, boolean persistent) { + this.name = name; + this.value = value; + this.expiresAt = expiresAt; + this.domain = domain; + this.path = path; + this.secure = secure; + this.httpOnly = httpOnly; + this.hostOnly = hostOnly; + this.persistent = persistent; + } + + private Cookie(Builder builder) { + if (builder.name == null) throw new IllegalArgumentException("builder.name == null"); + if (builder.value == null) throw new IllegalArgumentException("builder.value == null"); + if (builder.domain == null) throw new IllegalArgumentException("builder.domain == null"); + + this.name = builder.name; + this.value = builder.value; + this.expiresAt = builder.expiresAt; + this.domain = builder.domain; + this.path = builder.path; + this.secure = builder.secure; + this.httpOnly = builder.httpOnly; + this.persistent = builder.persistent; + this.hostOnly = builder.hostOnly; + } + + /** Returns a non-empty string with this cookie's name. */ + public String name() { + return name; + } + + /** Returns a possibly-empty string with this cookie's value. */ + public String value() { + return value; + } + + /** Returns true if this cookie expires at the end of the current session. */ + public boolean persistent() { + return persistent; + } + + /** + * Returns the time that this cookie expires, in the same format as {@link + * System#currentTimeMillis()}. This is December 31, 9999 if the cookie is {@linkplain + * #persistent() not persistent}, in which case it will expire at the end of the current session. + * + *

This may return a value less than the current time, in which case the cookie is already + * expired. Webservers may return expired cookies as a mechanism to delete previously set cookies + * that may or may not themselves be expired. + */ + public long expiresAt() { + return expiresAt; + } + + /** + * Returns true if this cookie's domain should be interpreted as a single host name, or false if + * it should be interpreted as a pattern. This flag will be false if its {@code Set-Cookie} header + * included a {@code domain} attribute. + * + *

For example, suppose the cookie's domain is {@code example.com}. If this flag is true it + * matches only {@code example.com}. If this flag is false it matches {@code + * example.com} and all subdomains including {@code api.example.com}, {@code www.example.com}, and + * {@code beta.api.example.com}. + */ + public boolean hostOnly() { + return hostOnly; + } + + /** + * Returns the cookie's domain. If {@link #hostOnly()} returns true this is the only domain that + * matches this cookie; otherwise it matches this domain and all subdomains. + */ + public String domain() { + return domain; + } + + /** + * Returns this cookie's path. This cookie matches URLs prefixed with path segments that match + * this path's segments. For example, if this path is {@code /foo} this cookie matches requests to + * {@code /foo} and {@code /foo/bar}, but not {@code /} or {@code /football}. + */ + public String path() { + return path; + } + + /** + * Returns true if this cookie should be limited to only HTTP APIs. In web browsers this prevents + * the cookie from being accessible to scripts. + */ + public boolean httpOnly() { + return httpOnly; + } + + /** Returns true if this cookie should be limited to only HTTPS requests. */ + public boolean secure() { + return secure; + } + + /** + * Returns true if this cookie should be included on a request to {@code url}. In addition to this + * check callers should also confirm that this cookie has not expired. + */ + public boolean matches(HttpUrl url) { + boolean domainMatch = hostOnly + ? url.host().equals(domain) + : domainMatch(url, domain); + if (!domainMatch) return false; + + if (!pathMatch(url, path)) return false; + + if (secure && !url.isHttps()) return false; + + return true; + } + + private static boolean domainMatch(HttpUrl url, String domain) { + String urlHost = url.host(); + + if (urlHost.equals(domain)) { + return true; // As in 'example.com' matching 'example.com'. + } + + if (urlHost.endsWith(domain) + && urlHost.charAt(urlHost.length() - domain.length() - 1) == '.' + && !verifyAsIpAddress(urlHost)) { + return true; // As in 'example.com' matching 'www.example.com'. + } + + return false; + } + + private static boolean pathMatch(HttpUrl url, String path) { + String urlPath = url.encodedPath(); + + if (urlPath.equals(path)) { + return true; // As in '/foo' matching '/foo'. + } + + if (urlPath.startsWith(path)) { + if (path.endsWith("/")) return true; // As in '/' matching '/foo'. + if (urlPath.charAt(path.length()) == '/') return true; // As in '/foo' matching '/foo/bar'. + } + + return false; + } + + /** + * Attempt to parse a {@code Set-Cookie} HTTP header value {@code setCookie} as a cookie. Returns + * null if {@code setCookie} is not a well-formed cookie. + */ + public static Cookie parse(HttpUrl url, String setCookie) { + return parse(System.currentTimeMillis(), url, setCookie); + } + + static Cookie parse(long currentTimeMillis, HttpUrl url, String setCookie) { + int pos = 0; + int limit = setCookie.length(); + int cookiePairEnd = delimiterOffset(setCookie, pos, limit, ';'); + + int pairEqualsSign = delimiterOffset(setCookie, pos, cookiePairEnd, '='); + if (pairEqualsSign == cookiePairEnd) return null; + + String cookieName = trimSubstring(setCookie, pos, pairEqualsSign); + if (cookieName.isEmpty()) return null; + + String cookieValue = trimSubstring(setCookie, pairEqualsSign + 1, cookiePairEnd); + + long expiresAt = HttpDate.MAX_DATE; + long deltaSeconds = -1L; + String domain = null; + String path = null; + boolean secureOnly = false; + boolean httpOnly = false; + boolean hostOnly = true; + boolean persistent = false; + + pos = cookiePairEnd + 1; + while (pos < limit) { + int attributePairEnd = delimiterOffset(setCookie, pos, limit, ';'); + + int attributeEqualsSign = delimiterOffset(setCookie, pos, attributePairEnd, '='); + String attributeName = trimSubstring(setCookie, pos, attributeEqualsSign); + String attributeValue = attributeEqualsSign < attributePairEnd + ? trimSubstring(setCookie, attributeEqualsSign + 1, attributePairEnd) + : ""; + + if (attributeName.equalsIgnoreCase("expires")) { + try { + expiresAt = parseExpires(attributeValue, 0, attributeValue.length()); + persistent = true; + } catch (IllegalArgumentException e) { + // Ignore this attribute, it isn't recognizable as a date. + } + } else if (attributeName.equalsIgnoreCase("max-age")) { + try { + deltaSeconds = parseMaxAge(attributeValue); + persistent = true; + } catch (NumberFormatException e) { + // Ignore this attribute, it isn't recognizable as a max age. + } + } else if (attributeName.equalsIgnoreCase("domain")) { + try { + domain = parseDomain(attributeValue); + hostOnly = false; + } catch (IllegalArgumentException e) { + // Ignore this attribute, it isn't recognizable as a domain. + } + } else if (attributeName.equalsIgnoreCase("path")) { + path = attributeValue; + } else if (attributeName.equalsIgnoreCase("secure")) { + secureOnly = true; + } else if (attributeName.equalsIgnoreCase("httponly")) { + httpOnly = true; + } + + pos = attributePairEnd + 1; + } + + // If 'Max-Age' is present, it takes precedence over 'Expires', regardless of the order the two + // attributes are declared in the cookie string. + if (deltaSeconds == Long.MIN_VALUE) { + expiresAt = Long.MIN_VALUE; + } else if (deltaSeconds != -1L) { + long deltaMilliseconds = deltaSeconds <= (Long.MAX_VALUE / 1000) + ? deltaSeconds * 1000 + : Long.MAX_VALUE; + expiresAt = currentTimeMillis + deltaMilliseconds; + if (expiresAt < currentTimeMillis || expiresAt > HttpDate.MAX_DATE) { + expiresAt = HttpDate.MAX_DATE; // Handle overflow & limit the date range. + } + } + + // If the domain is present, it must domain match. Otherwise we have a host-only cookie. + if (domain == null) { + domain = url.host(); + } else if (!domainMatch(url, domain)) { + return null; // No domain match? This is either incompetence or malice! + } + + // If the path is absent or didn't start with '/', use the default path. It's a string like + // '/foo/bar' for a URL like 'http://example.com/foo/bar/baz'. It always starts with '/'. + if (path == null || !path.startsWith("/")) { + String encodedPath = url.encodedPath(); + int lastSlash = encodedPath.lastIndexOf('/'); + path = lastSlash != 0 ? encodedPath.substring(0, lastSlash) : "/"; + } + + return new Cookie(cookieName, cookieValue, expiresAt, domain, path, secureOnly, httpOnly, + hostOnly, persistent); + } + + /** Parse a date as specified in RFC 6265, section 5.1.1. */ + private static long parseExpires(String s, int pos, int limit) { + pos = dateCharacterOffset(s, pos, limit, false); + + int hour = -1; + int minute = -1; + int second = -1; + int dayOfMonth = -1; + int month = -1; + int year = -1; + Matcher matcher = TIME_PATTERN.matcher(s); + + while (pos < limit) { + int end = dateCharacterOffset(s, pos + 1, limit, true); + matcher.region(pos, end); + + if (hour == -1 && matcher.usePattern(TIME_PATTERN).matches()) { + hour = Integer.parseInt(matcher.group(1)); + minute = Integer.parseInt(matcher.group(2)); + second = Integer.parseInt(matcher.group(3)); + } else if (dayOfMonth == -1 && matcher.usePattern(DAY_OF_MONTH_PATTERN).matches()) { + dayOfMonth = Integer.parseInt(matcher.group(1)); + } else if (month == -1 && matcher.usePattern(MONTH_PATTERN).matches()) { + String monthString = matcher.group(1).toLowerCase(Locale.US); + month = MONTH_PATTERN.pattern().indexOf(monthString) / 4; // Sneaky! jan=1, dec=12. + } else if (year == -1 && matcher.usePattern(YEAR_PATTERN).matches()) { + year = Integer.parseInt(matcher.group(1)); + } + + pos = dateCharacterOffset(s, end + 1, limit, false); + } + + // Convert two-digit years into four-digit years. 99 becomes 1999, 15 becomes 2015. + if (year >= 70 && year <= 99) year += 1900; + if (year >= 0 && year <= 69) year += 2000; + + // If any partial is omitted or out of range, return -1. The date is impossible. Note that leap + // seconds are not supported by this syntax. + if (year < 1601) throw new IllegalArgumentException(); + if (month == -1) throw new IllegalArgumentException(); + if (dayOfMonth < 1 || dayOfMonth > 31) throw new IllegalArgumentException(); + if (hour < 0 || hour > 23) throw new IllegalArgumentException(); + if (minute < 0 || minute > 59) throw new IllegalArgumentException(); + if (second < 0 || second > 59) throw new IllegalArgumentException(); + + Calendar calendar = new GregorianCalendar(UTC); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, 0); + return calendar.getTimeInMillis(); + } + + /** + * Returns the index of the next date character in {@code input}, or if {@code invert} the index + * of the next non-date character in {@code input}. + */ + private static int dateCharacterOffset(String input, int pos, int limit, boolean invert) { + for (int i = pos; i < limit; i++) { + int c = input.charAt(i); + boolean dateCharacter = (c < ' ' && c != '\t') || (c >= '\u007f') + || (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c == ':'); + if (dateCharacter == !invert) return i; + } + return limit; + } + + /** + * Returns the positive value if {@code attributeValue} is positive, or {@link Long#MIN_VALUE} if + * it is either 0 or negative. If the value is positive but out of range, this returns {@link + * Long#MAX_VALUE}. + * + * @throws NumberFormatException if {@code s} is not an integer of any precision. + */ + private static long parseMaxAge(String s) { + try { + long parsed = Long.parseLong(s); + return parsed <= 0L ? Long.MIN_VALUE : parsed; + } catch (NumberFormatException e) { + // Check if the value is an integer (positive or negative) that's too big for a long. + if (s.matches("-?\\d+")) { + return s.startsWith("-") ? Long.MIN_VALUE : Long.MAX_VALUE; + } + throw e; + } + } + + /** + * Returns a domain string like {@code example.com} for an input domain like {@code EXAMPLE.COM} + * or {@code .example.com}. + */ + private static String parseDomain(String s) { + if (s.endsWith(".")) { + throw new IllegalArgumentException(); + } + if (s.startsWith(".")) { + s = s.substring(1); + } + String canonicalDomain = domainToAscii(s); + if (canonicalDomain == null) { + throw new IllegalArgumentException(); + } + return canonicalDomain; + } + + /** Returns all of the cookies from a set of HTTP response headers. */ + public static List parseAll(HttpUrl url, Headers headers) { + List cookieStrings = headers.values("Set-Cookie"); + List cookies = null; + + for (int i = 0, size = cookieStrings.size(); i < size; i++) { + Cookie cookie = Cookie.parse(url, cookieStrings.get(i)); + if (cookie == null) continue; + if (cookies == null) cookies = new ArrayList<>(); + cookies.add(cookie); + } + + return cookies != null + ? Collections.unmodifiableList(cookies) + : Collections.emptyList(); + } + + /** + * Builds a cookie. The {@linkplain #name() name}, {@linkplain #value() value}, and {@linkplain + * #domain() domain} values must all be set before calling {@link #build}. + */ + public static final class Builder { + String name; + String value; + long expiresAt = HttpDate.MAX_DATE; + String domain; + String path = "/"; + boolean secure; + boolean httpOnly; + boolean persistent; + boolean hostOnly; + + public Builder name(String name) { + if (name == null) throw new NullPointerException("name == null"); + if (!name.trim().equals(name)) throw new IllegalArgumentException("name is not trimmed"); + this.name = name; + return this; + } + + public Builder value(String value) { + if (value == null) throw new NullPointerException("value == null"); + if (!value.trim().equals(value)) throw new IllegalArgumentException("value is not trimmed"); + this.value = value; + return this; + } + + public Builder expiresAt(long expiresAt) { + if (expiresAt <= 0) expiresAt = Long.MIN_VALUE; + if (expiresAt > HttpDate.MAX_DATE) expiresAt = HttpDate.MAX_DATE; + this.expiresAt = expiresAt; + this.persistent = true; + return this; + } + + /** + * Set the domain pattern for this cookie. The cookie will match {@code domain} and all of its + * subdomains. + */ + public Builder domain(String domain) { + return domain(domain, false); + } + + /** + * Set the host-only domain for this cookie. The cookie will match {@code domain} but none of + * its subdomains. + */ + public Builder hostOnlyDomain(String domain) { + return domain(domain, true); + } + + private Builder domain(String domain, boolean hostOnly) { + if (domain == null) throw new IllegalArgumentException("domain == null"); + String canonicalDomain = Util.domainToAscii(domain); + if (canonicalDomain == null) { + throw new IllegalArgumentException("unexpected domain: " + domain); + } + this.domain = canonicalDomain; + this.hostOnly = hostOnly; + return this; + } + + public Builder path(String path) { + if (!path.startsWith("/")) throw new IllegalArgumentException("path must start with '/'"); + this.path = path; + return this; + } + + public Builder secure() { + this.secure = true; + return this; + } + + public Builder httpOnly() { + this.httpOnly = true; + return this; + } + + public Cookie build() { + return new Cookie(this); + } + } + + @Override public String toString() { + StringBuilder result = new StringBuilder(); + result.append(name); + result.append('='); + result.append(value); + + if (persistent) { + if (expiresAt == Long.MIN_VALUE) { + result.append("; max-age=0"); + } else { + result.append("; expires=").append(HttpDate.format(new Date(expiresAt))); + } + } + + if (!hostOnly) { + result.append("; domain=").append(domain); + } + + result.append("; path=").append(path); + + if (secure) { + result.append("; secure"); + } + + if (httpOnly) { + result.append("; httponly"); + } + + return result.toString(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/CookieJar.java b/okhttp3/src/main/java/okhttp3/CookieJar.java new file mode 100644 index 000000000..7f6239219 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/CookieJar.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.util.Collections; +import java.util.List; + +/** + * Provides policy and persistence for HTTP cookies. + * + *

As policy, implementations of this interface are responsible for selecting which cookies to + * accept and which to reject. A reasonable policy is to reject all cookies, though that may be + * interfere with session-based authentication schemes that require cookies. + * + *

As persistence, implementations of this interface must also provide storage of cookies. Simple + * implementations may store cookies in memory; sophisticated ones may use the file system or + * database to hold accepted cookies. The cookie storage model specifies + * policies for updating and expiring cookies. + */ +public interface CookieJar { + /** A cookie jar that never accepts any cookies. */ + CookieJar NO_COOKIES = new CookieJar() { + @Override public void saveFromResponse(HttpUrl url, List cookies) { + } + + @Override public List loadForRequest(HttpUrl url, Request request) { + return Collections.emptyList(); + } + }; + + /** + * Saves {@code cookies} from an HTTP response to this store according to this jar's policy. + * + *

Note that this method may be called a second time for a single HTTP response if the response + * includes a trailer. For this obscure HTTP feature, {@code cookies} contains only the trailer's + * cookies. + */ + void saveFromResponse(HttpUrl url, List cookies); + + /** + * Load cookies from the jar for an HTTP request to {@code url}. This method returns a possibly + * empty list of cookies for the network request. + * + *

Simple implementations will return the accepted cookies that have not yet expired and that + * {@linkplain Cookie#matches match} {@code url}. + */ + List loadForRequest(HttpUrl url, Request request); +} diff --git a/okhttp3/src/main/java/okhttp3/Credentials.java b/okhttp3/src/main/java/okhttp3/Credentials.java new file mode 100644 index 000000000..9be4e6a22 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Credentials.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.UnsupportedEncodingException; +import okio.ByteString; + +/** Factory for HTTP authorization credentials. */ +public final class Credentials { + private Credentials() { + } + + /** Returns an auth credential for the Basic scheme. */ + public static String basic(String userName, String password) { + try { + String usernameAndPassword = userName + ":" + password; + byte[] bytes = usernameAndPassword.getBytes("ISO-8859-1"); + String encoded = ByteString.of(bytes).base64(); + return "Basic " + encoded; + } catch (UnsupportedEncodingException e) { + throw new AssertionError(); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Dispatcher.java b/okhttp3/src/main/java/okhttp3/Dispatcher.java new file mode 100644 index 000000000..5a135b325 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Dispatcher.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import okhttp3.RealCall.AsyncCall; +import okhttp3.internal.Util; + +/** + * Policy on when async requests are executed. + * + *

Each dispatcher uses an {@link ExecutorService} to run calls internally. If you supply your + * own executor, it should be able to run {@linkplain #getMaxRequests the configured maximum} number + * of calls concurrently. + */ +public final class Dispatcher { + private int maxRequests = 64; + private int maxRequestsPerHost = 5; + + /** Executes calls. Created lazily. */ + private ExecutorService executorService; + + /** Ready async calls in the order they'll be run. */ + private final Deque readyAsyncCalls = new ArrayDeque<>(); + + /** Running asynchronous calls. Includes canceled calls that haven't finished yet. */ + private final Deque runningAsyncCalls = new ArrayDeque<>(); + + /** Running synchronous calls. Includes canceled calls that haven't finished yet. */ + private final Deque runningSyncCalls = new ArrayDeque<>(); + + public Dispatcher(ExecutorService executorService) { + this.executorService = executorService; + } + + public Dispatcher() { + } + + public synchronized ExecutorService executorService() { + if (executorService == null) { + executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS, + new SynchronousQueue(), Util.threadFactory("OkHttp Dispatcher", false)); + } + return executorService; + } + + /** + * Set the maximum number of requests to execute concurrently. Above this requests queue in + * memory, waiting for the running calls to complete. + * + *

If more than {@code maxRequests} requests are in flight when this is invoked, those requests + * will remain in flight. + */ + public synchronized void setMaxRequests(int maxRequests) { + if (maxRequests < 1) { + throw new IllegalArgumentException("max < 1: " + maxRequests); + } + this.maxRequests = maxRequests; + promoteCalls(); + } + + public synchronized int getMaxRequests() { + return maxRequests; + } + + /** + * Set the maximum number of requests for each host to execute concurrently. This limits requests + * by the URL's host name. Note that concurrent requests to a single IP address may still exceed + * this limit: multiple hostnames may share an IP address or be routed through the same HTTP + * proxy. + * + *

If more than {@code maxRequestsPerHost} requests are in flight when this is invoked, those + * requests will remain in flight. + */ + public synchronized void setMaxRequestsPerHost(int maxRequestsPerHost) { + if (maxRequestsPerHost < 1) { + throw new IllegalArgumentException("max < 1: " + maxRequestsPerHost); + } + this.maxRequestsPerHost = maxRequestsPerHost; + promoteCalls(); + } + + public synchronized int getMaxRequestsPerHost() { + return maxRequestsPerHost; + } + + synchronized void enqueue(AsyncCall call) { + if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) { + runningAsyncCalls.add(call); + executorService().execute(call); + } else { + readyAsyncCalls.add(call); + } + } + + /** + * Cancel all calls currently enqueued or executing. Includes calls executed both {@linkplain + * Call#execute() synchronously} and {@linkplain Call#enqueue asynchronously}. + */ + public synchronized void cancelAll() { + for (AsyncCall call : readyAsyncCalls) { + call.cancel(); + } + + for (AsyncCall call : runningAsyncCalls) { + call.cancel(); + } + + for (RealCall call : runningSyncCalls) { + call.cancel(); + } + } + + /** Used by {@code AsyncCall#run} to signal completion. */ + synchronized void finished(AsyncCall call) { + if (!runningAsyncCalls.remove(call)) throw new AssertionError("AsyncCall wasn't running!"); + promoteCalls(); + } + + private void promoteCalls() { + if (runningAsyncCalls.size() >= maxRequests) return; // Already running max capacity. + if (readyAsyncCalls.isEmpty()) return; // No ready calls to promote. + + for (Iterator i = readyAsyncCalls.iterator(); i.hasNext(); ) { + AsyncCall call = i.next(); + + if (runningCallsForHost(call) < maxRequestsPerHost) { + i.remove(); + runningAsyncCalls.add(call); + executorService().execute(call); + } + + if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity. + } + } + + /** Returns the number of running calls that share a host with {@code call}. */ + private int runningCallsForHost(AsyncCall call) { + int result = 0; + for (AsyncCall c : runningAsyncCalls) { + if (c.host().equals(call.host())) result++; + } + return result; + } + + /** Used by {@code Call#execute} to signal it is in-flight. */ + synchronized void executed(RealCall call) { + runningSyncCalls.add(call); + } + + /** Used by {@code Call#execute} to signal completion. */ + synchronized void finished(Call call) { + if (!runningSyncCalls.remove(call)) throw new AssertionError("Call wasn't in-flight!"); + } + + /** Returns a snapshot of the calls currently awaiting execution. */ + public synchronized List queuedCalls() { + List result = new ArrayList<>(); + for (AsyncCall asyncCall : readyAsyncCalls) { + result.add(asyncCall.get()); + } + return Collections.unmodifiableList(result); + } + + /** Returns a snapshot of the calls currently being executed. */ + public synchronized List runningCalls() { + List result = new ArrayList<>(); + result.addAll(runningSyncCalls); + for (AsyncCall asyncCall : runningAsyncCalls) { + result.add(asyncCall.get()); + } + return Collections.unmodifiableList(result); + } + + public synchronized int queuedCallsCount() { + return readyAsyncCalls.size(); + } + + public synchronized int runningCallsCount() { + return runningAsyncCalls.size() + runningSyncCalls.size(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/Dns.java b/okhttp3/src/main/java/okhttp3/Dns.java new file mode 100644 index 000000000..a2e6db591 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Dns.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +/** + * A domain name service that resolves IP addresses for host names. Most applications will use the + * {@linkplain #SYSTEM system DNS service}, which is the default. Some applications may provide + * their own implementation to use a different DNS server, to prefer IPv6 addresses, to prefer IPv4 + * addresses, or to force a specific known IP address. + * + *

Implementations of this interface must be safe for concurrent use. + */ +public interface Dns { + /** + * A DNS that uses {@link InetAddress#getAllByName} to ask the underlying operating system to + * lookup IP addresses. Most custom {@link Dns} implementations should delegate to this instance. + */ + Dns SYSTEM = new Dns() { + @Override public List lookup(String hostname) throws UnknownHostException { + if (hostname == null) throw new UnknownHostException("hostname == null"); + return Arrays.asList(InetAddress.getAllByName(hostname)); + } + }; + + /** + * Returns the IP addresses of {@code hostname}, in the order they will be attempted by OkHttp. If + * a connection to an address fails, OkHttp will retry the connection with the next address until + * either a connection is made, the set of IP addresses is exhausted, or a limit is exceeded. + */ + List lookup(String hostname) throws UnknownHostException; +} diff --git a/okhttp3/src/main/java/okhttp3/FormBody.java b/okhttp3/src/main/java/okhttp3/FormBody.java new file mode 100644 index 000000000..95181b1f7 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/FormBody.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSink; + +import static okhttp3.HttpUrl.FORM_ENCODE_SET; +import static okhttp3.HttpUrl.percentDecode; + +public final class FormBody extends RequestBody { + private static final MediaType CONTENT_TYPE = + MediaType.parse("application/x-www-form-urlencoded"); + + private final List encodedNames; + private final List encodedValues; + + private FormBody(List encodedNames, List encodedValues) { + this.encodedNames = Util.immutableList(encodedNames); + this.encodedValues = Util.immutableList(encodedValues); + } + + /** The number of key-value pairs in this form-encoded body. */ + public int size() { + return encodedNames.size(); + } + + public String encodedName(int index) { + return encodedNames.get(index); + } + + public String name(int index) { + return percentDecode(encodedName(index), true); + } + + public String encodedValue(int index) { + return encodedValues.get(index); + } + + public String value(int index) { + return percentDecode(encodedValue(index), true); + } + + @Override public MediaType contentType() { + return CONTENT_TYPE; + } + + @Override public long contentLength() { + return writeOrCountBytes(null, true); + } + + @Override public void writeTo(BufferedSink sink) throws IOException { + writeOrCountBytes(sink, false); + } + + /** + * Either writes this request to {@code sink} or measures its content length. We have one method + * do double-duty to make sure the counting and content are consistent, particularly when it comes + * to awkward operations like measuring the encoded length of header strings, or the + * length-in-digits of an encoded integer. + */ + private long writeOrCountBytes(BufferedSink sink, boolean countBytes) { + long byteCount = 0L; + + Buffer buffer; + if (countBytes) { + buffer = new Buffer(); + } else { + buffer = sink.buffer(); + } + + for (int i = 0, size = encodedNames.size(); i < size; i++) { + if (i > 0) buffer.writeByte('&'); + buffer.writeUtf8(encodedNames.get(i)); + buffer.writeByte('='); + buffer.writeUtf8(encodedValues.get(i)); + } + + if (countBytes) { + byteCount = buffer.size(); + buffer.clear(); + } + + return byteCount; + } + + public static final class Builder { + private final List names = new ArrayList<>(); + private final List values = new ArrayList<>(); + + public Builder add(String name, String value) { + names.add(HttpUrl.canonicalize(name, FORM_ENCODE_SET, false, false, true, true)); + values.add(HttpUrl.canonicalize(value, FORM_ENCODE_SET, false, false, true, true)); + return this; + } + + public Builder addEncoded(String name, String value) { + names.add(HttpUrl.canonicalize(name, FORM_ENCODE_SET, true, false, true, true)); + values.add(HttpUrl.canonicalize(value, FORM_ENCODE_SET, true, false, true, true)); + return this; + } + + public FormBody build() { + return new FormBody(names, values); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Handshake.java b/okhttp3/src/main/java/okhttp3/Handshake.java new file mode 100644 index 000000000..022148275 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Handshake.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3; + +import java.security.Principal; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import okhttp3.internal.Util; + +/** + * A record of a TLS handshake. For HTTPS clients, the client is local and the remote server + * is its peer. + * + *

This value object describes a completed handshake. Use {@link ConnectionSpec} to set policy + * for new handshakes. + */ +public final class Handshake { + private final TlsVersion tlsVersion; + private final CipherSuite cipherSuite; + private final List peerCertificates; + private final List localCertificates; + + private Handshake(TlsVersion tlsVersion, CipherSuite cipherSuite, + List peerCertificates, List localCertificates) { + this.tlsVersion = tlsVersion; + this.cipherSuite = cipherSuite; + this.peerCertificates = peerCertificates; + this.localCertificates = localCertificates; + } + + public static Handshake get(SSLSession session) { + String cipherSuiteString = session.getCipherSuite(); + if (cipherSuiteString == null) throw new IllegalStateException("cipherSuite == null"); + CipherSuite cipherSuite = CipherSuite.forJavaName(cipherSuiteString); + + String tlsVersionString = session.getProtocol(); + if (tlsVersionString == null) throw new IllegalStateException("tlsVersion == null"); + TlsVersion tlsVersion = TlsVersion.forJavaName(tlsVersionString); + + Certificate[] peerCertificates; + try { + peerCertificates = session.getPeerCertificates(); + } catch (SSLPeerUnverifiedException ignored) { + peerCertificates = null; + } + List peerCertificatesList = peerCertificates != null + ? Util.immutableList(peerCertificates) + : Collections.emptyList(); + + Certificate[] localCertificates = session.getLocalCertificates(); + List localCertificatesList = localCertificates != null + ? Util.immutableList(localCertificates) + : Collections.emptyList(); + + return new Handshake(tlsVersion, cipherSuite, peerCertificatesList, localCertificatesList); + } + + public static Handshake get(TlsVersion tlsVersion, CipherSuite cipherSuite, + List peerCertificates, List localCertificates) { + if (cipherSuite == null) throw new IllegalArgumentException("cipherSuite == null"); + return new Handshake(tlsVersion, cipherSuite, Util.immutableList(peerCertificates), + Util.immutableList(localCertificates)); + } + + /** + * Returns the TLS version used for this connection. May return null if the response was cached + * with a version of OkHttp prior to 3.0. + */ + public TlsVersion tlsVersion() { + return tlsVersion; + } + + /** Returns the cipher suite used for the connection. */ + public CipherSuite cipherSuite() { + return cipherSuite; + } + + /** Returns a possibly-empty list of certificates that identify the remote peer. */ + public List peerCertificates() { + return peerCertificates; + } + + /** Returns the remote peer's principle, or null if that peer is anonymous. */ + public Principal peerPrincipal() { + return !peerCertificates.isEmpty() + ? ((X509Certificate) peerCertificates.get(0)).getSubjectX500Principal() + : null; + } + + /** Returns a possibly-empty list of certificates that identify this peer. */ + public List localCertificates() { + return localCertificates; + } + + /** Returns the local principle, or null if this peer is anonymous. */ + public Principal localPrincipal() { + return !localCertificates.isEmpty() + ? ((X509Certificate) localCertificates.get(0)).getSubjectX500Principal() + : null; + } + + @Override public boolean equals(Object other) { + if (!(other instanceof Handshake)) return false; + Handshake that = (Handshake) other; + return Util.equal(cipherSuite, that.cipherSuite) + && cipherSuite.equals(that.cipherSuite) + && peerCertificates.equals(that.peerCertificates) + && localCertificates.equals(that.localCertificates); + } + + @Override public int hashCode() { + int result = 17; + result = 31 * result + (tlsVersion != null ? tlsVersion.hashCode() : 0); + result = 31 * result + cipherSuite.hashCode(); + result = 31 * result + peerCertificates.hashCode(); + result = 31 * result + localCertificates.hashCode(); + return result; + } +} diff --git a/okhttp3/src/main/java/okhttp3/Headers.java b/okhttp3/src/main/java/okhttp3/Headers.java new file mode 100644 index 000000000..6f72d0cc4 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Headers.java @@ -0,0 +1,303 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import okhttp3.internal.http.HttpDate; + +/** + * The header fields of a single HTTP message. Values are uninterpreted strings; use {@code Request} + * and {@code Response} for interpreted headers. This class maintains the order of the header fields + * within the HTTP message. + * + *

This class tracks header values line-by-line. A field with multiple comma- separated values on + * the same line will be treated as a field with a single value by this class. It is the caller's + * responsibility to detect and split on commas if their field permits multiple values. This + * simplifies use of single-valued fields whose values routinely contain commas, such as cookies or + * dates. + * + *

This class trims whitespace from values. It never returns values with leading or trailing + * whitespace. + * + *

Instances of this class are immutable. Use {@link Builder} to create instances. + */ +public final class Headers { + private final String[] namesAndValues; + + private Headers(Builder builder) { + this.namesAndValues = builder.namesAndValues.toArray(new String[builder.namesAndValues.size()]); + } + + private Headers(String[] namesAndValues) { + this.namesAndValues = namesAndValues; + } + + /** Returns the last value corresponding to the specified field, or null. */ + public String get(String name) { + return get(namesAndValues, name); + } + + /** + * Returns the last value corresponding to the specified field parsed as an HTTP date, or null if + * either the field is absent or cannot be parsed as a date. + */ + public Date getDate(String name) { + String value = get(name); + return value != null ? HttpDate.parse(value) : null; + } + + /** Returns the number of field values. */ + public int size() { + return namesAndValues.length / 2; + } + + /** Returns the field at {@code position}. */ + public String name(int index) { + return namesAndValues[index * 2]; + } + + /** Returns the value at {@code index}. */ + public String value(int index) { + return namesAndValues[index * 2 + 1]; + } + + /** Returns an immutable case-insensitive set of header names. */ + public Set names() { + TreeSet result = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (int i = 0, size = size(); i < size; i++) { + result.add(name(i)); + } + return Collections.unmodifiableSet(result); + } + + /** Returns an immutable list of the header values for {@code name}. */ + public List values(String name) { + List result = null; + for (int i = 0, size = size(); i < size; i++) { + if (name.equalsIgnoreCase(name(i))) { + if (result == null) result = new ArrayList<>(2); + result.add(value(i)); + } + } + return result != null + ? Collections.unmodifiableList(result) + : Collections.emptyList(); + } + + public Builder newBuilder() { + Builder result = new Builder(); + Collections.addAll(result.namesAndValues, namesAndValues); + return result; + } + + @Override public String toString() { + StringBuilder result = new StringBuilder(); + for (int i = 0, size = size(); i < size; i++) { + result.append(name(i)).append(": ").append(value(i)).append("\n"); + } + return result.toString(); + } + + public Map> toMultimap() { + Map> result = new LinkedHashMap>(); + for (int i = 0, size = size(); i < size; i++) { + String name = name(i); + List values = result.get(name); + if (values == null) { + values = new ArrayList<>(2); + result.put(name, values); + } + values.add(value(i)); + } + return result; + } + + private static String get(String[] namesAndValues, String name) { + for (int i = namesAndValues.length - 2; i >= 0; i -= 2) { + if (name.equalsIgnoreCase(namesAndValues[i])) { + return namesAndValues[i + 1]; + } + } + return null; + } + + /** + * Returns headers for the alternating header names and values. There must be an even number of + * arguments, and they must alternate between header names and values. + */ + public static Headers of(String... namesAndValues) { + if (namesAndValues == null || namesAndValues.length % 2 != 0) { + throw new IllegalArgumentException("Expected alternating header names and values"); + } + + // Make a defensive copy and clean it up. + namesAndValues = namesAndValues.clone(); + for (int i = 0; i < namesAndValues.length; i++) { + if (namesAndValues[i] == null) throw new IllegalArgumentException("Headers cannot be null"); + namesAndValues[i] = namesAndValues[i].trim(); + } + + // Check for malformed headers. + for (int i = 0; i < namesAndValues.length; i += 2) { + String name = namesAndValues[i]; + String value = namesAndValues[i + 1]; + if (name.length() == 0 || name.indexOf('\0') != -1 || value.indexOf('\0') != -1) { + throw new IllegalArgumentException("Unexpected header: " + name + ": " + value); + } + } + + return new Headers(namesAndValues); + } + + /** + * Returns headers for the header names and values in the {@link Map}. + */ + public static Headers of(Map headers) { + if (headers == null) { + throw new IllegalArgumentException("Expected map with header names and values"); + } + + // Make a defensive copy and clean it up. + String[] namesAndValues = new String[headers.size() * 2]; + int i = 0; + for (Map.Entry header : headers.entrySet()) { + if (header.getKey() == null || header.getValue() == null) { + throw new IllegalArgumentException("Headers cannot be null"); + } + String name = header.getKey().trim(); + String value = header.getValue().trim(); + if (name.length() == 0 || name.indexOf('\0') != -1 || value.indexOf('\0') != -1) { + throw new IllegalArgumentException("Unexpected header: " + name + ": " + value); + } + namesAndValues[i] = name; + namesAndValues[i + 1] = value; + i += 2; + } + + return new Headers(namesAndValues); + } + + public static final class Builder { + private final List namesAndValues = new ArrayList<>(20); + + /** + * Add a header line without any validation. Only appropriate for headers from the remote peer + * or cache. + */ + Builder addLenient(String line) { + int index = line.indexOf(":", 1); + if (index != -1) { + return addLenient(line.substring(0, index), line.substring(index + 1)); + } else if (line.startsWith(":")) { + // Work around empty header names and header names that start with a + // colon (created by old broken SPDY versions of the response cache). + return addLenient("", line.substring(1)); // Empty header name. + } else { + return addLenient("", line); // No header name. + } + } + + /** Add an header line containing a field name, a literal colon, and a value. */ + public Builder add(String line) { + int index = line.indexOf(":"); + if (index == -1) { + throw new IllegalArgumentException("Unexpected header: " + line); + } + return add(line.substring(0, index).trim(), line.substring(index + 1)); + } + + /** Add a field with the specified value. */ + public Builder add(String name, String value) { + checkNameAndValue(name, value); + return addLenient(name, value); + } + + /** + * Add a field with the specified value without any validation. Only appropriate for headers + * from the remote peer or cache. + */ + Builder addLenient(String name, String value) { + namesAndValues.add(name); + namesAndValues.add(value.trim()); + return this; + } + + public Builder removeAll(String name) { + for (int i = 0; i < namesAndValues.size(); i += 2) { + if (name.equalsIgnoreCase(namesAndValues.get(i))) { + namesAndValues.remove(i); // name + namesAndValues.remove(i); // value + i -= 2; + } + } + return this; + } + + /** + * Set a field with the specified value. If the field is not found, it is added. If the field is + * found, the existing values are replaced. + */ + public Builder set(String name, String value) { + checkNameAndValue(name, value); + removeAll(name); + addLenient(name, value); + return this; + } + + private void checkNameAndValue(String name, String value) { + if (name == null) throw new IllegalArgumentException("name == null"); + if (name.isEmpty()) throw new IllegalArgumentException("name is empty"); + for (int i = 0, length = name.length(); i < length; i++) { + char c = name.charAt(i); + if (c <= '\u001f' || c >= '\u007f') { + throw new IllegalArgumentException(String.format( + "Unexpected char %#04x at %d in header name: %s", (int) c, i, name)); + } + } + if (value == null) throw new IllegalArgumentException("value == null"); + for (int i = 0, length = value.length(); i < length; i++) { + char c = value.charAt(i); + if (c <= '\u001f' || c >= '\u007f') { + throw new IllegalArgumentException(String.format( + "Unexpected char %#04x at %d in %s value: %s", (int) c, i, name, value)); + } + } + } + + /** Equivalent to {@code build().get(name)}, but potentially faster. */ + public String get(String name) { + for (int i = namesAndValues.size() - 2; i >= 0; i -= 2) { + if (name.equalsIgnoreCase(namesAndValues.get(i))) { + return namesAndValues.get(i + 1); + } + } + return null; + } + + public Headers build() { + return new Headers(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/HttpUrl.java b/okhttp3/src/main/java/okhttp3/HttpUrl.java new file mode 100644 index 000000000..59cbdd435 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/HttpUrl.java @@ -0,0 +1,1579 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import okio.Buffer; + +import static okhttp3.internal.Util.delimiterOffset; +import static okhttp3.internal.Util.domainToAscii; +import static okhttp3.internal.Util.skipLeadingAsciiWhitespace; +import static okhttp3.internal.Util.skipTrailingAsciiWhitespace; + +/** + * A uniform resource locator (URL) with a scheme of either {@code http} or {@code https}. Use this + * class to compose and decompose Internet addresses. For example, this code will compose and print + * a URL for Google search:

   {@code
+ *
+ *   HttpUrl url = new HttpUrl.Builder()
+ *       .scheme("https")
+ *       .host("www.google.com")
+ *       .addPathSegment("search")
+ *       .addQueryParameter("q", "polar bears")
+ *       .build();
+ *   System.out.println(url);
+ * }
+ * + * which prints:
   {@code
+ *
+ *     https://www.google.com/search?q=polar%20bears
+ * }
+ * + * As another example, this code prints the human-readable query parameters of a Twitter search: + *
   {@code
+ *
+ *   HttpUrl url = HttpUrl.parse("https://twitter.com/search?q=cute%20%23puppies&f=images");
+ *   for (int i = 0, size = url.querySize(); i < size; i++) {
+ *     System.out.println(url.queryParameterName(i) + ": " + url.queryParameterValue(i));
+ *   }
+ * }
+ * + * which prints:
   {@code
+ *
+ *   q: cute #puppies
+ *   f: images
+ * }
+ * + * In addition to composing URLs from their component parts and decomposing URLs into their + * component parts, this class implements relative URL resolution: what address you'd reach by + * clicking a relative link on a specified page. For example:
   {@code
+ *
+ *   HttpUrl base = HttpUrl.parse("https://www.youtube.com/user/WatchTheDaily/videos");
+ *   HttpUrl link = base.resolve("../../watch?v=cbP2N1BQdYc");
+ *   System.out.println(link);
+ * }
+ * + * which prints:
   {@code
+ *
+ *   https://www.youtube.com/watch?v=cbP2N1BQdYc
+ * }
+ * + *

What's in a URL?

+ * + * A URL has several components. + * + *

Scheme

+ * + *

Sometimes referred to as protocol, A URL's scheme describes what mechanism should be + * used to retrieve the resource. Although URLs have many schemes ({@code mailto}, {@code file}, + * {@code ftp}), this class only supports {@code http} and {@code https}. Use {@link URI + * java.net.URI} for URLs with arbitrary schemes. + * + *

Username and Password

+ * + *

Username and password are either present, or the empty string {@code ""} if absent. This class + * offers no mechanism to differentiate empty from absent. Neither of these components are popular + * in practice. Typically HTTP applications use other mechanisms for user identification and + * authentication. + * + *

Host

+ * + *

The host identifies the webserver that serves the URL's resource. It is either a hostname like + * {@code square.com} or {@code localhost}, an IPv4 address like {@code 192.168.0.1}, or an IPv6 + * address like {@code ::1}. + * + *

Usually a webserver is reachable with multiple identifiers: its IP addresses, registered + * domain names, and even {@code localhost} when connecting from the server itself. Each of a + * webserver's names is a distinct URL and they are not interchangeable. For example, even if {@code + * http://square.github.io/dagger} and {@code http://google.github.io/dagger} are served by the same + * IP address, the two URLs identify different resources. + * + *

Port

+ * + *

The port used to connect to the webserver. By default this is 80 for HTTP and 443 for HTTPS. + * This class never returns -1 for the port: if no port is explicitly specified in the URL then the + * scheme's default is used. + * + *

Path

+ * + *

The path identifies a specific resource on the host. Paths have a hierarchical structure like + * "/square/okhttp/issues/1486". Each path segment is prefixed with "/". This class offers methods + * to compose and decompose paths by segment. If a path's last segment is the empty string, then the + * path ends with "/". This class always builds non-empty paths: if the path is omitted it defaults + * to "/", which is a path whose only segment is the empty string. + * + *

Query

+ * + *

The query is optional: it can be null, empty, or non-empty. For many HTTP URLs the query + * string is subdivided into a collection of name-value parameters. This class offers methods to set + * the query as the single string, or as individual name-value parameters. With name-value + * parameters the values are optional and names may be repeated. + * + *

Fragment

+ * + *

The fragment is optional: it can be null, empty, or non-empty. Unlike host, port, path, and + * query the fragment is not sent to the webserver: it's private to the client. + * + *

Encoding

+ * + *

Each component must be encoded before it is embedded in the complete URL. As we saw above, the + * string {@code cute #puppies} is encoded as {@code cute%20%23puppies} when used as a query + * parameter value. + * + *

Percent encoding

+ * + *

Percent encoding replaces a character (like {@code \ud83c\udf69}) with its UTF-8 hex bytes + * (like {@code %F0%9F%8D%A9}). This approach works for whitespace characters, control characters, + * non-ASCII characters, and characters that already have another meaning in a particular context. + * + *

Percent encoding is used in every URL component except for the hostname. But the set of + * characters that need to be encoded is different for each component. For example, the path + * component must escape all of its {@code ?} characters, otherwise it could be interpreted as the + * start of the URL's query. But within the query and fragment components, the {@code ?} character + * doesn't delimit anything and doesn't need to be escaped.

   {@code
+ *
+ *   HttpUrl url = HttpUrl.parse("http://who-let-the-dogs.out").newBuilder()
+ *       .addPathSegment("_Who?_")
+ *       .query("_Who?_")
+ *       .fragment("_Who?_")
+ *       .build();
+ *   System.out.println(url);
+ * }
+ * + * This prints:
   {@code
+ *
+ *   http://who-let-the-dogs.out/_Who%3F_?_Who?_#_Who?_
+ * }
+ * + * When parsing URLs that lack percent encoding where it is required, this class will percent encode + * the offending characters. + * + *

IDNA Mapping and Punycode encoding

+ * + *

Hostnames have different requirements and use a different encoding scheme. It consists of IDNA + * mapping and Punycode encoding. + * + *

In order to avoid confusion and discourage phishing attacks, IDNA Mapping transforms names to avoid + * confusing characters. This includes basic case folding: transforming shouting {@code SQUARE.COM} + * into cool and casual {@code square.com}. It also handles more exotic characters. For example, the + * Unicode trademark sign (™) could be confused for the letters "TM" in {@code http://ho™mail.com}. + * To mitigate this, the single character (™) maps to the string (tm). There is similar policy for + * all of the 1.1 million Unicode code points. Note that some code points such as "\ud83c\udf69" are + * not mapped and cannot be used in a hostname. + * + *

Punycode converts a Unicode string to an ASCII + * string to make international domain names work everywhere. For example, "σ" encodes as "xn--4xa". + * The encoded string is not human readable, but can be used with classes like {@link InetAddress} + * to establish connections. + * + *

Why another URL model?

+ * + *

Java includes both {@link URL java.net.URL} and {@link URI java.net.URI}. We offer a new URL + * model to address problems that the others don't. + * + *

Different URLs should be different

+ * + *

Although they have different content, {@code java.net.URL} considers the following two URLs + * equal, and the {@link Object#equals equals()} method between them returns true: + * + *

    + *
  • http://square.github.io/ + *
  • http://google.github.io/ + *
+ * + * This is because those two hosts share the same IP address. This is an old, bad design decision + * that makes {@code java.net.URL} unusable for many things. It shouldn't be used as a {@link + * java.util.Map Map} key or in a {@link Set}. Doing so is both inefficient because equality may + * require a DNS lookup, and incorrect because unequal URLs may be equal because of how they are + * hosted. + * + *

Equal URLs should be equal

+ * + *

These two URLs are semantically identical, but {@code java.net.URI} disagrees: + * + *

    + *
  • http://host:80/ + *
  • http://host + *
+ * + * Both the unnecessary port specification ({@code :80}) and the absent trailing slash ({@code /}) + * cause URI to bucket the two URLs separately. This harms URI's usefulness in collections. Any + * application that stores information-per-URL will need to either canonicalize manually, or suffer + * unnecessary redundancy for such URLs. + * + *

Because they don't attempt canonical form, these classes are surprisingly difficult to use + * securely. Suppose you're building a webservice that checks that incoming paths are prefixed + * "/static/images/" before serving the corresponding assets from the filesystem.

   {@code
+ *
+ *   String attack = "http://example.com/static/images/../../../../../etc/passwd";
+ *   System.out.println(new URL(attack).getPath());
+ *   System.out.println(new URI(attack).getPath());
+ *   System.out.println(HttpUrl.parse(attack).path());
+ * }
+ * + * By canonicalizing the input paths, they are complicit in directory traversal attacks. Code that + * checks only the path prefix may suffer! + *
   {@code
+ *
+ *    /static/images/../../../../../etc/passwd
+ *    /static/images/../../../../../etc/passwd
+ *    /etc/passwd
+ * }
+ * + *

If it works on the web, it should work in your application

+ * + *

The {@code java.net.URI} class is strict around what URLs it accepts. It rejects URLs like + * "http://example.com/abc|def" because the '|' character is unsupported. This class is more + * forgiving: it will automatically percent-encode the '|', yielding "http://example.com/abc%7Cdef". + * This kind behavior is consistent with web browsers. {@code HttpUrl} prefers consistency with + * major web browsers over consistency with obsolete specifications. + * + *

Paths and Queries should decompose

+ * + *

Neither of the built-in URL models offer direct access to path segments or query parameters. + * Manually using {@code StringBuilder} to assemble these components is cumbersome: do '+' + * characters get silently replaced with spaces? If a query parameter contains a '&', does that + * get escaped? By offering methods to read and write individual query parameters directly, + * application developers are saved from the hassles of encoding and decoding. + * + *

Plus a modern API

+ * + *

The URL (JDK1.0) and URI (Java 1.4) classes predate builders and instead use telescoping + * constructors. For example, there's no API to compose a URI with a custom port without also + * providing a query and fragment. + * + *

Instances of {@link HttpUrl} are well-formed and always have a scheme, host, and path. With + * {@code java.net.URL} it's possible to create an awkward URL like {@code http:/} with scheme and + * path but no hostname. Building APIs that consume such malformed values is difficult! + * + *

This class has a modern API. It avoids punitive checked exceptions: {@link #parse parse()} + * returns null if the input is an invalid URL. You can even be explicit about whether each + * component has been encoded already. + */ +public final class HttpUrl { + private static final char[] HEX_DIGITS = + {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; + static final String USERNAME_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#"; + static final String PASSWORD_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#"; + static final String PATH_SEGMENT_ENCODE_SET = " \"<>^`{}|/\\?#"; + static final String PATH_SEGMENT_ENCODE_SET_URI = "[]"; + static final String QUERY_ENCODE_SET = " \"'<>#"; + static final String QUERY_COMPONENT_ENCODE_SET = " \"'<>#&="; + static final String QUERY_COMPONENT_ENCODE_SET_URI = "\\^`{|}"; + static final String FORM_ENCODE_SET = " \"':;<=>@[]^`{}|/\\?#&!$(),~"; + static final String FRAGMENT_ENCODE_SET = ""; + static final String FRAGMENT_ENCODE_SET_URI = " \"#<>\\^`{|}"; + + /** Either "http" or "https". */ + private final String scheme; + + /** Decoded username. */ + private final String username; + + /** Decoded password. */ + private final String password; + + /** Canonical hostname. */ + private final String host; + + /** Either 80, 443 or a user-specified port. In range [1..65535]. */ + private final int port; + + /** + * A list of canonical path segments. This list always contains at least one element, which may be + * the empty string. Each segment is formatted with a leading '/', so if path segments were ["a", + * "b", ""], then the encoded path would be "/a/b/". + */ + private final List pathSegments; + + /** + * Alternating, decoded query names and values, or null for no query. Names may be empty or + * non-empty, but never null. Values are null if the name has no corresponding '=' separator, or + * empty, or non-empty. + */ + private final List queryNamesAndValues; + + /** Decoded fragment. */ + private final String fragment; + + /** Canonical URL. */ + private final String url; + + private HttpUrl(Builder builder) { + this.scheme = builder.scheme; + this.username = percentDecode(builder.encodedUsername, false); + this.password = percentDecode(builder.encodedPassword, false); + this.host = builder.host; + this.port = builder.effectivePort(); + this.pathSegments = percentDecode(builder.encodedPathSegments, false); + this.queryNamesAndValues = builder.encodedQueryNamesAndValues != null + ? percentDecode(builder.encodedQueryNamesAndValues, true) + : null; + this.fragment = builder.encodedFragment != null + ? percentDecode(builder.encodedFragment, false) + : null; + this.url = builder.toString(); + } + + /** Returns this URL as a {@link URL java.net.URL}. */ + public URL url() { + try { + return new URL(url); + } catch (MalformedURLException e) { + throw new RuntimeException(e); // Unexpected! + } + } + + /** + * Returns this URL as a {@link URI java.net.URI}. Because {@code URI} is more strict than this + * class, the returned URI may be semantically different from this URL: + * + *

    + *
  • Characters forbidden by URI like {@code [} and {@code |} will be escaped. + *
  • Invalid percent-encoded sequences like {@code %xx} will be encoded like {@code %25xx}. + *
  • Whitespace and control characters in the fragment will be stripped. + *
+ * + *

These differences may have a significant consequence when the URI is interpretted by a + * webserver. For this reason the {@linkplain URI URI class} and this method should be avoided. + */ + public URI uri() { + String uri = newBuilder().reencodeForUri().toString(); + try { + return new URI(uri); + } catch (URISyntaxException e) { + // Unlikely edge case: the URI has a forbidden character in the fragment. Strip it & retry. + try { + String stripped = uri.replaceAll("[\\u0000-\\u001F\\u007F-\\u009F\\p{javaWhitespace}]", ""); + return URI.create(stripped); + } catch (Exception e1) { + throw new RuntimeException(e); // Unexpected! + } + } + } + + /** Returns either "http" or "https". */ + public String scheme() { + return scheme; + } + + public boolean isHttps() { + return scheme.equals("https"); + } + + /** Returns the username, or an empty string if none is set. */ + public String encodedUsername() { + if (username.isEmpty()) return ""; + int usernameStart = scheme.length() + 3; // "://".length() == 3. + int usernameEnd = delimiterOffset(url, usernameStart, url.length(), ":@"); + return url.substring(usernameStart, usernameEnd); + } + + public String username() { + return username; + } + + /** Returns the password, or an empty string if none is set. */ + public String encodedPassword() { + if (password.isEmpty()) return ""; + int passwordStart = url.indexOf(':', scheme.length() + 3) + 1; + int passwordEnd = url.indexOf('@'); + return url.substring(passwordStart, passwordEnd); + } + + /** Returns the decoded password, or an empty string if none is present. */ + public String password() { + return password; + } + + /** + * Returns the host address suitable for use with {@link InetAddress#getAllByName(String)}. May + * be: + * + *

    + *
  • A regular host name, like {@code android.com}. + *
  • An IPv4 address, like {@code 127.0.0.1}. + *
  • An IPv6 address, like {@code ::1}. Note that there are no square braces. + *
  • An encoded IDN, like {@code xn--n3h.net}. + *
+ */ + public String host() { + return host; + } + + /** + * Returns the explicitly-specified port if one was provided, or the default port for this URL's + * scheme. For example, this returns 8443 for {@code https://square.com:8443/} and 443 for {@code + * https://square.com/}. The result is in {@code [1..65535]}. + */ + public int port() { + return port; + } + + /** + * Returns 80 if {@code scheme.equals("http")}, 443 if {@code scheme.equals("https")} and -1 + * otherwise. + */ + public static int defaultPort(String scheme) { + if (scheme.equals("http")) { + return 80; + } else if (scheme.equals("https")) { + return 443; + } else { + return -1; + } + } + + public int pathSize() { + return pathSegments.size(); + } + + /** + * Returns the entire path of this URL, encoded for use in HTTP resource resolution. The returned + * path is always nonempty and is prefixed with {@code /}. + */ + public String encodedPath() { + int pathStart = url.indexOf('/', scheme.length() + 3); // "://".length() == 3. + int pathEnd = delimiterOffset(url, pathStart, url.length(), "?#"); + return url.substring(pathStart, pathEnd); + } + + static void pathSegmentsToString(StringBuilder out, List pathSegments) { + for (int i = 0, size = pathSegments.size(); i < size; i++) { + out.append('/'); + out.append(pathSegments.get(i)); + } + } + + public List encodedPathSegments() { + int pathStart = url.indexOf('/', scheme.length() + 3); + int pathEnd = delimiterOffset(url, pathStart, url.length(), "?#"); + List result = new ArrayList<>(); + for (int i = pathStart; i < pathEnd; ) { + i++; // Skip the '/'. + int segmentEnd = delimiterOffset(url, i, pathEnd, '/'); + result.add(url.substring(i, segmentEnd)); + i = segmentEnd; + } + return result; + } + + public List pathSegments() { + return pathSegments; + } + + /** + * Returns the query of this URL, encoded for use in HTTP resource resolution. The returned string + * may be null (for URLs with no query), empty (for URLs with an empty query) or non-empty (all + * other URLs). + */ + public String encodedQuery() { + if (queryNamesAndValues == null) return null; // No query. + int queryStart = url.indexOf('?') + 1; + int queryEnd = delimiterOffset(url, queryStart + 1, url.length(), '#'); + return url.substring(queryStart, queryEnd); + } + + static void namesAndValuesToQueryString(StringBuilder out, List namesAndValues) { + for (int i = 0, size = namesAndValues.size(); i < size; i += 2) { + String name = namesAndValues.get(i); + String value = namesAndValues.get(i + 1); + if (i > 0) out.append('&'); + out.append(name); + if (value != null) { + out.append('='); + out.append(value); + } + } + } + + /** + * Cuts {@code encodedQuery} up into alternating parameter names and values. This divides a query + * string like {@code subject=math&easy&problem=5-2=3} into the list {@code ["subject", "math", + * "easy", null, "problem", "5-2=3"]}. Note that values may be null and may contain '=' + * characters. + */ + static List queryStringToNamesAndValues(String encodedQuery) { + List result = new ArrayList<>(); + for (int pos = 0; pos <= encodedQuery.length(); ) { + int ampersandOffset = encodedQuery.indexOf('&', pos); + if (ampersandOffset == -1) ampersandOffset = encodedQuery.length(); + + int equalsOffset = encodedQuery.indexOf('=', pos); + if (equalsOffset == -1 || equalsOffset > ampersandOffset) { + result.add(encodedQuery.substring(pos, ampersandOffset)); + result.add(null); // No value for this name. + } else { + result.add(encodedQuery.substring(pos, equalsOffset)); + result.add(encodedQuery.substring(equalsOffset + 1, ampersandOffset)); + } + pos = ampersandOffset + 1; + } + return result; + } + + public String query() { + if (queryNamesAndValues == null) return null; // No query. + StringBuilder result = new StringBuilder(); + namesAndValuesToQueryString(result, queryNamesAndValues); + return result.toString(); + } + + public int querySize() { + return queryNamesAndValues != null ? queryNamesAndValues.size() / 2 : 0; + } + + /** + * Returns the first query parameter named {@code name} decoded using UTF-8, or null if there is + * no such query parameter. + */ + public String queryParameter(String name) { + if (queryNamesAndValues == null) return null; + for (int i = 0, size = queryNamesAndValues.size(); i < size; i += 2) { + if (name.equals(queryNamesAndValues.get(i))) { + return queryNamesAndValues.get(i + 1); + } + } + return null; + } + + public Set queryParameterNames() { + if (queryNamesAndValues == null) return Collections.emptySet(); + Set result = new LinkedHashSet<>(); + for (int i = 0, size = queryNamesAndValues.size(); i < size; i += 2) { + result.add(queryNamesAndValues.get(i)); + } + return Collections.unmodifiableSet(result); + } + + public List queryParameterValues(String name) { + if (queryNamesAndValues == null) return Collections.emptyList(); + List result = new ArrayList<>(); + for (int i = 0, size = queryNamesAndValues.size(); i < size; i += 2) { + if (name.equals(queryNamesAndValues.get(i))) { + result.add(queryNamesAndValues.get(i + 1)); + } + } + return Collections.unmodifiableList(result); + } + + public String queryParameterName(int index) { + return queryNamesAndValues.get(index * 2); + } + + public String queryParameterValue(int index) { + return queryNamesAndValues.get(index * 2 + 1); + } + + public String encodedFragment() { + if (fragment == null) return null; + int fragmentStart = url.indexOf('#') + 1; + return url.substring(fragmentStart); + } + + public String fragment() { + return fragment; + } + + /** + * Returns the URL that would be retrieved by following {@code link} from this URL, or null if + * the resulting URL is not well-formed. + */ + public HttpUrl resolve(String link) { + Builder builder = newBuilder(link); + return builder != null ? builder.build() : null; + } + + public Builder newBuilder() { + Builder result = new Builder(); + result.scheme = scheme; + result.encodedUsername = encodedUsername(); + result.encodedPassword = encodedPassword(); + result.host = host; + // If we're set to a default port, unset it in case of a scheme change. + result.port = port != defaultPort(scheme) ? port : -1; + result.encodedPathSegments.clear(); + result.encodedPathSegments.addAll(encodedPathSegments()); + result.encodedQuery(encodedQuery()); + result.encodedFragment = encodedFragment(); + return result; + } + + /** + * Returns a builder for the URL that would be retrieved by following {@code link} from this URL, + * or null if the resulting URL is not well-formed. + */ + public Builder newBuilder(String link) { + Builder builder = new Builder(); + Builder.ParseResult result = builder.parse(this, link); + return result == Builder.ParseResult.SUCCESS ? builder : null; + } + + /** + * Returns a new {@code HttpUrl} representing {@code url} if it is a well-formed HTTP or HTTPS + * URL, or null if it isn't. + */ + public static HttpUrl parse(String url) { + Builder builder = new Builder(); + Builder.ParseResult result = builder.parse(null, url); + return result == Builder.ParseResult.SUCCESS ? builder.build() : null; + } + + /** + * Returns an {@link HttpUrl} for {@code url} if its protocol is {@code http} or {@code https}, or + * null if it has any other protocol. + */ + public static HttpUrl get(URL url) { + return parse(url.toString()); + } + + /** + * Returns a new {@code HttpUrl} representing {@code url} if it is a well-formed HTTP or HTTPS + * URL, or throws an exception if it isn't. + * + * @throws MalformedURLException if there was a non-host related URL issue + * @throws UnknownHostException if the host was invalid + */ + static HttpUrl getChecked(String url) throws MalformedURLException, UnknownHostException { + Builder builder = new Builder(); + Builder.ParseResult result = builder.parse(null, url); + switch (result) { + case SUCCESS: + return builder.build(); + case INVALID_HOST: + throw new UnknownHostException("Invalid host: " + url); + case UNSUPPORTED_SCHEME: + case MISSING_SCHEME: + case INVALID_PORT: + default: + throw new MalformedURLException("Invalid URL: " + result + " for " + url); + } + } + + public static HttpUrl get(URI uri) { + return parse(uri.toString()); + } + + @Override public boolean equals(Object o) { + return o instanceof HttpUrl && ((HttpUrl) o).url.equals(url); + } + + @Override public int hashCode() { + return url.hashCode(); + } + + @Override public String toString() { + return url; + } + + public static final class Builder { + String scheme; + String encodedUsername = ""; + String encodedPassword = ""; + String host; + int port = -1; + final List encodedPathSegments = new ArrayList<>(); + List encodedQueryNamesAndValues; + String encodedFragment; + + public Builder() { + encodedPathSegments.add(""); // The default path is '/' which needs a trailing space. + } + + public Builder scheme(String scheme) { + if (scheme == null) { + throw new IllegalArgumentException("scheme == null"); + } else if (scheme.equalsIgnoreCase("http")) { + this.scheme = "http"; + } else if (scheme.equalsIgnoreCase("https")) { + this.scheme = "https"; + } else { + throw new IllegalArgumentException("unexpected scheme: " + scheme); + } + return this; + } + + public Builder username(String username) { + if (username == null) throw new IllegalArgumentException("username == null"); + this.encodedUsername = canonicalize(username, USERNAME_ENCODE_SET, false, false, false, true); + return this; + } + + public Builder encodedUsername(String encodedUsername) { + if (encodedUsername == null) throw new IllegalArgumentException("encodedUsername == null"); + this.encodedUsername = canonicalize( + encodedUsername, USERNAME_ENCODE_SET, true, false, false, true); + return this; + } + + public Builder password(String password) { + if (password == null) throw new IllegalArgumentException("password == null"); + this.encodedPassword = canonicalize(password, PASSWORD_ENCODE_SET, false, false, false, true); + return this; + } + + public Builder encodedPassword(String encodedPassword) { + if (encodedPassword == null) throw new IllegalArgumentException("encodedPassword == null"); + this.encodedPassword = canonicalize( + encodedPassword, PASSWORD_ENCODE_SET, true, false, false, true); + return this; + } + + /** + * @param host either a regular hostname, International Domain Name, IPv4 address, or IPv6 + * address. + */ + public Builder host(String host) { + if (host == null) throw new IllegalArgumentException("host == null"); + String encoded = canonicalizeHost(host, 0, host.length()); + if (encoded == null) throw new IllegalArgumentException("unexpected host: " + host); + this.host = encoded; + return this; + } + + public Builder port(int port) { + if (port <= 0 || port > 65535) throw new IllegalArgumentException("unexpected port: " + port); + this.port = port; + return this; + } + + int effectivePort() { + return port != -1 ? port : defaultPort(scheme); + } + + public Builder addPathSegment(String pathSegment) { + if (pathSegment == null) throw new IllegalArgumentException("pathSegment == null"); + push(pathSegment, 0, pathSegment.length(), false, false); + return this; + } + + public Builder addEncodedPathSegment(String encodedPathSegment) { + if (encodedPathSegment == null) { + throw new IllegalArgumentException("encodedPathSegment == null"); + } + push(encodedPathSegment, 0, encodedPathSegment.length(), false, true); + return this; + } + + public Builder setPathSegment(int index, String pathSegment) { + if (pathSegment == null) throw new IllegalArgumentException("pathSegment == null"); + String canonicalPathSegment = canonicalize( + pathSegment, 0, pathSegment.length(), PATH_SEGMENT_ENCODE_SET, false, false, false, true); + if (isDot(canonicalPathSegment) || isDotDot(canonicalPathSegment)) { + throw new IllegalArgumentException("unexpected path segment: " + pathSegment); + } + encodedPathSegments.set(index, canonicalPathSegment); + return this; + } + + public Builder setEncodedPathSegment(int index, String encodedPathSegment) { + if (encodedPathSegment == null) { + throw new IllegalArgumentException("encodedPathSegment == null"); + } + String canonicalPathSegment = canonicalize(encodedPathSegment, + 0, encodedPathSegment.length(), PATH_SEGMENT_ENCODE_SET, true, false, false, true); + encodedPathSegments.set(index, canonicalPathSegment); + if (isDot(canonicalPathSegment) || isDotDot(canonicalPathSegment)) { + throw new IllegalArgumentException("unexpected path segment: " + encodedPathSegment); + } + return this; + } + + public Builder removePathSegment(int index) { + encodedPathSegments.remove(index); + if (encodedPathSegments.isEmpty()) { + encodedPathSegments.add(""); // Always leave at least one '/'. + } + return this; + } + + public Builder encodedPath(String encodedPath) { + if (encodedPath == null) throw new IllegalArgumentException("encodedPath == null"); + if (!encodedPath.startsWith("/")) { + throw new IllegalArgumentException("unexpected encodedPath: " + encodedPath); + } + resolvePath(encodedPath, 0, encodedPath.length()); + return this; + } + + public Builder query(String query) { + this.encodedQueryNamesAndValues = query != null + ? queryStringToNamesAndValues(canonicalize( + query, QUERY_ENCODE_SET, false, false, true, true)) + : null; + return this; + } + + public Builder encodedQuery(String encodedQuery) { + this.encodedQueryNamesAndValues = encodedQuery != null + ? queryStringToNamesAndValues( + canonicalize(encodedQuery, QUERY_ENCODE_SET, true, false, true, true)) + : null; + return this; + } + + /** Encodes the query parameter using UTF-8 and adds it to this URL's query string. */ + public Builder addQueryParameter(String name, String value) { + if (name == null) throw new IllegalArgumentException("name == null"); + if (encodedQueryNamesAndValues == null) encodedQueryNamesAndValues = new ArrayList<>(); + encodedQueryNamesAndValues.add( + canonicalize(name, QUERY_COMPONENT_ENCODE_SET, false, false, true, true)); + encodedQueryNamesAndValues.add(value != null + ? canonicalize(value, QUERY_COMPONENT_ENCODE_SET, false, false, true, true) + : null); + return this; + } + + /** Adds the pre-encoded query parameter to this URL's query string. */ + public Builder addEncodedQueryParameter(String encodedName, String encodedValue) { + if (encodedName == null) throw new IllegalArgumentException("encodedName == null"); + if (encodedQueryNamesAndValues == null) encodedQueryNamesAndValues = new ArrayList<>(); + encodedQueryNamesAndValues.add( + canonicalize(encodedName, QUERY_COMPONENT_ENCODE_SET, true, false, true, true)); + encodedQueryNamesAndValues.add(encodedValue != null + ? canonicalize(encodedValue, QUERY_COMPONENT_ENCODE_SET, true, false, true, true) + : null); + return this; + } + + public Builder setQueryParameter(String name, String value) { + removeAllQueryParameters(name); + addQueryParameter(name, value); + return this; + } + + public Builder setEncodedQueryParameter(String encodedName, String encodedValue) { + removeAllEncodedQueryParameters(encodedName); + addEncodedQueryParameter(encodedName, encodedValue); + return this; + } + + public Builder removeAllQueryParameters(String name) { + if (name == null) throw new IllegalArgumentException("name == null"); + if (encodedQueryNamesAndValues == null) return this; + String nameToRemove = canonicalize( + name, QUERY_COMPONENT_ENCODE_SET, false, false, true, true); + removeAllCanonicalQueryParameters(nameToRemove); + return this; + } + + public Builder removeAllEncodedQueryParameters(String encodedName) { + if (encodedName == null) throw new IllegalArgumentException("encodedName == null"); + if (encodedQueryNamesAndValues == null) return this; + removeAllCanonicalQueryParameters( + canonicalize(encodedName, QUERY_COMPONENT_ENCODE_SET, true, false, true, true)); + return this; + } + + private void removeAllCanonicalQueryParameters(String canonicalName) { + for (int i = encodedQueryNamesAndValues.size() - 2; i >= 0; i -= 2) { + if (canonicalName.equals(encodedQueryNamesAndValues.get(i))) { + encodedQueryNamesAndValues.remove(i + 1); + encodedQueryNamesAndValues.remove(i); + if (encodedQueryNamesAndValues.isEmpty()) { + encodedQueryNamesAndValues = null; + return; + } + } + } + } + + public Builder fragment(String fragment) { + this.encodedFragment = fragment != null + ? canonicalize(fragment, FRAGMENT_ENCODE_SET, false, false, false, false) + : null; + return this; + } + + public Builder encodedFragment(String encodedFragment) { + this.encodedFragment = encodedFragment != null + ? canonicalize(encodedFragment, FRAGMENT_ENCODE_SET, true, false, false, false) + : null; + return this; + } + + /** + * Re-encodes the components of this URL so that it satisfies (obsolete) RFC 2396, which is + * particularly strict for certain components. + */ + Builder reencodeForUri() { + for (int i = 0, size = encodedPathSegments.size(); i < size; i++) { + String pathSegment = encodedPathSegments.get(i); + encodedPathSegments.set(i, + canonicalize(pathSegment, PATH_SEGMENT_ENCODE_SET_URI, true, true, false, true)); + } + if (encodedQueryNamesAndValues != null) { + for (int i = 0, size = encodedQueryNamesAndValues.size(); i < size; i++) { + String component = encodedQueryNamesAndValues.get(i); + if (component != null) { + encodedQueryNamesAndValues.set(i, + canonicalize(component, QUERY_COMPONENT_ENCODE_SET_URI, true, true, true, true)); + } + } + } + if (encodedFragment != null) { + encodedFragment = canonicalize( + encodedFragment, FRAGMENT_ENCODE_SET_URI, true, true, false, false); + } + return this; + } + + public HttpUrl build() { + if (scheme == null) throw new IllegalStateException("scheme == null"); + if (host == null) throw new IllegalStateException("host == null"); + return new HttpUrl(this); + } + + @Override public String toString() { + StringBuilder result = new StringBuilder(); + result.append(scheme); + result.append("://"); + + if (!encodedUsername.isEmpty() || !encodedPassword.isEmpty()) { + result.append(encodedUsername); + if (!encodedPassword.isEmpty()) { + result.append(':'); + result.append(encodedPassword); + } + result.append('@'); + } + + if (host.indexOf(':') != -1) { + // Host is an IPv6 address. + result.append('['); + result.append(host); + result.append(']'); + } else { + result.append(host); + } + + int effectivePort = effectivePort(); + if (effectivePort != defaultPort(scheme)) { + result.append(':'); + result.append(effectivePort); + } + + pathSegmentsToString(result, encodedPathSegments); + + if (encodedQueryNamesAndValues != null) { + result.append('?'); + namesAndValuesToQueryString(result, encodedQueryNamesAndValues); + } + + if (encodedFragment != null) { + result.append('#'); + result.append(encodedFragment); + } + + return result.toString(); + } + + enum ParseResult { + SUCCESS, + MISSING_SCHEME, + UNSUPPORTED_SCHEME, + INVALID_PORT, + INVALID_HOST, + } + + ParseResult parse(HttpUrl base, String input) { + int pos = skipLeadingAsciiWhitespace(input, 0, input.length()); + int limit = skipTrailingAsciiWhitespace(input, pos, input.length()); + + // Scheme. + int schemeDelimiterOffset = schemeDelimiterOffset(input, pos, limit); + if (schemeDelimiterOffset != -1) { + if (input.regionMatches(true, pos, "https:", 0, 6)) { + this.scheme = "https"; + pos += "https:".length(); + } else if (input.regionMatches(true, pos, "http:", 0, 5)) { + this.scheme = "http"; + pos += "http:".length(); + } else { + return ParseResult.UNSUPPORTED_SCHEME; // Not an HTTP scheme. + } + } else if (base != null) { + this.scheme = base.scheme; + } else { + return ParseResult.MISSING_SCHEME; // No scheme. + } + + // Authority. + boolean hasUsername = false; + boolean hasPassword = false; + int slashCount = slashCount(input, pos, limit); + if (slashCount >= 2 || base == null || !base.scheme.equals(this.scheme)) { + // Read an authority if either: + // * The input starts with 2 or more slashes. These follow the scheme if it exists. + // * The input scheme exists and is different from the base URL's scheme. + // + // The structure of an authority is: + // username:password@host:port + // + // Username, password and port are optional. + // [username[:password]@]host[:port] + pos += slashCount; + authority: + while (true) { + int componentDelimiterOffset = delimiterOffset(input, pos, limit, "@/\\?#"); + int c = componentDelimiterOffset != limit + ? input.charAt(componentDelimiterOffset) + : -1; + switch (c) { + case '@': + // User info precedes. + if (!hasPassword) { + int passwordColonOffset = delimiterOffset( + input, pos, componentDelimiterOffset, ':'); + String canonicalUsername = canonicalize( + input, pos, passwordColonOffset, USERNAME_ENCODE_SET, true, false, false, true); + this.encodedUsername = hasUsername + ? this.encodedUsername + "%40" + canonicalUsername + : canonicalUsername; + if (passwordColonOffset != componentDelimiterOffset) { + hasPassword = true; + this.encodedPassword = canonicalize(input, passwordColonOffset + 1, + componentDelimiterOffset, PASSWORD_ENCODE_SET, true, false, false, true); + } + hasUsername = true; + } else { + this.encodedPassword = this.encodedPassword + "%40" + canonicalize(input, pos, + componentDelimiterOffset, PASSWORD_ENCODE_SET, true, false, false, true); + } + pos = componentDelimiterOffset + 1; + break; + + case -1: + case '/': + case '\\': + case '?': + case '#': + // Host info precedes. + int portColonOffset = portColonOffset(input, pos, componentDelimiterOffset); + if (portColonOffset + 1 < componentDelimiterOffset) { + this.host = canonicalizeHost(input, pos, portColonOffset); + this.port = parsePort(input, portColonOffset + 1, componentDelimiterOffset); + if (this.port == -1) return ParseResult.INVALID_PORT; // Invalid port. + } else { + this.host = canonicalizeHost(input, pos, portColonOffset); + this.port = defaultPort(this.scheme); + } + if (this.host == null) return ParseResult.INVALID_HOST; // Invalid host. + pos = componentDelimiterOffset; + break authority; + } + } + } else { + // This is a relative link. Copy over all authority components. Also maybe the path & query. + this.encodedUsername = base.encodedUsername(); + this.encodedPassword = base.encodedPassword(); + this.host = base.host; + this.port = base.port; + this.encodedPathSegments.clear(); + this.encodedPathSegments.addAll(base.encodedPathSegments()); + if (pos == limit || input.charAt(pos) == '#') { + encodedQuery(base.encodedQuery()); + } + } + + // Resolve the relative path. + int pathDelimiterOffset = delimiterOffset(input, pos, limit, "?#"); + resolvePath(input, pos, pathDelimiterOffset); + pos = pathDelimiterOffset; + + // Query. + if (pos < limit && input.charAt(pos) == '?') { + int queryDelimiterOffset = delimiterOffset(input, pos, limit, '#'); + this.encodedQueryNamesAndValues = queryStringToNamesAndValues(canonicalize( + input, pos + 1, queryDelimiterOffset, QUERY_ENCODE_SET, true, false, true, true)); + pos = queryDelimiterOffset; + } + + // Fragment. + if (pos < limit && input.charAt(pos) == '#') { + this.encodedFragment = canonicalize( + input, pos + 1, limit, FRAGMENT_ENCODE_SET, true, false, false, false); + } + + return ParseResult.SUCCESS; + } + + private void resolvePath(String input, int pos, int limit) { + // Read a delimiter. + if (pos == limit) { + // Empty path: keep the base path as-is. + return; + } + char c = input.charAt(pos); + if (c == '/' || c == '\\') { + // Absolute path: reset to the default "/". + encodedPathSegments.clear(); + encodedPathSegments.add(""); + pos++; + } else { + // Relative path: clear everything after the last '/'. + encodedPathSegments.set(encodedPathSegments.size() - 1, ""); + } + + // Read path segments. + for (int i = pos; i < limit; ) { + int pathSegmentDelimiterOffset = delimiterOffset(input, i, limit, "/\\"); + boolean segmentHasTrailingSlash = pathSegmentDelimiterOffset < limit; + push(input, i, pathSegmentDelimiterOffset, segmentHasTrailingSlash, true); + i = pathSegmentDelimiterOffset; + if (segmentHasTrailingSlash) i++; + } + } + + /** Adds a path segment. If the input is ".." or equivalent, this pops a path segment. */ + private void push(String input, int pos, int limit, boolean addTrailingSlash, + boolean alreadyEncoded) { + String segment = canonicalize( + input, pos, limit, PATH_SEGMENT_ENCODE_SET, alreadyEncoded, false, false, true); + if (isDot(segment)) { + return; // Skip '.' path segments. + } + if (isDotDot(segment)) { + pop(); + return; + } + if (encodedPathSegments.get(encodedPathSegments.size() - 1).isEmpty()) { + encodedPathSegments.set(encodedPathSegments.size() - 1, segment); + } else { + encodedPathSegments.add(segment); + } + if (addTrailingSlash) { + encodedPathSegments.add(""); + } + } + + private boolean isDot(String input) { + return input.equals(".") || input.equalsIgnoreCase("%2e"); + } + + private boolean isDotDot(String input) { + return input.equals("..") + || input.equalsIgnoreCase("%2e.") + || input.equalsIgnoreCase(".%2e") + || input.equalsIgnoreCase("%2e%2e"); + } + + /** + * Removes a path segment. When this method returns the last segment is always "", which means + * the encoded path will have a trailing '/'. + * + *

Popping "/a/b/c/" yields "/a/b/". In this case the list of path segments goes from ["a", + * "b", "c", ""] to ["a", "b", ""]. + * + *

Popping "/a/b/c" also yields "/a/b/". The list of path segments goes from ["a", "b", "c"] + * to ["a", "b", ""]. + */ + private void pop() { + String removed = encodedPathSegments.remove(encodedPathSegments.size() - 1); + + // Make sure the path ends with a '/' by either adding an empty string or clearing a segment. + if (removed.isEmpty() && !encodedPathSegments.isEmpty()) { + encodedPathSegments.set(encodedPathSegments.size() - 1, ""); + } else { + encodedPathSegments.add(""); + } + } + + /** + * Returns the index of the ':' in {@code input} that is after scheme characters. Returns -1 if + * {@code input} does not have a scheme that starts at {@code pos}. + */ + private static int schemeDelimiterOffset(String input, int pos, int limit) { + if (limit - pos < 2) return -1; + + char c0 = input.charAt(pos); + if ((c0 < 'a' || c0 > 'z') && (c0 < 'A' || c0 > 'Z')) return -1; // Not a scheme start char. + + for (int i = pos + 1; i < limit; i++) { + char c = input.charAt(i); + + if ((c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '+' + || c == '-' + || c == '.') { + continue; // Scheme character. Keep going. + } else if (c == ':') { + return i; // Scheme prefix! + } else { + return -1; // Non-scheme character before the first ':'. + } + } + + return -1; // No ':'; doesn't start with a scheme. + } + + /** Returns the number of '/' and '\' slashes in {@code input}, starting at {@code pos}. */ + private static int slashCount(String input, int pos, int limit) { + int slashCount = 0; + while (pos < limit) { + char c = input.charAt(pos); + if (c == '\\' || c == '/') { + slashCount++; + pos++; + } else { + break; + } + } + return slashCount; + } + + /** Finds the first ':' in {@code input}, skipping characters between square braces "[...]". */ + private static int portColonOffset(String input, int pos, int limit) { + for (int i = pos; i < limit; i++) { + switch (input.charAt(i)) { + case '[': + while (++i < limit) { + if (input.charAt(i) == ']') break; + } + break; + case ':': + return i; + } + } + return limit; // No colon. + } + + private static String canonicalizeHost(String input, int pos, int limit) { + // Start by percent decoding the host. The WHATWG spec suggests doing this only after we've + // checked for IPv6 square braces. But Chrome does it first, and that's more lenient. + String percentDecoded = percentDecode(input, pos, limit, false); + + // If the input is encased in square braces "[...]", drop 'em. We have an IPv6 address. + if (percentDecoded.startsWith("[") && percentDecoded.endsWith("]")) { + InetAddress inetAddress = decodeIpv6(percentDecoded, 1, percentDecoded.length() - 1); + if (inetAddress == null) return null; + byte[] address = inetAddress.getAddress(); + if (address.length == 16) return inet6AddressToAscii(address); + throw new AssertionError(); + } + + return domainToAscii(percentDecoded); + } + + /** Decodes an IPv6 address like 1111:2222:3333:4444:5555:6666:7777:8888 or ::1. */ + private static InetAddress decodeIpv6(String input, int pos, int limit) { + byte[] address = new byte[16]; + int b = 0; + int compress = -1; + int groupOffset = -1; + + for (int i = pos; i < limit; ) { + if (b == address.length) return null; // Too many groups. + + // Read a delimiter. + if (i + 2 <= limit && input.regionMatches(i, "::", 0, 2)) { + // Compression "::" delimiter, which is anywhere in the input, including its prefix. + if (compress != -1) return null; // Multiple "::" delimiters. + i += 2; + b += 2; + compress = b; + if (i == limit) break; + } else if (b != 0) { + // Group separator ":" delimiter. + if (input.regionMatches(i, ":", 0, 1)) { + i++; + } else if (input.regionMatches(i, ".", 0, 1)) { + // If we see a '.', rewind to the beginning of the previous group and parse as IPv4. + if (!decodeIpv4Suffix(input, groupOffset, limit, address, b - 2)) return null; + b += 2; // We rewound two bytes and then added four. + break; + } else { + return null; // Wrong delimiter. + } + } + + // Read a group, one to four hex digits. + int value = 0; + groupOffset = i; + for (; i < limit; i++) { + char c = input.charAt(i); + int hexDigit = decodeHexDigit(c); + if (hexDigit == -1) break; + value = (value << 4) + hexDigit; + } + int groupLength = i - groupOffset; + if (groupLength == 0 || groupLength > 4) return null; // Group is the wrong size. + + // We've successfully read a group. Assign its value to our byte array. + address[b++] = (byte) ((value >>> 8) & 0xff); + address[b++] = (byte) (value & 0xff); + } + + // All done. If compression happened, we need to move bytes to the right place in the + // address. Here's a sample: + // + // input: "1111:2222:3333::7777:8888" + // before: { 11, 11, 22, 22, 33, 33, 00, 00, 77, 77, 88, 88, 00, 00, 00, 00 } + // compress: 6 + // b: 10 + // after: { 11, 11, 22, 22, 33, 33, 00, 00, 00, 00, 00, 00, 77, 77, 88, 88 } + // + if (b != address.length) { + if (compress == -1) return null; // Address didn't have compression or enough groups. + System.arraycopy(address, compress, address, address.length - (b - compress), b - compress); + Arrays.fill(address, compress, compress + (address.length - b), (byte) 0); + } + + try { + return InetAddress.getByAddress(address); + } catch (UnknownHostException e) { + throw new AssertionError(); + } + } + + /** Decodes an IPv4 address suffix of an IPv6 address, like 1111::5555:6666:192.168.0.1. */ + private static boolean decodeIpv4Suffix( + String input, int pos, int limit, byte[] address, int addressOffset) { + int b = addressOffset; + + for (int i = pos; i < limit; ) { + if (b == address.length) return false; // Too many groups. + + // Read a delimiter. + if (b != addressOffset) { + if (input.charAt(i) != '.') return false; // Wrong delimiter. + i++; + } + + // Read 1 or more decimal digits for a value in 0..255. + int value = 0; + int groupOffset = i; + for (; i < limit; i++) { + char c = input.charAt(i); + if (c < '0' || c > '9') break; + if (value == 0 && groupOffset != i) return false; // Reject unnecessary leading '0's. + value = (value * 10) + c - '0'; + if (value > 255) return false; // Value out of range. + } + int groupLength = i - groupOffset; + if (groupLength == 0) return false; // No digits. + + // We've successfully read a byte. + address[b++] = (byte) value; + } + + if (b != addressOffset + 4) return false; // Too few groups. We wanted exactly four. + return true; // Success. + } + + private static String inet6AddressToAscii(byte[] address) { + // Go through the address looking for the longest run of 0s. Each group is 2-bytes. + int longestRunOffset = -1; + int longestRunLength = 0; + for (int i = 0; i < address.length; i += 2) { + int currentRunOffset = i; + while (i < 16 && address[i] == 0 && address[i + 1] == 0) { + i += 2; + } + int currentRunLength = i - currentRunOffset; + if (currentRunLength > longestRunLength) { + longestRunOffset = currentRunOffset; + longestRunLength = currentRunLength; + } + } + + // Emit each 2-byte group in hex, separated by ':'. The longest run of zeroes is "::". + Buffer result = new Buffer(); + for (int i = 0; i < address.length; ) { + if (i == longestRunOffset) { + result.writeByte(':'); + i += longestRunLength; + if (i == 16) result.writeByte(':'); + } else { + if (i > 0) result.writeByte(':'); + int group = (address[i] & 0xff) << 8 | address[i + 1] & 0xff; + result.writeHexadecimalUnsignedLong(group); + i += 2; + } + } + return result.readUtf8(); + } + + private static int parsePort(String input, int pos, int limit) { + try { + // Canonicalize the port string to skip '\n' etc. + String portString = canonicalize(input, pos, limit, "", false, false, false, true); + int i = Integer.parseInt(portString); + if (i > 0 && i <= 65535) return i; + return -1; + } catch (NumberFormatException e) { + return -1; // Invalid port. + } + } + } + + static String percentDecode(String encoded, boolean plusIsSpace) { + return percentDecode(encoded, 0, encoded.length(), plusIsSpace); + } + + private List percentDecode(List list, boolean plusIsSpace) { + List result = new ArrayList<>(list.size()); + for (String s : list) { + result.add(s != null ? percentDecode(s, plusIsSpace) : null); + } + return Collections.unmodifiableList(result); + } + + static String percentDecode(String encoded, int pos, int limit, boolean plusIsSpace) { + for (int i = pos; i < limit; i++) { + char c = encoded.charAt(i); + if (c == '%' || (c == '+' && plusIsSpace)) { + // Slow path: the character at i requires decoding! + Buffer out = new Buffer(); + out.writeUtf8(encoded, pos, i); + percentDecode(out, encoded, i, limit, plusIsSpace); + return out.readUtf8(); + } + } + + // Fast path: no characters in [pos..limit) required decoding. + return encoded.substring(pos, limit); + } + + static void percentDecode(Buffer out, String encoded, int pos, int limit, boolean plusIsSpace) { + int codePoint; + for (int i = pos; i < limit; i += Character.charCount(codePoint)) { + codePoint = encoded.codePointAt(i); + if (codePoint == '%' && i + 2 < limit) { + int d1 = decodeHexDigit(encoded.charAt(i + 1)); + int d2 = decodeHexDigit(encoded.charAt(i + 2)); + if (d1 != -1 && d2 != -1) { + out.writeByte((d1 << 4) + d2); + i += 2; + continue; + } + } else if (codePoint == '+' && plusIsSpace) { + out.writeByte(' '); + continue; + } + out.writeUtf8CodePoint(codePoint); + } + } + + static boolean percentEncoded(String encoded, int pos, int limit) { + return pos + 2 < limit + && encoded.charAt(pos) == '%' + && decodeHexDigit(encoded.charAt(pos + 1)) != -1 + && decodeHexDigit(encoded.charAt(pos + 2)) != -1; + } + + static int decodeHexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + /** + * Returns a substring of {@code input} on the range {@code [pos..limit)} with the following + * transformations: + *

    + *
  • Tabs, newlines, form feeds and carriage returns are skipped. + *
  • In queries, ' ' is encoded to '+' and '+' is encoded to "%2B". + *
  • Characters in {@code encodeSet} are percent-encoded. + *
  • Control characters and non-ASCII characters are percent-encoded. + *
  • All other characters are copied without transformation. + *
+ * + * @param alreadyEncoded true to leave '%' as-is; false to convert it to '%25'. + * @param strict true to encode '%' if it is not the prefix of a valid percent encoding. + * @param plusIsSpace true to encode '+' as "%2B" if it is not already encoded. + * @param asciiOnly true to encode all non-ASCII codepoints. + */ + static String canonicalize(String input, int pos, int limit, String encodeSet, + boolean alreadyEncoded, boolean strict, boolean plusIsSpace, boolean asciiOnly) { + int codePoint; + for (int i = pos; i < limit; i += Character.charCount(codePoint)) { + codePoint = input.codePointAt(i); + if (codePoint < 0x20 + || codePoint == 0x7f + || codePoint >= 0x80 && asciiOnly + || encodeSet.indexOf(codePoint) != -1 + || codePoint == '%' && (!alreadyEncoded || strict && !percentEncoded(input, i, limit)) + || codePoint == '+' && plusIsSpace) { + // Slow path: the character at i requires encoding! + Buffer out = new Buffer(); + out.writeUtf8(input, pos, i); + canonicalize(out, input, i, limit, encodeSet, alreadyEncoded, strict, plusIsSpace, + asciiOnly); + return out.readUtf8(); + } + } + + // Fast path: no characters in [pos..limit) required encoding. + return input.substring(pos, limit); + } + + static void canonicalize(Buffer out, String input, int pos, int limit, String encodeSet, + boolean alreadyEncoded, boolean strict, boolean plusIsSpace, boolean asciiOnly) { + Buffer utf8Buffer = null; // Lazily allocated. + int codePoint; + for (int i = pos; i < limit; i += Character.charCount(codePoint)) { + codePoint = input.codePointAt(i); + if (alreadyEncoded + && (codePoint == '\t' || codePoint == '\n' || codePoint == '\f' || codePoint == '\r')) { + // Skip this character. + } else if (codePoint == '+' && plusIsSpace) { + // Encode '+' as '%2B' since we permit ' ' to be encoded as either '+' or '%20'. + out.writeUtf8(alreadyEncoded ? "+" : "%2B"); + } else if (codePoint < 0x20 + || codePoint == 0x7f + || codePoint >= 0x80 && asciiOnly + || encodeSet.indexOf(codePoint) != -1 + || codePoint == '%' && (!alreadyEncoded || strict && !percentEncoded(input, i, limit))) { + // Percent encode this character. + if (utf8Buffer == null) { + utf8Buffer = new Buffer(); + } + utf8Buffer.writeUtf8CodePoint(codePoint); + while (!utf8Buffer.exhausted()) { + int b = utf8Buffer.readByte() & 0xff; + out.writeByte('%'); + out.writeByte(HEX_DIGITS[(b >> 4) & 0xf]); + out.writeByte(HEX_DIGITS[b & 0xf]); + } + } else { + // This character doesn't need encoding. Just copy it over. + out.writeUtf8CodePoint(codePoint); + } + } + } + + static String canonicalize(String input, String encodeSet, boolean alreadyEncoded, boolean strict, + boolean plusIsSpace, boolean asciiOnly) { + return canonicalize( + input, 0, input.length(), encodeSet, alreadyEncoded, strict, plusIsSpace, asciiOnly); + } +} diff --git a/okhttp3/src/main/java/okhttp3/Interceptor.java b/okhttp3/src/main/java/okhttp3/Interceptor.java new file mode 100644 index 000000000..94effbdf4 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Interceptor.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; + +/** + * Observes, modifies, and potentially short-circuits requests going out and the corresponding + * requests coming back in. Typically interceptors will be used to add, remove, or transform headers + * on the request or response. + */ +public interface Interceptor { + Response intercept(Chain chain) throws IOException; + + interface Chain { + Request request(); + + Response proceed(Request request) throws IOException; + + Connection connection(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/MediaType.java b/okhttp3/src/main/java/okhttp3/MediaType.java new file mode 100644 index 000000000..bbdcedf68 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/MediaType.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.nio.charset.Charset; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * An RFC 2045 Media Type, appropriate to describe + * the content type of an HTTP request or response body. + */ +public final class MediaType { + private static final String TOKEN = "([a-zA-Z0-9-!#$%&'*+.^_`{|}~]+)"; + private static final String QUOTED = "\"([^\"]*)\""; + private static final Pattern TYPE_SUBTYPE = Pattern.compile(TOKEN + "/" + TOKEN); + private static final Pattern PARAMETER = Pattern.compile( + ";\\s*(?:" + TOKEN + "=(?:" + TOKEN + "|" + QUOTED + "))?"); + + private final String mediaType; + private final String type; + private final String subtype; + private final String charset; + + private MediaType(String mediaType, String type, String subtype, String charset) { + this.mediaType = mediaType; + this.type = type; + this.subtype = subtype; + this.charset = charset; + } + + /** + * Returns a media type for {@code string}, or null if {@code string} is not a well-formed media + * type. + */ + public static MediaType parse(String string) { + Matcher typeSubtype = TYPE_SUBTYPE.matcher(string); + if (!typeSubtype.lookingAt()) return null; + String type = typeSubtype.group(1).toLowerCase(Locale.US); + String subtype = typeSubtype.group(2).toLowerCase(Locale.US); + + String charset = null; + Matcher parameter = PARAMETER.matcher(string); + for (int s = typeSubtype.end(); s < string.length(); s = parameter.end()) { + parameter.region(s, string.length()); + if (!parameter.lookingAt()) return null; // This is not a well-formed media type. + + String name = parameter.group(1); + if (name == null || !name.equalsIgnoreCase("charset")) continue; + String charsetParameter = parameter.group(2) != null + ? parameter.group(2) // Value is a token. + : parameter.group(3); // Value is a quoted string. + if (charset != null && !charsetParameter.equalsIgnoreCase(charset)) { + throw new IllegalArgumentException("Multiple different charsets: " + string); + } + charset = charsetParameter; + } + + return new MediaType(string, type, subtype, charset); + } + + /** + * Returns the high-level media type, such as "text", "image", "audio", "video", or + * "application". + */ + public String type() { + return type; + } + + /** + * Returns a specific media subtype, such as "plain" or "png", "mpeg", "mp4" or "xml". + */ + public String subtype() { + return subtype; + } + + /** + * Returns the charset of this media type, or null if this media type doesn't specify a charset. + */ + public Charset charset() { + return charset != null ? Charset.forName(charset) : null; + } + + /** + * Returns the charset of this media type, or {@code defaultValue} if this media type doesn't + * specify a charset. + */ + public Charset charset(Charset defaultValue) { + return charset != null ? Charset.forName(charset) : defaultValue; + } + + /** + * Returns the encoded media type, like "text/plain; charset=utf-8", appropriate for use in a + * Content-Type header. + */ + @Override public String toString() { + return mediaType; + } + + @Override public boolean equals(Object o) { + return o instanceof MediaType && ((MediaType) o).mediaType.equals(mediaType); + } + + @Override public int hashCode() { + return mediaType.hashCode(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/MultipartBody.java b/okhttp3/src/main/java/okhttp3/MultipartBody.java new file mode 100644 index 000000000..7ac584d1d --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/MultipartBody.java @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSink; +import okio.ByteString; + +/** An RFC 2387-compliant request body. */ +public final class MultipartBody extends RequestBody { + /** + * The "mixed" subtype of "multipart" is intended for use when the body parts are independent and + * need to be bundled in a particular order. Any "multipart" subtypes that an implementation does + * not recognize must be treated as being of subtype "mixed". + */ + public static final MediaType MIXED = MediaType.parse("multipart/mixed"); + + /** + * The "multipart/alternative" type is syntactically identical to "multipart/mixed", but the + * semantics are different. In particular, each of the body parts is an "alternative" version of + * the same information. + */ + public static final MediaType ALTERNATIVE = MediaType.parse("multipart/alternative"); + + /** + * This type is syntactically identical to "multipart/mixed", but the semantics are different. In + * particular, in a digest, the default {@code Content-Type} value for a body part is changed from + * "text/plain" to "message/rfc822". + */ + public static final MediaType DIGEST = MediaType.parse("multipart/digest"); + + /** + * This type is syntactically identical to "multipart/mixed", but the semantics are different. In + * particular, in a parallel entity, the order of body parts is not significant. + */ + public static final MediaType PARALLEL = MediaType.parse("multipart/parallel"); + + /** + * The media-type multipart/form-data follows the rules of all multipart MIME data streams as + * outlined in RFC 2046. In forms, there are a series of fields to be supplied by the user who + * fills out the form. Each field has a name. Within a given form, the names are unique. + */ + public static final MediaType FORM = MediaType.parse("multipart/form-data"); + + private static final byte[] COLONSPACE = {':', ' '}; + private static final byte[] CRLF = {'\r', '\n'}; + private static final byte[] DASHDASH = {'-', '-'}; + + private final ByteString boundary; + private final MediaType originalType; + private final MediaType contentType; + private final List parts; + private long contentLength = -1L; + + MultipartBody(ByteString boundary, MediaType type, List parts) { + this.boundary = boundary; + this.originalType = type; + this.contentType = MediaType.parse(type + "; boundary=" + boundary.utf8()); + this.parts = Util.immutableList(parts); + } + + public MediaType type() { + return originalType; + } + + public String boundary() { + return boundary.utf8(); + } + + /** The number of parts in this multipart body. */ + public int size() { + return parts.size(); + } + + public List parts() { + return parts; + } + + public Part part(int index) { + return parts.get(index); + } + + /** A combination of {@link #type()} and {@link #boundary()}. */ + @Override public MediaType contentType() { + return contentType; + } + + @Override public long contentLength() throws IOException { + long result = contentLength; + if (result != -1L) return result; + return contentLength = writeOrCountBytes(null, true); + } + + @Override public void writeTo(BufferedSink sink) throws IOException { + writeOrCountBytes(sink, false); + } + + /** + * Either writes this request to {@code sink} or measures its content length. We have one method + * do double-duty to make sure the counting and content are consistent, particularly when it comes + * to awkward operations like measuring the encoded length of header strings, or the + * length-in-digits of an encoded integer. + */ + private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException { + long byteCount = 0L; + + Buffer byteCountBuffer = null; + if (countBytes) { + sink = byteCountBuffer = new Buffer(); + } + + for (int p = 0, partCount = parts.size(); p < partCount; p++) { + Part part = parts.get(p); + Headers headers = part.headers; + RequestBody body = part.body; + + sink.write(DASHDASH); + sink.write(boundary); + sink.write(CRLF); + + if (headers != null) { + for (int h = 0, headerCount = headers.size(); h < headerCount; h++) { + sink.writeUtf8(headers.name(h)) + .write(COLONSPACE) + .writeUtf8(headers.value(h)) + .write(CRLF); + } + } + + MediaType contentType = body.contentType(); + if (contentType != null) { + sink.writeUtf8("Content-Type: ") + .writeUtf8(contentType.toString()) + .write(CRLF); + } + + long contentLength = body.contentLength(); + if (contentLength != -1) { + sink.writeUtf8("Content-Length: ") + .writeDecimalLong(contentLength) + .write(CRLF); + } else if (countBytes) { + // We can't measure the body's size without the sizes of its components. + byteCountBuffer.clear(); + return -1L; + } + + sink.write(CRLF); + + if (countBytes) { + byteCount += contentLength; + } else { + body.writeTo(sink); + } + + sink.write(CRLF); + } + + sink.write(DASHDASH); + sink.write(boundary); + sink.write(DASHDASH); + sink.write(CRLF); + + if (countBytes) { + byteCount += byteCountBuffer.size(); + byteCountBuffer.clear(); + } + + return byteCount; + } + + /** + * Appends a quoted-string to a StringBuilder. + * + *

RFC 2388 is rather vague about how one should escape special characters in form-data + * parameters, and as it turns out Firefox and Chrome actually do rather different things, and + * both say in their comments that they're not really sure what the right approach is. We go with + * Chrome's behavior (which also experimentally seems to match what IE does), but if you actually + * want to have a good chance of things working, please avoid double-quotes, newlines, percent + * signs, and the like in your field names. + */ + static StringBuilder appendQuotedString(StringBuilder target, String key) { + target.append('"'); + for (int i = 0, len = key.length(); i < len; i++) { + char ch = key.charAt(i); + switch (ch) { + case '\n': + target.append("%0A"); + break; + case '\r': + target.append("%0D"); + break; + case '"': + target.append("%22"); + break; + default: + target.append(ch); + break; + } + } + target.append('"'); + return target; + } + + public static final class Part { + public static Part create(RequestBody body) { + return create(null, body); + } + + public static Part create(Headers headers, RequestBody body) { + if (body == null) { + throw new NullPointerException("body == null"); + } + if (headers != null && headers.get("Content-Type") != null) { + throw new IllegalArgumentException("Unexpected header: Content-Type"); + } + if (headers != null && headers.get("Content-Length") != null) { + throw new IllegalArgumentException("Unexpected header: Content-Length"); + } + return new Part(headers, body); + } + + public static Part createFormData(String name, String value) { + return createFormData(name, null, RequestBody.create(null, value)); + } + + public static Part createFormData(String name, String filename, RequestBody body) { + if (name == null) { + throw new NullPointerException("name == null"); + } + StringBuilder disposition = new StringBuilder("form-data; name="); + appendQuotedString(disposition, name); + + if (filename != null) { + disposition.append("; filename="); + appendQuotedString(disposition, filename); + } + + return create(Headers.of("Content-Disposition", disposition.toString()), body); + } + + private final Headers headers; + private final RequestBody body; + + private Part(Headers headers, RequestBody body) { + this.headers = headers; + this.body = body; + } + } + + public static final class Builder { + private final ByteString boundary; + private MediaType type = MIXED; + private final List parts = new ArrayList<>(); + + public Builder() { + this(UUID.randomUUID().toString()); + } + + public Builder(String boundary) { + this.boundary = ByteString.encodeUtf8(boundary); + } + + /** + * Set the MIME type. Expected values for {@code type} are {@link #MIXED} (the default), {@link + * #ALTERNATIVE}, {@link #DIGEST}, {@link #PARALLEL} and {@link #FORM}. + */ + public Builder setType(MediaType type) { + if (type == null) { + throw new NullPointerException("type == null"); + } + if (!type.type().equals("multipart")) { + throw new IllegalArgumentException("multipart != " + type); + } + this.type = type; + return this; + } + + /** Add a part to the body. */ + public Builder addPart(RequestBody body) { + return addPart(Part.create(body)); + } + + /** Add a part to the body. */ + public Builder addPart(Headers headers, RequestBody body) { + return addPart(Part.create(headers, body)); + } + + /** Add a form data part to the body. */ + public Builder addFormDataPart(String name, String value) { + return addPart(Part.createFormData(name, value)); + } + + /** Add a form data part to the body. */ + public Builder addFormDataPart(String name, String filename, RequestBody body) { + return addPart(Part.createFormData(name, filename, body)); + } + + /** Add a part to the body. */ + public Builder addPart(Part part) { + if (part == null) throw new NullPointerException("part == null"); + parts.add(part); + return this; + } + + /** Assemble the specified parts into a request body. */ + public MultipartBody build() { + if (parts.isEmpty()) { + throw new IllegalStateException("Multipart body must have at least one part."); + } + return new MultipartBody(boundary, type, parts); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/OkHttpClient.java b/okhttp3/src/main/java/okhttp3/OkHttpClient.java new file mode 100644 index 000000000..3d7470948 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/OkHttpClient.java @@ -0,0 +1,695 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.MalformedURLException; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.UnknownHostException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.net.SocketFactory; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import okhttp3.internal.Internal; +import okhttp3.internal.InternalCache; +import okhttp3.internal.RouteDatabase; +import okhttp3.internal.Util; +import okhttp3.internal.http.StreamAllocation; +import okhttp3.internal.io.RealConnection; +import okhttp3.internal.tls.OkHostnameVerifier; + +/** + * Factory for {@linkplain Call calls}, which can be used to send HTTP requests and read their + * responses. Most applications can use a single OkHttpClient for all of their HTTP requests, + * benefiting from a shared response cache, thread pool, connection re-use, etc. + * + *

To create an {@code OkHttpClient} with the default settings, use the {@linkplain + * #OkHttpClient() default constructor}. Or create a configured instance with {@link + * OkHttpClient.Builder}. To adjust an existing client before making a request, use {@link + * #newBuilder()}. This example shows a call with a 30 second timeout: + *

   {@code
+ *
+ *   OkHttpClient client = ...
+ *   OkHttpClient clientWith30sTimeout = client.newBuilder()
+ *       .readTimeout(30, TimeUnit.SECONDS)
+ *       .build();
+ *   Response response = clientWith30sTimeout.newCall(request).execute();
+ * }
+ */ +public final class OkHttpClient implements Cloneable, Call.Factory { + private static final List DEFAULT_PROTOCOLS = Util.immutableList( + Protocol.HTTP_2, Protocol.SPDY_3, Protocol.HTTP_1_1); + + private static final List DEFAULT_CONNECTION_SPECS = Util.immutableList( + ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT); + + static { + Internal.instance = new Internal() { + @Override public void addLenient(Headers.Builder builder, String line) { + builder.addLenient(line); + } + + @Override public void addLenient(Headers.Builder builder, String name, String value) { + builder.addLenient(name, value); + } + + @Override public void setCache(OkHttpClient.Builder builder, InternalCache internalCache) { + builder.setInternalCache(internalCache); + } + + @Override public InternalCache internalCache(OkHttpClient client) { + return client.internalCache(); + } + + @Override public boolean connectionBecameIdle( + ConnectionPool pool, RealConnection connection) { + return pool.connectionBecameIdle(connection); + } + + @Override public RealConnection get( + ConnectionPool pool, Address address, StreamAllocation streamAllocation) { + return pool.get(address, streamAllocation); + } + + @Override public void put(ConnectionPool pool, RealConnection connection) { + pool.put(connection); + } + + @Override public RouteDatabase routeDatabase(ConnectionPool connectionPool) { + return connectionPool.routeDatabase; + } + + @Override + public void callEnqueue(Call call, Callback responseCallback, boolean forWebSocket) { + ((RealCall) call).enqueue(responseCallback, forWebSocket); + } + + @Override public StreamAllocation callEngineGetStreamAllocation(Call call) { + return ((RealCall) call).engine.streamAllocation; + } + + @Override + public void apply(ConnectionSpec tlsConfiguration, SSLSocket sslSocket, boolean isFallback) { + tlsConfiguration.apply(sslSocket, isFallback); + } + + @Override public HttpUrl getHttpUrlChecked(String url) + throws MalformedURLException, UnknownHostException { + return HttpUrl.getChecked(url); + } + }; + } + + final Dispatcher dispatcher; + final Proxy proxy; + final List protocols; + final List connectionSpecs; + final List interceptors; + final List networkInterceptors; + final ProxySelector proxySelector; + final CookieJar cookieJar; + final Cache cache; + final InternalCache internalCache; + final SocketFactory socketFactory; + final SSLSocketFactory sslSocketFactory; + final HostnameVerifier hostnameVerifier; + final CertificatePinner certificatePinner; + final Authenticator proxyAuthenticator; + final Authenticator authenticator; + final ConnectionPool connectionPool; + final Dns dns; + final boolean followSslRedirects; + final boolean followRedirects; + final boolean retryOnConnectionFailure; + final int connectTimeout; + final int readTimeout; + final int writeTimeout; + + public OkHttpClient() { + this(new Builder()); + } + + private OkHttpClient(Builder builder) { + this.dispatcher = builder.dispatcher; + this.proxy = builder.proxy; + this.protocols = builder.protocols; + this.connectionSpecs = builder.connectionSpecs; + this.interceptors = Util.immutableList(builder.interceptors); + this.networkInterceptors = Util.immutableList(builder.networkInterceptors); + this.proxySelector = builder.proxySelector; + this.cookieJar = builder.cookieJar; + this.cache = builder.cache; + this.internalCache = builder.internalCache; + this.socketFactory = builder.socketFactory; + if (builder.sslSocketFactory != null) { + this.sslSocketFactory = builder.sslSocketFactory; + } else { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + this.sslSocketFactory = sslContext.getSocketFactory(); + } catch (GeneralSecurityException e) { + throw new AssertionError(); // The system has no TLS. Just give up. + } + } + this.hostnameVerifier = builder.hostnameVerifier; + this.certificatePinner = builder.certificatePinner; + this.proxyAuthenticator = builder.proxyAuthenticator; + this.authenticator = builder.authenticator; + this.connectionPool = builder.connectionPool; + this.dns = builder.dns; + this.followSslRedirects = builder.followSslRedirects; + this.followRedirects = builder.followRedirects; + this.retryOnConnectionFailure = builder.retryOnConnectionFailure; + this.connectTimeout = builder.connectTimeout; + this.readTimeout = builder.readTimeout; + this.writeTimeout = builder.writeTimeout; + } + + /** Default connect timeout (in milliseconds). */ + public int connectTimeoutMillis() { + return connectTimeout; + } + + /** Default read timeout (in milliseconds). */ + public int readTimeoutMillis() { + return readTimeout; + } + + /** Default write timeout (in milliseconds). */ + public int writeTimeoutMillis() { + return writeTimeout; + } + + public Proxy proxy() { + return proxy; + } + + public ProxySelector proxySelector() { + return proxySelector; + } + + public CookieJar cookieJar() { + return cookieJar; + } + + public Cache cache() { + return cache; + } + + InternalCache internalCache() { + return cache != null ? cache.internalCache : internalCache; + } + + public Dns dns() { + return dns; + } + + public SocketFactory socketFactory() { + return socketFactory; + } + + public SSLSocketFactory sslSocketFactory() { + return sslSocketFactory; + } + + public HostnameVerifier hostnameVerifier() { + return hostnameVerifier; + } + + public CertificatePinner certificatePinner() { + return certificatePinner; + } + + public Authenticator authenticator() { + return authenticator; + } + + public Authenticator proxyAuthenticator() { + return proxyAuthenticator; + } + + public ConnectionPool connectionPool() { + return connectionPool; + } + + public boolean followSslRedirects() { + return followSslRedirects; + } + + public boolean followRedirects() { + return followRedirects; + } + + public boolean retryOnConnectionFailure() { + return retryOnConnectionFailure; + } + + public Dispatcher dispatcher() { + return dispatcher; + } + + public List protocols() { + return protocols; + } + + public List connectionSpecs() { + return connectionSpecs; + } + + /** + * Returns an immutable list of interceptors that observe the full span of each call: from before + * the connection is established (if any) until after the response source is selected (either the + * origin server, cache, or both). + */ + public List interceptors() { + return interceptors; + } + + /** + * Returns an immutable list of interceptors that observe a single network request and response. + * These interceptors must call {@link Interceptor.Chain#proceed} exactly once: it is an error for + * a network interceptor to short-circuit or repeat a network request. + */ + public List networkInterceptors() { + return networkInterceptors; + } + + /** + * Prepares the {@code request} to be executed at some point in the future. + */ + @Override public Call newCall(Request request) { + return new RealCall(this, request); + } + + public Builder newBuilder() { + return new Builder(this); + } + + public static final class Builder { + Dispatcher dispatcher; + Proxy proxy; + List protocols; + List connectionSpecs; + final List interceptors = new ArrayList<>(); + final List networkInterceptors = new ArrayList<>(); + ProxySelector proxySelector; + CookieJar cookieJar; + Cache cache; + InternalCache internalCache; + SocketFactory socketFactory; + SSLSocketFactory sslSocketFactory; + HostnameVerifier hostnameVerifier; + CertificatePinner certificatePinner; + Authenticator proxyAuthenticator; + Authenticator authenticator; + ConnectionPool connectionPool; + Dns dns; + boolean followSslRedirects; + boolean followRedirects; + boolean retryOnConnectionFailure; + int connectTimeout; + int readTimeout; + int writeTimeout; + + public Builder() { + dispatcher = new Dispatcher(); + protocols = DEFAULT_PROTOCOLS; + connectionSpecs = DEFAULT_CONNECTION_SPECS; + proxySelector = ProxySelector.getDefault(); + cookieJar = CookieJar.NO_COOKIES; + socketFactory = SocketFactory.getDefault(); + hostnameVerifier = OkHostnameVerifier.INSTANCE; + certificatePinner = CertificatePinner.DEFAULT; + proxyAuthenticator = Authenticator.NONE; + authenticator = Authenticator.NONE; + connectionPool = new ConnectionPool(); + dns = Dns.SYSTEM; + followSslRedirects = true; + followRedirects = true; + retryOnConnectionFailure = true; + connectTimeout = 10_000; + readTimeout = 10_000; + writeTimeout = 10_000; + } + + Builder(OkHttpClient okHttpClient) { + this.dispatcher = okHttpClient.dispatcher; + this.proxy = okHttpClient.proxy; + this.protocols = okHttpClient.protocols; + this.connectionSpecs = okHttpClient.connectionSpecs; + this.interceptors.addAll(okHttpClient.interceptors); + this.networkInterceptors.addAll(okHttpClient.networkInterceptors); + this.proxySelector = okHttpClient.proxySelector; + this.cookieJar = okHttpClient.cookieJar; + this.internalCache = okHttpClient.internalCache; + this.cache = okHttpClient.cache; + this.socketFactory = okHttpClient.socketFactory; + this.sslSocketFactory = okHttpClient.sslSocketFactory; + this.hostnameVerifier = okHttpClient.hostnameVerifier; + this.certificatePinner = okHttpClient.certificatePinner; + this.proxyAuthenticator = okHttpClient.proxyAuthenticator; + this.authenticator = okHttpClient.authenticator; + this.connectionPool = okHttpClient.connectionPool; + this.dns = okHttpClient.dns; + this.followSslRedirects = okHttpClient.followSslRedirects; + this.followRedirects = okHttpClient.followRedirects; + this.retryOnConnectionFailure = okHttpClient.retryOnConnectionFailure; + this.connectTimeout = okHttpClient.connectTimeout; + this.readTimeout = okHttpClient.readTimeout; + this.writeTimeout = okHttpClient.writeTimeout; + } + + /** + * Sets the default connect timeout for new connections. A value of 0 means no timeout, + * otherwise values must be between 1 and {@link Integer#MAX_VALUE} when converted to + * milliseconds. + */ + public Builder connectTimeout(long timeout, TimeUnit unit) { + if (timeout < 0) throw new IllegalArgumentException("timeout < 0"); + if (unit == null) throw new IllegalArgumentException("unit == null"); + long millis = unit.toMillis(timeout); + if (millis > Integer.MAX_VALUE) throw new IllegalArgumentException("Timeout too large."); + if (millis == 0 && timeout > 0) throw new IllegalArgumentException("Timeout too small."); + connectTimeout = (int) millis; + return this; + } + + /** + * Sets the default read timeout for new connections. A value of 0 means no timeout, otherwise + * values must be between 1 and {@link Integer#MAX_VALUE} when converted to milliseconds. + */ + public Builder readTimeout(long timeout, TimeUnit unit) { + if (timeout < 0) throw new IllegalArgumentException("timeout < 0"); + if (unit == null) throw new IllegalArgumentException("unit == null"); + long millis = unit.toMillis(timeout); + if (millis > Integer.MAX_VALUE) throw new IllegalArgumentException("Timeout too large."); + if (millis == 0 && timeout > 0) throw new IllegalArgumentException("Timeout too small."); + readTimeout = (int) millis; + return this; + } + + /** + * Sets the default write timeout for new connections. A value of 0 means no timeout, otherwise + * values must be between 1 and {@link Integer#MAX_VALUE} when converted to milliseconds. + */ + public Builder writeTimeout(long timeout, TimeUnit unit) { + if (timeout < 0) throw new IllegalArgumentException("timeout < 0"); + if (unit == null) throw new IllegalArgumentException("unit == null"); + long millis = unit.toMillis(timeout); + if (millis > Integer.MAX_VALUE) throw new IllegalArgumentException("Timeout too large."); + if (millis == 0 && timeout > 0) throw new IllegalArgumentException("Timeout too small."); + writeTimeout = (int) millis; + return this; + } + + /** + * Sets the HTTP proxy that will be used by connections created by this client. This takes + * precedence over {@link #proxySelector}, which is only honored when this proxy is null (which + * it is by default). To disable proxy use completely, call {@code setProxy(Proxy.NO_PROXY)}. + */ + public Builder proxy(Proxy proxy) { + this.proxy = proxy; + return this; + } + + /** + * Sets the proxy selection policy to be used if no {@link #proxy proxy} is specified + * explicitly. The proxy selector may return multiple proxies; in that case they will be tried + * in sequence until a successful connection is established. + * + *

If unset, the {@link ProxySelector#getDefault() system-wide default} proxy selector will + * be used. + */ + public Builder proxySelector(ProxySelector proxySelector) { + this.proxySelector = proxySelector; + return this; + } + + /** + * Sets the handler that can accept cookies from incoming HTTP responses and provides cookies to + * outgoing HTTP requests. + * + *

If unset, {@linkplain CookieJar#NO_COOKIES no cookies} will be accepted nor provided. + */ + public Builder cookieJar(CookieJar cookieJar) { + if (cookieJar == null) throw new NullPointerException("cookieJar == null"); + this.cookieJar = cookieJar; + return this; + } + + /** Sets the response cache to be used to read and write cached responses. */ + void setInternalCache(InternalCache internalCache) { + this.internalCache = internalCache; + this.cache = null; + } + + public Builder cache(Cache cache) { + this.cache = cache; + this.internalCache = null; + return this; + } + + /** + * Sets the DNS service used to lookup IP addresses for hostnames. + * + *

If unset, the {@link Dns#SYSTEM system-wide default} DNS will be used. + */ + public Builder dns(Dns dns) { + if (dns == null) throw new NullPointerException("dns == null"); + this.dns = dns; + return this; + } + + /** + * Sets the socket factory used to create connections. OkHttp only uses the parameterless {@link + * SocketFactory#createSocket() createSocket()} method to create unconnected sockets. Overriding + * this method, e. g., allows the socket to be bound to a specific local address. + * + *

If unset, the {@link SocketFactory#getDefault() system-wide default} socket factory will + * be used. + */ + public Builder socketFactory(SocketFactory socketFactory) { + if (socketFactory == null) throw new NullPointerException("socketFactory == null"); + this.socketFactory = socketFactory; + return this; + } + + /** + * Sets the socket factory used to secure HTTPS connections. + * + *

If unset, a lazily created SSL socket factory will be used. + */ + public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory) { + if (sslSocketFactory == null) throw new NullPointerException("sslSocketFactory == null"); + this.sslSocketFactory = sslSocketFactory; + return this; + } + + /** + * Sets the verifier used to confirm that response certificates apply to requested hostnames for + * HTTPS connections. + * + *

If unset, a default hostname verifier will be used. + */ + public Builder hostnameVerifier(HostnameVerifier hostnameVerifier) { + if (hostnameVerifier == null) throw new NullPointerException("hostnameVerifier == null"); + this.hostnameVerifier = hostnameVerifier; + return this; + } + + /** + * Sets the certificate pinner that constrains which certificates are trusted. By default HTTPS + * connections rely on only the {@link #sslSocketFactory SSL socket factory} to establish trust. + * Pinning certificates avoids the need to trust certificate authorities. + */ + public Builder certificatePinner(CertificatePinner certificatePinner) { + if (certificatePinner == null) throw new NullPointerException("certificatePinner == null"); + this.certificatePinner = certificatePinner; + return this; + } + + /** + * Sets the authenticator used to respond to challenges from origin servers. Use {@link + * #proxyAuthenticator} to set the authenticator for proxy servers. + * + *

If unset, the {@linkplain Authenticator#NONE no authentication will be attempted}. + */ + public Builder authenticator(Authenticator authenticator) { + if (authenticator == null) throw new NullPointerException("authenticator == null"); + this.authenticator = authenticator; + return this; + } + + /** + * Sets the authenticator used to respond to challenges from proxy servers. Use {@link + * #authenticator} to set the authenticator for origin servers. + * + *

If unset, the {@linkplain Authenticator#NONE no authentication will be attempted}. + */ + public Builder proxyAuthenticator(Authenticator proxyAuthenticator) { + if (proxyAuthenticator == null) throw new NullPointerException("proxyAuthenticator == null"); + this.proxyAuthenticator = proxyAuthenticator; + return this; + } + + /** + * Sets the connection pool used to recycle HTTP and HTTPS connections. + * + *

If unset, a new connection pool will be used. + */ + public Builder connectionPool(ConnectionPool connectionPool) { + if (connectionPool == null) throw new NullPointerException("connectionPool == null"); + this.connectionPool = connectionPool; + return this; + } + + /** + * Configure this client to follow redirects from HTTPS to HTTP and from HTTP to HTTPS. + * + *

If unset, protocol redirects will be followed. This is different than the built-in {@code + * HttpURLConnection}'s default. + */ + public Builder followSslRedirects(boolean followProtocolRedirects) { + this.followSslRedirects = followProtocolRedirects; + return this; + } + + /** Configure this client to follow redirects. If unset, redirects be followed. */ + public Builder followRedirects(boolean followRedirects) { + this.followRedirects = followRedirects; + return this; + } + + /** + * Configure this client to retry or not when a connectivity problem is encountered. By default, + * this client silently recovers from the following problems: + * + *

    + *
  • Unreachable IP addresses. If the URL's host has multiple IP addresses, + * failure to reach any individual IP address doesn't fail the overall request. This can + * increase availability of multi-homed services. + *
  • Stale pooled connections. The {@link ConnectionPool} reuses sockets + * to decrease request latency, but these connections will occasionally time out. + *
  • Unreachable proxy servers. A {@link ProxySelector} can be used to + * attempt multiple proxy servers in sequence, eventually falling back to a direct + * connection. + *
+ * + * Set this to false to avoid retrying requests when doing so is destructive. In this case the + * calling application should do its own recovery of connectivity failures. + */ + public Builder retryOnConnectionFailure(boolean retryOnConnectionFailure) { + this.retryOnConnectionFailure = retryOnConnectionFailure; + return this; + } + + /** + * Sets the dispatcher used to set policy and execute asynchronous requests. Must not be null. + */ + public Builder dispatcher(Dispatcher dispatcher) { + if (dispatcher == null) throw new IllegalArgumentException("dispatcher == null"); + this.dispatcher = dispatcher; + return this; + } + + /** + * Configure the protocols used by this client to communicate with remote servers. By default + * this client will prefer the most efficient transport available, falling back to more + * ubiquitous protocols. Applications should only call this method to avoid specific + * compatibility problems, such as web servers that behave incorrectly when SPDY is enabled. + * + *

The following protocols are currently supported: + * + *

+ * + *

This is an evolving set. Future releases include support for transitional + * protocols. The http/1.1 transport will never be dropped. + * + *

If multiple protocols are specified, ALPN will be used to + * negotiate a transport. + * + *

{@link Protocol#HTTP_1_0} is not supported in this set. Requests are initiated with {@code + * HTTP/1.1} only. If the server responds with {@code HTTP/1.0}, that will be exposed by {@link + * Response#protocol()}. + * + * @param protocols the protocols to use, in order of preference. The list must contain {@link + * Protocol#HTTP_1_1}. It must not contain null or {@link Protocol#HTTP_1_0}. + */ + public Builder protocols(List protocols) { + protocols = Util.immutableList(protocols); + if (!protocols.contains(Protocol.HTTP_1_1)) { + throw new IllegalArgumentException("protocols doesn't contain http/1.1: " + protocols); + } + if (protocols.contains(Protocol.HTTP_1_0)) { + throw new IllegalArgumentException("protocols must not contain http/1.0: " + protocols); + } + if (protocols.contains(null)) { + throw new IllegalArgumentException("protocols must not contain null"); + } + this.protocols = Util.immutableList(protocols); + return this; + } + + public Builder connectionSpecs(List connectionSpecs) { + this.connectionSpecs = Util.immutableList(connectionSpecs); + return this; + } + + /** + * Returns a modifiable list of interceptors that observe the full span of each call: from + * before the connection is established (if any) until after the response source is selected + * (either the origin server, cache, or both). + */ + public List interceptors() { + return interceptors; + } + + public Builder addInterceptor(Interceptor interceptor) { + interceptors.add(interceptor); + return this; + } + + /** + * Returns a modifiable list of interceptors that observe a single network request and response. + * These interceptors must call {@link Interceptor.Chain#proceed} exactly once: it is an error + * for a network interceptor to short-circuit or repeat a network request. + */ + public List networkInterceptors() { + return networkInterceptors; + } + + public Builder addNetworkInterceptor(Interceptor interceptor) { + networkInterceptors.add(interceptor); + return this; + } + + public OkHttpClient build() { + return new OkHttpClient(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Protocol.java b/okhttp3/src/main/java/okhttp3/Protocol.java new file mode 100644 index 000000000..eec1c1219 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Protocol.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; + +/** + * Protocols that OkHttp implements for ALPN selection. + * + *

Protocol vs Scheme

Despite its name, {@link java.net.URL#getProtocol()} returns the + * {@linkplain java.net.URI#getScheme() scheme} (http, https, etc.) of the URL, not the protocol + * (http/1.1, spdy/3.1, etc.). OkHttp uses the word protocol to identify how HTTP messages + * are framed. + */ +public enum Protocol { + /** + * An obsolete plaintext framing that does not use persistent sockets by default. + */ + HTTP_1_0("http/1.0"), + + /** + * A plaintext framing that includes persistent connections. + * + *

This version of OkHttp implements RFC + * 2616, and tracks revisions to that spec. + */ + HTTP_1_1("http/1.1"), + + /** + * Chromium's binary-framed protocol that includes header compression, multiplexing multiple + * requests on the same socket, and server-push. HTTP/1.1 semantics are layered on SPDY/3. + * + *

This version of OkHttp implements SPDY 3 draft 3.1. Future + * releases of OkHttp may use this identifier for a newer draft of the SPDY spec. + */ + SPDY_3("spdy/3.1"), + + /** + * The IETF's binary-framed protocol that includes header compression, multiplexing multiple + * requests on the same socket, and server-push. HTTP/1.1 semantics are layered on HTTP/2. + * + *

HTTP/2 requires deployments of HTTP/2 that use TLS 1.2 support {@linkplain + * CipherSuite#TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256} , present in Java 8+ and Android 5+. Servers + * that enforce this may send an exception message including the string {@code + * INADEQUATE_SECURITY}. + */ + HTTP_2("h2"); + + private final String protocol; + + Protocol(String protocol) { + this.protocol = protocol; + } + + /** + * Returns the protocol identified by {@code protocol}. + * + * @throws IOException if {@code protocol} is unknown. + */ + public static Protocol get(String protocol) throws IOException { + // Unroll the loop over values() to save an allocation. + if (protocol.equals(HTTP_1_0.protocol)) return HTTP_1_0; + if (protocol.equals(HTTP_1_1.protocol)) return HTTP_1_1; + if (protocol.equals(HTTP_2.protocol)) return HTTP_2; + if (protocol.equals(SPDY_3.protocol)) return SPDY_3; + throw new IOException("Unexpected protocol: " + protocol); + } + + /** + * Returns the string used to identify this protocol for ALPN, like "http/1.1", "spdy/3.1" or + * "h2". + */ + @Override public String toString() { + return protocol; + } +} diff --git a/okhttp3/src/main/java/okhttp3/RealCall.java b/okhttp3/src/main/java/okhttp3/RealCall.java new file mode 100644 index 000000000..16cb67cdc --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/RealCall.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; +import java.net.ProtocolException; +import java.util.logging.Level; +import okhttp3.internal.NamedRunnable; +import okhttp3.internal.http.HttpEngine; +import okhttp3.internal.http.RequestException; +import okhttp3.internal.http.RouteException; +import okhttp3.internal.http.StreamAllocation; + +import static okhttp3.internal.Internal.logger; +import static okhttp3.internal.http.HttpEngine.MAX_FOLLOW_UPS; + +final class RealCall implements Call { + private final OkHttpClient client; + + // Guarded by this. + private boolean executed; + volatile boolean canceled; + + /** The application's original request unadulterated by redirects or auth headers. */ + Request originalRequest; + HttpEngine engine; + + protected RealCall(OkHttpClient client, Request originalRequest) { + this.client = client; + this.originalRequest = originalRequest; + } + + @Override public Request request() { + return originalRequest; + } + + @Override public Response execute() throws IOException { + synchronized (this) { + if (executed) throw new IllegalStateException("Already Executed"); + executed = true; + } + try { + client.dispatcher().executed(this); + Response result = getResponseWithInterceptorChain(false); + if (result == null) throw new IOException("Canceled"); + return result; + } finally { + client.dispatcher().finished(this); + } + } + + Object tag() { + return originalRequest.tag(); + } + + @Override public void enqueue(Callback responseCallback) { + enqueue(responseCallback, false); + } + + void enqueue(Callback responseCallback, boolean forWebSocket) { + synchronized (this) { + if (executed) throw new IllegalStateException("Already Executed"); + executed = true; + } + client.dispatcher().enqueue(new AsyncCall(responseCallback, forWebSocket)); + } + + @Override public void cancel() { + canceled = true; + if (engine != null) engine.cancel(); + } + + @Override public synchronized boolean isExecuted() { + return executed; + } + + @Override public boolean isCanceled() { + return canceled; + } + + final class AsyncCall extends NamedRunnable { + private final Callback responseCallback; + private final boolean forWebSocket; + + private AsyncCall(Callback responseCallback, boolean forWebSocket) { + super("OkHttp %s", originalRequest.url().toString()); + this.responseCallback = responseCallback; + this.forWebSocket = forWebSocket; + } + + String host() { + return originalRequest.url().host(); + } + + Request request() { + return originalRequest; + } + + Object tag() { + return originalRequest.tag(); + } + + void cancel() { + RealCall.this.cancel(); + } + + RealCall get() { + return RealCall.this; + } + + @Override protected void execute() { + boolean signalledCallback = false; + try { + Response response = getResponseWithInterceptorChain(forWebSocket); + if (canceled) { + signalledCallback = true; + responseCallback.onFailure(RealCall.this, new IOException("Canceled")); + } else { + signalledCallback = true; + responseCallback.onResponse(RealCall.this, response); + } + } catch (IOException e) { + if (signalledCallback) { + // Do not signal the callback twice! + logger.log(Level.INFO, "Callback failure for " + toLoggableString(), e); + } else { + responseCallback.onFailure(RealCall.this, e); + } + } finally { + client.dispatcher().finished(this); + } + } + } + + /** + * Returns a string that describes this call. Doesn't include a full URL as that might contain + * sensitive information. + */ + private String toLoggableString() { + String string = canceled ? "canceled call" : "call"; + HttpUrl redactedUrl = originalRequest.url().resolve("/..."); + return string + " to " + redactedUrl; + } + + private Response getResponseWithInterceptorChain(boolean forWebSocket) throws IOException { + Interceptor.Chain chain = new ApplicationInterceptorChain(0, originalRequest, forWebSocket); + return chain.proceed(originalRequest); + } + + class ApplicationInterceptorChain implements Interceptor.Chain { + private final int index; + private final Request request; + private final boolean forWebSocket; + + ApplicationInterceptorChain(int index, Request request, boolean forWebSocket) { + this.index = index; + this.request = request; + this.forWebSocket = forWebSocket; + } + + @Override public Connection connection() { + return null; + } + + @Override public Request request() { + return request; + } + + @Override public Response proceed(Request request) throws IOException { + // If there's another interceptor in the chain, call that. + if (index < client.interceptors().size()) { + Interceptor.Chain chain = new ApplicationInterceptorChain(index + 1, request, forWebSocket); + Interceptor interceptor = client.interceptors().get(index); + Response interceptedResponse = interceptor.intercept(chain); + + if (interceptedResponse == null) { + throw new NullPointerException("application interceptor " + interceptor + + " returned null"); + } + + return interceptedResponse; + } + + // No more interceptors. Do HTTP. + return getResponse(request, forWebSocket); + } + } + + /** + * Performs the request and returns the response. May return null if this call was canceled. + */ + Response getResponse(Request request, boolean forWebSocket) throws IOException { + // Copy body metadata to the appropriate request headers. + RequestBody body = request.body(); + if (body != null) { + Request.Builder requestBuilder = request.newBuilder(); + + MediaType contentType = body.contentType(); + if (contentType != null) { + requestBuilder.header("Content-Type", contentType.toString()); + } + + long contentLength = body.contentLength(); + if (contentLength != -1) { + requestBuilder.header("Content-Length", Long.toString(contentLength)); + requestBuilder.removeHeader("Transfer-Encoding"); + } else { + requestBuilder.header("Transfer-Encoding", "chunked"); + requestBuilder.removeHeader("Content-Length"); + } + + request = requestBuilder.build(); + } + + // Create the initial HTTP engine. Retries and redirects need new engine for each attempt. + engine = new HttpEngine(client, request, false, false, forWebSocket, null, null, null); + + int followUpCount = 0; + while (true) { + if (canceled) { + engine.releaseStreamAllocation(); + throw new IOException("Canceled"); + } + + boolean releaseConnection = true; + try { + engine.sendRequest(); + engine.readResponse(); + releaseConnection = false; + } catch (RequestException e) { + // The attempt to interpret the request failed. Give up. + throw e.getCause(); + } catch (RouteException e) { + // The attempt to connect via a route failed. The request will not have been sent. + HttpEngine retryEngine = engine.recover(e.getLastConnectException(), null); + if (retryEngine != null) { + releaseConnection = false; + engine = retryEngine; + continue; + } + // Give up; recovery is not possible. + throw e.getLastConnectException(); + } catch (IOException e) { + // An attempt to communicate with a server failed. The request may have been sent. + HttpEngine retryEngine = engine.recover(e, null); + if (retryEngine != null) { + releaseConnection = false; + engine = retryEngine; + continue; + } + + // Give up; recovery is not possible. + throw e; + } finally { + // We're throwing an unchecked exception. Release any resources. + if (releaseConnection) { + StreamAllocation streamAllocation = engine.close(); + streamAllocation.release(); + } + } + + Response response = engine.getResponse(); + Request followUp = engine.followUpRequest(); + + if (followUp == null) { + if (!forWebSocket) { + engine.releaseStreamAllocation(); + } + return response; + } + + StreamAllocation streamAllocation = engine.close(); + + if (++followUpCount > MAX_FOLLOW_UPS) { + streamAllocation.release(); + throw new ProtocolException("Too many follow-up requests: " + followUpCount); + } + + if (!engine.sameConnection(followUp.url())) { + streamAllocation.release(); + streamAllocation = null; + } + + request = followUp; + engine = new HttpEngine(client, request, false, false, forWebSocket, streamAllocation, null, + response); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/Request.java b/okhttp3/src/main/java/okhttp3/Request.java new file mode 100644 index 000000000..2daa0ab9b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Request.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.URI; +import java.net.URL; +import java.util.List; +import okhttp3.internal.http.HttpMethod; + +/** + * An HTTP request. Instances of this class are immutable if their {@link #body} is null or itself + * immutable. + */ +public final class Request { + private final HttpUrl url; + private final String method; + private final Headers headers; + private final RequestBody body; + private final Object tag; + + private volatile URI javaNetUri; // Lazily initialized. + private volatile CacheControl cacheControl; // Lazily initialized. + + private Request(Builder builder) { + this.url = builder.url; + this.method = builder.method; + this.headers = builder.headers.build(); + this.body = builder.body; + this.tag = builder.tag != null ? builder.tag : this; + } + + public HttpUrl url() { + return url; + } + + public String method() { + return method; + } + + public Headers headers() { + return headers; + } + + public String header(String name) { + return headers.get(name); + } + + public List headers(String name) { + return headers.values(name); + } + + public RequestBody body() { + return body; + } + + public Object tag() { + return tag; + } + + public Builder newBuilder() { + return new Builder(this); + } + + /** + * Returns the cache control directives for this response. This is never null, even if this + * response contains no {@code Cache-Control} header. + */ + public CacheControl cacheControl() { + CacheControl result = cacheControl; + return result != null ? result : (cacheControl = CacheControl.parse(headers)); + } + + public boolean isHttps() { + return url.isHttps(); + } + + @Override public String toString() { + return "Request{method=" + + method + + ", url=" + + url + + ", tag=" + + (tag != this ? tag : null) + + '}'; + } + + public static class Builder { + private HttpUrl url; + private String method; + private Headers.Builder headers; + private RequestBody body; + private Object tag; + + public Builder() { + this.method = "GET"; + this.headers = new Headers.Builder(); + } + + private Builder(Request request) { + this.url = request.url; + this.method = request.method; + this.body = request.body; + this.tag = request.tag; + this.headers = request.headers.newBuilder(); + } + + public Builder url(HttpUrl url) { + if (url == null) throw new IllegalArgumentException("url == null"); + this.url = url; + return this; + } + + /** + * Sets the URL target of this request. + * + * @throws IllegalArgumentException if {@code url} is not a valid HTTP or HTTPS URL. Avoid this + * exception by calling {@link HttpUrl#parse}; it returns null for invalid URLs. + */ + public Builder url(String url) { + if (url == null) throw new IllegalArgumentException("url == null"); + + // Silently replace websocket URLs with HTTP URLs. + if (url.regionMatches(true, 0, "ws:", 0, 3)) { + url = "http:" + url.substring(3); + } else if (url.regionMatches(true, 0, "wss:", 0, 4)) { + url = "https:" + url.substring(4); + } + + HttpUrl parsed = HttpUrl.parse(url); + if (parsed == null) throw new IllegalArgumentException("unexpected url: " + url); + return url(parsed); + } + + /** + * Sets the URL target of this request. + * + * @throws IllegalArgumentException if the scheme of {@code url} is not {@code http} or {@code + * https}. + */ + public Builder url(URL url) { + if (url == null) throw new IllegalArgumentException("url == null"); + HttpUrl parsed = HttpUrl.get(url); + if (parsed == null) throw new IllegalArgumentException("unexpected url: " + url); + return url(parsed); + } + + /** + * Sets the header named {@code name} to {@code value}. If this request already has any headers + * with that name, they are all replaced. + */ + public Builder header(String name, String value) { + headers.set(name, value); + return this; + } + + /** + * Adds a header with {@code name} and {@code value}. Prefer this method for multiply-valued + * headers like "Cookie". + * + *

Note that for some headers including {@code Content-Length} and {@code Content-Encoding}, + * OkHttp may replace {@code value} with a header derived from the request body. + */ + public Builder addHeader(String name, String value) { + headers.add(name, value); + return this; + } + + public Builder removeHeader(String name) { + headers.removeAll(name); + return this; + } + + /** Removes all headers on this builder and adds {@code headers}. */ + public Builder headers(Headers headers) { + this.headers = headers.newBuilder(); + return this; + } + + /** + * Sets this request's {@code Cache-Control} header, replacing any cache control headers already + * present. If {@code cacheControl} doesn't define any directives, this clears this request's + * cache-control headers. + */ + public Builder cacheControl(CacheControl cacheControl) { + String value = cacheControl.toString(); + if (value.isEmpty()) return removeHeader("Cache-Control"); + return header("Cache-Control", value); + } + + public Builder get() { + return method("GET", null); + } + + public Builder head() { + return method("HEAD", null); + } + + public Builder post(RequestBody body) { + return method("POST", body); + } + + public Builder delete(RequestBody body) { + return method("DELETE", body); + } + + public Builder delete() { + return delete(RequestBody.create(null, new byte[0])); + } + + public Builder put(RequestBody body) { + return method("PUT", body); + } + + public Builder patch(RequestBody body) { + return method("PATCH", body); + } + + public Builder method(String method, RequestBody body) { + if (method == null || method.length() == 0) { + throw new IllegalArgumentException("method == null || method.length() == 0"); + } + if (body != null && !HttpMethod.permitsRequestBody(method)) { + throw new IllegalArgumentException("method " + method + " must not have a request body."); + } + if (body == null && HttpMethod.requiresRequestBody(method)) { + throw new IllegalArgumentException("method " + method + " must have a request body."); + } + this.method = method; + this.body = body; + return this; + } + + /** + * Attaches {@code tag} to the request. It can be used later to cancel the request. If the tag + * is unspecified or null, the request is canceled by using the request itself as the tag. + */ + public Builder tag(Object tag) { + this.tag = tag; + return this; + } + + public Request build() { + if (url == null) throw new IllegalStateException("url == null"); + return new Request(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/RequestBody.java b/okhttp3/src/main/java/okhttp3/RequestBody.java new file mode 100644 index 000000000..136cfdc4e --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/RequestBody.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import okhttp3.internal.Util; +import okio.BufferedSink; +import okio.ByteString; +import okio.Okio; +import okio.Source; + +public abstract class RequestBody { + /** Returns the Content-Type header for this body. */ + public abstract MediaType contentType(); + + /** + * Returns the number of bytes that will be written to {@code out} in a call to {@link #writeTo}, + * or -1 if that count is unknown. + */ + public long contentLength() throws IOException { + return -1; + } + + /** Writes the content of this request to {@code out}. */ + public abstract void writeTo(BufferedSink sink) throws IOException; + + /** + * Returns a new request body that transmits {@code content}. If {@code contentType} is non-null + * and lacks a charset, this will use UTF-8. + */ + public static RequestBody create(MediaType contentType, String content) { + Charset charset = Util.UTF_8; + if (contentType != null) { + charset = contentType.charset(); + if (charset == null) { + charset = Util.UTF_8; + contentType = MediaType.parse(contentType + "; charset=utf-8"); + } + } + byte[] bytes = content.getBytes(charset); + return create(contentType, bytes); + } + + /** Returns a new request body that transmits {@code content}. */ + public static RequestBody create(final MediaType contentType, final ByteString content) { + return new RequestBody() { + @Override public MediaType contentType() { + return contentType; + } + + @Override public long contentLength() throws IOException { + return content.size(); + } + + @Override public void writeTo(BufferedSink sink) throws IOException { + sink.write(content); + } + }; + } + + /** Returns a new request body that transmits {@code content}. */ + public static RequestBody create(final MediaType contentType, final byte[] content) { + return create(contentType, content, 0, content.length); + } + + /** Returns a new request body that transmits {@code content}. */ + public static RequestBody create(final MediaType contentType, final byte[] content, + final int offset, final int byteCount) { + if (content == null) throw new NullPointerException("content == null"); + Util.checkOffsetAndCount(content.length, offset, byteCount); + return new RequestBody() { + @Override public MediaType contentType() { + return contentType; + } + + @Override public long contentLength() { + return byteCount; + } + + @Override public void writeTo(BufferedSink sink) throws IOException { + sink.write(content, offset, byteCount); + } + }; + } + + /** Returns a new request body that transmits the content of {@code file}. */ + public static RequestBody create(final MediaType contentType, final File file) { + if (file == null) throw new NullPointerException("content == null"); + + return new RequestBody() { + @Override public MediaType contentType() { + return contentType; + } + + @Override public long contentLength() { + return file.length(); + } + + @Override public void writeTo(BufferedSink sink) throws IOException { + Source source = null; + try { + source = Okio.source(file); + sink.writeAll(source); + } finally { + Util.closeQuietly(source); + } + } + }; + } +} diff --git a/okhttp3/src/main/java/okhttp3/Response.java b/okhttp3/src/main/java/okhttp3/Response.java new file mode 100644 index 000000000..7a2d717e2 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Response.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import okhttp3.internal.http.OkHeaders; +import okio.Buffer; +import okio.BufferedSource; + +import static java.net.HttpURLConnection.HTTP_MOVED_PERM; +import static java.net.HttpURLConnection.HTTP_MOVED_TEMP; +import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; +import static java.net.HttpURLConnection.HTTP_PROXY_AUTH; +import static java.net.HttpURLConnection.HTTP_SEE_OTHER; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static okhttp3.internal.http.StatusLine.HTTP_PERM_REDIRECT; +import static okhttp3.internal.http.StatusLine.HTTP_TEMP_REDIRECT; + +/** + * An HTTP response. Instances of this class are not immutable: the response body is a one-shot + * value that may be consumed only once. All other properties are immutable. + */ +public final class Response { + private final Request request; + private final Protocol protocol; + private final int code; + private final String message; + private final Handshake handshake; + private final Headers headers; + private final ResponseBody body; + private Response networkResponse; + private Response cacheResponse; + private final Response priorResponse; + + private volatile CacheControl cacheControl; // Lazily initialized. + + private Response(Builder builder) { + this.request = builder.request; + this.protocol = builder.protocol; + this.code = builder.code; + this.message = builder.message; + this.handshake = builder.handshake; + this.headers = builder.headers.build(); + this.body = builder.body; + this.networkResponse = builder.networkResponse; + this.cacheResponse = builder.cacheResponse; + this.priorResponse = builder.priorResponse; + } + + /** + * The wire-level request that initiated this HTTP response. This is not + * necessarily the same request issued by the application: + * + *

    + *
  • It may be transformed by the HTTP client. For example, the client + * may copy headers like {@code Content-Length} from the request body. + *
  • It may be the request generated in response to an HTTP redirect or + * authentication challenge. In this case the request URL may be + * different than the initial request URL. + *
+ */ + public Request request() { + return request; + } + + /** + * Returns the HTTP protocol, such as {@link Protocol#HTTP_1_1} or {@link Protocol#HTTP_1_0}. + */ + public Protocol protocol() { + return protocol; + } + + /** Returns the HTTP status code. */ + public int code() { + return code; + } + + /** + * Returns true if the code is in [200..300), which means the request was successfully received, + * understood, and accepted. + */ + public boolean isSuccessful() { + return code >= 200 && code < 300; + } + + /** Returns the HTTP status message or null if it is unknown. */ + public String message() { + return message; + } + + /** + * Returns the TLS handshake of the connection that carried this response, or null if the response + * was received without TLS. + */ + public Handshake handshake() { + return handshake; + } + + public List headers(String name) { + return headers.values(name); + } + + public String header(String name) { + return header(name, null); + } + + public String header(String name, String defaultValue) { + String result = headers.get(name); + return result != null ? result : defaultValue; + } + + public Headers headers() { + return headers; + } + + /** + * Peeks up to {@code byteCount} bytes from the response body and returns them as a new response + * body. If fewer than {@code byteCount} bytes are in the response body, the full response body is + * returned. If more than {@code byteCount} bytes are in the response body, the returned value + * will be truncated to {@code byteCount} bytes. + * + *

It is an error to call this method after the body has been consumed. + * + *

Warning: this method loads the requested bytes into memory. Most + * applications should set a modest limit on {@code byteCount}, such as 1 MiB. + */ + public ResponseBody peekBody(long byteCount) throws IOException { + BufferedSource source = body.source(); + source.request(byteCount); + Buffer copy = source.buffer().clone(); + + // There may be more than byteCount bytes in source.buffer(). If there is, return a prefix. + Buffer result; + if (copy.size() > byteCount) { + result = new Buffer(); + result.write(copy, byteCount); + copy.clear(); + } else { + result = copy; + } + + return ResponseBody.create(body.contentType(), result.size(), result); + } + + public ResponseBody body() { + return body; + } + + public Builder newBuilder() { + return new Builder(this); + } + + /** Returns true if this response redirects to another resource. */ + public boolean isRedirect() { + switch (code) { + case HTTP_PERM_REDIRECT: + case HTTP_TEMP_REDIRECT: + case HTTP_MULT_CHOICE: + case HTTP_MOVED_PERM: + case HTTP_MOVED_TEMP: + case HTTP_SEE_OTHER: + return true; + default: + return false; + } + } + + /** + * Returns the raw response received from the network. Will be null if this response didn't use + * the network, such as when the response is fully cached. The body of the returned response + * should not be read. + */ + public Response networkResponse() { + return networkResponse; + } + + /** + * Returns the raw response received from the cache. Will be null if this response didn't use the + * cache. For conditional get requests the cache response and network response may both be + * non-null. The body of the returned response should not be read. + */ + public Response cacheResponse() { + return cacheResponse; + } + + /** + * Returns the response for the HTTP redirect or authorization challenge that triggered this + * response, or null if this response wasn't triggered by an automatic retry. The body of the + * returned response should not be read because it has already been consumed by the redirecting + * client. + */ + public Response priorResponse() { + return priorResponse; + } + + /** + * Returns the authorization challenges appropriate for this response's code. If the response code + * is 401 unauthorized, this returns the "WWW-Authenticate" challenges. If the response code is + * 407 proxy unauthorized, this returns the "Proxy-Authenticate" challenges. Otherwise this + * returns an empty list of challenges. + */ + public List challenges() { + String responseField; + if (code == HTTP_UNAUTHORIZED) { + responseField = "WWW-Authenticate"; + } else if (code == HTTP_PROXY_AUTH) { + responseField = "Proxy-Authenticate"; + } else { + return Collections.emptyList(); + } + return OkHeaders.parseChallenges(headers(), responseField); + } + + /** + * Returns the cache control directives for this response. This is never null, even if this + * response contains no {@code Cache-Control} header. + */ + public CacheControl cacheControl() { + CacheControl result = cacheControl; + return result != null ? result : (cacheControl = CacheControl.parse(headers)); + } + + @Override public String toString() { + return "Response{protocol=" + + protocol + + ", code=" + + code + + ", message=" + + message + + ", url=" + + request.url() + + '}'; + } + + public static class Builder { + private Request request; + private Protocol protocol; + private int code = -1; + private String message; + private Handshake handshake; + private Headers.Builder headers; + private ResponseBody body; + private Response networkResponse; + private Response cacheResponse; + private Response priorResponse; + + public Builder() { + headers = new Headers.Builder(); + } + + private Builder(Response response) { + this.request = response.request; + this.protocol = response.protocol; + this.code = response.code; + this.message = response.message; + this.handshake = response.handshake; + this.headers = response.headers.newBuilder(); + this.body = response.body; + this.networkResponse = response.networkResponse; + this.cacheResponse = response.cacheResponse; + this.priorResponse = response.priorResponse; + } + + public Builder request(Request request) { + this.request = request; + return this; + } + + public Builder protocol(Protocol protocol) { + this.protocol = protocol; + return this; + } + + public Builder code(int code) { + this.code = code; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder handshake(Handshake handshake) { + this.handshake = handshake; + return this; + } + + /** + * Sets the header named {@code name} to {@code value}. If this request already has any headers + * with that name, they are all replaced. + */ + public Builder header(String name, String value) { + headers.set(name, value); + return this; + } + + /** + * Adds a header with {@code name} and {@code value}. Prefer this method for multiply-valued + * headers like "Set-Cookie". + */ + public Builder addHeader(String name, String value) { + headers.add(name, value); + return this; + } + + public Builder removeHeader(String name) { + headers.removeAll(name); + return this; + } + + /** Removes all headers on this builder and adds {@code headers}. */ + public Builder headers(Headers headers) { + this.headers = headers.newBuilder(); + return this; + } + + public Builder body(ResponseBody body) { + this.body = body; + return this; + } + + public Builder networkResponse(Response networkResponse) { + if (networkResponse != null) checkSupportResponse("networkResponse", networkResponse); + this.networkResponse = networkResponse; + return this; + } + + public Builder cacheResponse(Response cacheResponse) { + if (cacheResponse != null) checkSupportResponse("cacheResponse", cacheResponse); + this.cacheResponse = cacheResponse; + return this; + } + + private void checkSupportResponse(String name, Response response) { + if (response.body != null) { + throw new IllegalArgumentException(name + ".body != null"); + } else if (response.networkResponse != null) { + throw new IllegalArgumentException(name + ".networkResponse != null"); + } else if (response.cacheResponse != null) { + throw new IllegalArgumentException(name + ".cacheResponse != null"); + } else if (response.priorResponse != null) { + throw new IllegalArgumentException(name + ".priorResponse != null"); + } + } + + public Builder priorResponse(Response priorResponse) { + if (priorResponse != null) checkPriorResponse(priorResponse); + this.priorResponse = priorResponse; + return this; + } + + private void checkPriorResponse(Response response) { + if (response.body != null) { + throw new IllegalArgumentException("priorResponse.body != null"); + } + } + + public Response build() { + if (request == null) throw new IllegalStateException("request == null"); + if (protocol == null) throw new IllegalStateException("protocol == null"); + if (code < 0) throw new IllegalStateException("code < 0: " + code); + return new Response(this); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/ResponseBody.java b/okhttp3/src/main/java/okhttp3/ResponseBody.java new file mode 100644 index 000000000..98223bbad --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/ResponseBody.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSource; + +import static okhttp3.internal.Util.UTF_8; + +/** + * A one-shot stream from the origin server to the client application with the raw bytes of the + * response body. Each response body is supported by an active connection to the webserver. This + * imposes both obligations and limits on the client application. + * + *

The response body must be closed.

+ * + *

Each response body is backed by a limited resource like a socket (live network responses) or + * an open file (for cached responses). Failing to close the response body will leak these resources + * and may ultimately cause the application to slow down or crash. Close the response body by + * calling either {@link ResponseBody#close close()}, {@link InputStream#close() + * byteStream().close()}, or {@link Reader#close() reader().close()}. The {@link #bytes()} and + * {@link #string()} methods both close the response body automatically. + * + *

The response body can be consumed only once.

+ * + *

This class may be used to stream very large responses. For example, it is possible to use this + * class to read a response that is larger than the entire memory allocated to the current process. + * It can even stream a response larger than the total storage on the current device, which is a + * common requirement for video streaming applications. + * + *

Because this class does not buffer the full response in memory, the application may not + * re-read the bytes of the response. Use this one shot to read the entire response into memory with + * {@link #bytes()} or {@link #string()}. Or stream the response with either {@link #source()}, + * {@link #byteStream()}, or {@link #charStream()}. + */ +public abstract class ResponseBody implements Closeable { + /** Multiple calls to {@link #charStream()} must return the same instance. */ + private Reader reader; + + public abstract MediaType contentType(); + + /** + * Returns the number of bytes in that will returned by {@link #bytes}, or {@link #byteStream}, or + * -1 if unknown. + */ + public abstract long contentLength(); + + public final InputStream byteStream() { + return source().inputStream(); + } + + public abstract BufferedSource source(); + + public final byte[] bytes() throws IOException { + long contentLength = contentLength(); + if (contentLength > Integer.MAX_VALUE) { + throw new IOException("Cannot buffer entire body for content length: " + contentLength); + } + + BufferedSource source = source(); + byte[] bytes; + try { + bytes = source.readByteArray(); + } finally { + Util.closeQuietly(source); + } + if (contentLength != -1 && contentLength != bytes.length) { + throw new IOException("Content-Length and stream length disagree"); + } + return bytes; + } + + /** + * Returns the response as a character stream decoded with the charset of the Content-Type header. + * If that header is either absent or lacks a charset, this will attempt to decode the response + * body as UTF-8. + */ + public final Reader charStream() { + Reader r = reader; + return r != null ? r : (reader = new InputStreamReader(byteStream(), charset())); + } + + /** + * Returns the response as a string decoded with the charset of the Content-Type header. If that + * header is either absent or lacks a charset, this will attempt to decode the response body as + * UTF-8. + */ + public final String string() throws IOException { + return new String(bytes(), charset().name()); + } + + private Charset charset() { + MediaType contentType = contentType(); + return contentType != null ? contentType.charset(UTF_8) : UTF_8; + } + + @Override public void close() { + Util.closeQuietly(source()); + } + + /** + * Returns a new response body that transmits {@code content}. If {@code contentType} is non-null + * and lacks a charset, this will use UTF-8. + */ + public static ResponseBody create(MediaType contentType, String content) { + Charset charset = UTF_8; + if (contentType != null) { + charset = contentType.charset(); + if (charset == null) { + charset = UTF_8; + contentType = MediaType.parse(contentType + "; charset=utf-8"); + } + } + Buffer buffer = new Buffer().writeString(content, charset); + return create(contentType, buffer.size(), buffer); + } + + /** Returns a new response body that transmits {@code content}. */ + public static ResponseBody create(final MediaType contentType, byte[] content) { + Buffer buffer = new Buffer().write(content); + return create(contentType, content.length, buffer); + } + + /** Returns a new response body that transmits {@code content}. */ + public static ResponseBody create( + final MediaType contentType, final long contentLength, final BufferedSource content) { + if (content == null) throw new NullPointerException("source == null"); + return new ResponseBody() { + @Override public MediaType contentType() { + return contentType; + } + + @Override public long contentLength() { + return contentLength; + } + + @Override public BufferedSource source() { + return content; + } + }; + } +} diff --git a/okhttp3/src/main/java/okhttp3/Route.java b/okhttp3/src/main/java/okhttp3/Route.java new file mode 100644 index 000000000..8aaa0f5b8 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/Route.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +/** + * The concrete route used by a connection to reach an abstract origin server. When creating a + * connection the client has many options: + * + *

    + *
  • HTTP proxy: a proxy server may be explicitly configured for the client. + * Otherwise the {@linkplain java.net.ProxySelector proxy selector} is used. It may return + * multiple proxies to attempt. + *
  • IP address: whether connecting directly to an origin server or a proxy, + * opening a socket requires an IP address. The DNS server may return multiple IP addresses + * to attempt. + *
+ * + *

Each route is a specific selection of these options. + */ +public final class Route { + final Address address; + final Proxy proxy; + final InetSocketAddress inetSocketAddress; + + public Route(Address address, Proxy proxy, InetSocketAddress inetSocketAddress) { + if (address == null) { + throw new NullPointerException("address == null"); + } + if (proxy == null) { + throw new NullPointerException("proxy == null"); + } + if (inetSocketAddress == null) { + throw new NullPointerException("inetSocketAddress == null"); + } + this.address = address; + this.proxy = proxy; + this.inetSocketAddress = inetSocketAddress; + } + + public Address address() { + return address; + } + + /** + * Returns the {@link Proxy} of this route. + * + * Warning: This may disagree with {@link Address#proxy} when it is null. When + * the address's proxy is null, the proxy selector is used. + */ + public Proxy proxy() { + return proxy; + } + + public InetSocketAddress socketAddress() { + return inetSocketAddress; + } + + /** + * Returns true if this route tunnels HTTPS through an HTTP proxy. See RFC 2817, Section 5.2. + */ + public boolean requiresTunnel() { + return address.sslSocketFactory != null && proxy.type() == Proxy.Type.HTTP; + } + + @Override public boolean equals(Object obj) { + if (obj instanceof Route) { + Route other = (Route) obj; + return address.equals(other.address) + && proxy.equals(other.proxy) + && inetSocketAddress.equals(other.inetSocketAddress); + } + return false; + } + + @Override public int hashCode() { + int result = 17; + result = 31 * result + address.hashCode(); + result = 31 * result + proxy.hashCode(); + result = 31 * result + inetSocketAddress.hashCode(); + return result; + } +} diff --git a/okhttp3/src/main/java/okhttp3/TlsVersion.java b/okhttp3/src/main/java/okhttp3/TlsVersion.java new file mode 100644 index 000000000..668cd9732 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/TlsVersion.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +/** + * Versions of TLS that can be offered when negotiating a secure socket. See {@link + * javax.net.ssl.SSLSocket#setEnabledProtocols}. + */ +public enum TlsVersion { + TLS_1_2("TLSv1.2"), // 2008. + TLS_1_1("TLSv1.1"), // 2006. + TLS_1_0("TLSv1"), // 1999. + SSL_3_0("SSLv3"), // 1996. + ; + + final String javaName; + + TlsVersion(String javaName) { + this.javaName = javaName; + } + + public static TlsVersion forJavaName(String javaName) { + switch (javaName) { + case "TLSv1.2": + return TLS_1_2; + case "TLSv1.1": + return TLS_1_1; + case "TLSv1": + return TLS_1_0; + case "SSLv3": + return SSL_3_0; + } + throw new IllegalArgumentException("Unexpected TLS version: " + javaName); + } + + public String javaName() { + return javaName; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/ConnectionSpecSelector.java b/okhttp3/src/main/java/okhttp3/internal/ConnectionSpecSelector.java new file mode 100644 index 000000000..3123eece2 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/ConnectionSpecSelector.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.ProtocolException; +import java.net.UnknownServiceException; +import java.security.cert.CertificateException; +import java.util.Arrays; +import java.util.List; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLProtocolException; +import javax.net.ssl.SSLSocket; +import okhttp3.ConnectionSpec; + +/** + * Handles the connection spec fallback strategy: When a secure socket connection fails due to a + * handshake / protocol problem the connection may be retried with different protocols. Instances + * are stateful and should be created and used for a single connection attempt. + */ +public final class ConnectionSpecSelector { + + private final List connectionSpecs; + private int nextModeIndex; + private boolean isFallbackPossible; + private boolean isFallback; + + public ConnectionSpecSelector(List connectionSpecs) { + this.nextModeIndex = 0; + this.connectionSpecs = connectionSpecs; + } + + /** + * Configures the supplied {@link SSLSocket} to connect to the specified host using an appropriate + * {@link ConnectionSpec}. Returns the chosen {@link ConnectionSpec}, never {@code null}. + * + * @throws IOException if the socket does not support any of the TLS modes available + */ + public ConnectionSpec configureSecureSocket(SSLSocket sslSocket) throws IOException { + ConnectionSpec tlsConfiguration = null; + for (int i = nextModeIndex, size = connectionSpecs.size(); i < size; i++) { + ConnectionSpec connectionSpec = connectionSpecs.get(i); + if (connectionSpec.isCompatible(sslSocket)) { + tlsConfiguration = connectionSpec; + nextModeIndex = i + 1; + break; + } + } + + if (tlsConfiguration == null) { + // This may be the first time a connection has been attempted and the socket does not support + // any the required protocols, or it may be a retry (but this socket supports fewer + // protocols than was suggested by a prior socket). + throw new UnknownServiceException( + "Unable to find acceptable protocols. isFallback=" + isFallback + + ", modes=" + connectionSpecs + + ", supported protocols=" + Arrays.toString(sslSocket.getEnabledProtocols())); + } + + isFallbackPossible = isFallbackPossible(sslSocket); + + Internal.instance.apply(tlsConfiguration, sslSocket, isFallback); + + return tlsConfiguration; + } + + /** + * Reports a failure to complete a connection. Determines the next {@link ConnectionSpec} to try, + * if any. + * + * @return {@code true} if the connection should be retried using {@link + * #configureSecureSocket(SSLSocket)} or {@code false} if not + */ + public boolean connectionFailed(IOException e) { + // Any future attempt to connect using this strategy will be a fallback attempt. + isFallback = true; + + if (!isFallbackPossible) { + return false; + } + + // If there was a protocol problem, don't recover. + if (e instanceof ProtocolException) { + return false; + } + + // If there was an interruption or timeout (SocketTimeoutException), don't recover. + // For the socket connect timeout case we do not try the same host with a different + // ConnectionSpec: we assume it is unreachable. + if (e instanceof InterruptedIOException) { + return false; + } + + // Look for known client-side or negotiation errors that are unlikely to be fixed by trying + // again with a different connection spec. + if (e instanceof SSLHandshakeException) { + // If the problem was a CertificateException from the X509TrustManager, + // do not retry. + if (e.getCause() instanceof CertificateException) { + return false; + } + } + if (e instanceof SSLPeerUnverifiedException) { + // e.g. a certificate pinning error. + return false; + } + + // On Android, SSLProtocolExceptions can be caused by TLS_FALLBACK_SCSV failures, which means we + // retry those when we probably should not. + return (e instanceof SSLHandshakeException || e instanceof SSLProtocolException); + } + + /** + * Returns {@code true} if any later {@link ConnectionSpec} in the fallback strategy looks + * possible based on the supplied {@link SSLSocket}. It assumes that a future socket will have the + * same capabilities as the supplied socket. + */ + private boolean isFallbackPossible(SSLSocket socket) { + for (int i = nextModeIndex; i < connectionSpecs.size(); i++) { + if (connectionSpecs.get(i).isCompatible(socket)) { + return true; + } + } + return false; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/DiskLruCache.java b/okhttp3/src/main/java/okhttp3/internal/DiskLruCache.java new file mode 100644 index 000000000..34f993a9a --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/DiskLruCache.java @@ -0,0 +1,1005 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.Flushable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.NoSuchElementException; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import okhttp3.internal.io.FileSystem; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.Okio; +import okio.Sink; +import okio.Source; +import okio.Timeout; + +/** + * A cache that uses a bounded amount of space on a filesystem. Each cache entry has a string key + * and a fixed number of values. Each key must match the regex [a-z0-9_-]{1,64}. + * Values are byte sequences, accessible as streams or files. Each value must be between {@code 0} + * and {@code Integer.MAX_VALUE} bytes in length. + * + *

The cache stores its data in a directory on the filesystem. This directory must be exclusive + * to the cache; the cache may delete or overwrite files from its directory. It is an error for + * multiple processes to use the same cache directory at the same time. + * + *

This cache limits the number of bytes that it will store on the filesystem. When the number of + * stored bytes exceeds the limit, the cache will remove entries in the background until the limit + * is satisfied. The limit is not strict: the cache may temporarily exceed it while waiting for + * files to be deleted. The limit does not include filesystem overhead or the cache journal so + * space-sensitive applications should set a conservative limit. + * + *

Clients call {@link #edit} to create or update the values of an entry. An entry may have only + * one editor at one time; if a value is not available to be edited then {@link #edit} will return + * null. + * + *

    + *
  • When an entry is being created it is necessary to supply a full set of + * values; the empty value should be used as a placeholder if necessary. + *
  • When an entry is being edited, it is not necessary to supply data for + * every value; values default to their previous value. + *
+ * + *

Every {@link #edit} call must be matched by a call to {@link Editor#commit} or {@link + * Editor#abort}. Committing is atomic: a read observes the full set of values as they were before + * or after the commit, but never a mix of values. + * + *

Clients call {@link #get} to read a snapshot of an entry. The read will observe the value at + * the time that {@link #get} was called. Updates and removals after the call do not impact ongoing + * reads. + * + *

This class is tolerant of some I/O errors. If files are missing from the filesystem, the + * corresponding entries will be dropped from the cache. If an error occurs while writing a cache + * value, the edit will fail silently. Callers should handle other problems by catching {@code + * IOException} and responding appropriately. + */ +public final class DiskLruCache implements Closeable, Flushable { + static final String JOURNAL_FILE = "journal"; + static final String JOURNAL_FILE_TEMP = "journal.tmp"; + static final String JOURNAL_FILE_BACKUP = "journal.bkp"; + static final String MAGIC = "libcore.io.DiskLruCache"; + static final String VERSION_1 = "1"; + static final long ANY_SEQUENCE_NUMBER = -1; + static final Pattern LEGAL_KEY_PATTERN = Pattern.compile("[a-z0-9_-]{1,120}"); + private static final String CLEAN = "CLEAN"; + private static final String DIRTY = "DIRTY"; + private static final String REMOVE = "REMOVE"; + private static final String READ = "READ"; + + /* + * This cache uses a journal file named "journal". A typical journal file + * looks like this: + * libcore.io.DiskLruCache + * 1 + * 100 + * 2 + * + * CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 + * DIRTY 335c4c6028171cfddfbaae1a9c313c52 + * CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 + * REMOVE 335c4c6028171cfddfbaae1a9c313c52 + * DIRTY 1ab96a171faeeee38496d8b330771a7a + * CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 + * READ 335c4c6028171cfddfbaae1a9c313c52 + * READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 + * + * The first five lines of the journal form its header. They are the + * constant string "libcore.io.DiskLruCache", the disk cache's version, + * the application's version, the value count, and a blank line. + * + * Each of the subsequent lines in the file is a record of the state of a + * cache entry. Each line contains space-separated values: a state, a key, + * and optional state-specific values. + * o DIRTY lines track that an entry is actively being created or updated. + * Every successful DIRTY action should be followed by a CLEAN or REMOVE + * action. DIRTY lines without a matching CLEAN or REMOVE indicate that + * temporary files may need to be deleted. + * o CLEAN lines track a cache entry that has been successfully published + * and may be read. A publish line is followed by the lengths of each of + * its values. + * o READ lines track accesses for LRU. + * o REMOVE lines track entries that have been deleted. + * + * The journal file is appended to as cache operations occur. The journal may + * occasionally be compacted by dropping redundant lines. A temporary file named + * "journal.tmp" will be used during compaction; that file should be deleted if + * it exists when the cache is opened. + */ + + private final FileSystem fileSystem; + private final File directory; + private final File journalFile; + private final File journalFileTmp; + private final File journalFileBackup; + private final int appVersion; + private long maxSize; + private final int valueCount; + private long size = 0; + private BufferedSink journalWriter; + private final LinkedHashMap lruEntries = new LinkedHashMap<>(0, 0.75f, true); + private int redundantOpCount; + private boolean hasJournalErrors; + + // Must be read and written when synchronized on 'this'. + private boolean initialized; + private boolean closed; + + /** + * To differentiate between old and current snapshots, each entry is given a sequence number each + * time an edit is committed. A snapshot is stale if its sequence number is not equal to its + * entry's sequence number. + */ + private long nextSequenceNumber = 0; + + /** Used to run 'cleanupRunnable' for journal rebuilds. */ + private final Executor executor; + private final Runnable cleanupRunnable = new Runnable() { + public void run() { + synchronized (DiskLruCache.this) { + if (!initialized | closed) { + return; // Nothing to do + } + try { + trimToSize(); + if (journalRebuildRequired()) { + rebuildJournal(); + redundantOpCount = 0; + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + }; + + DiskLruCache(FileSystem fileSystem, File directory, int appVersion, int valueCount, long maxSize, + Executor executor) { + this.fileSystem = fileSystem; + this.directory = directory; + this.appVersion = appVersion; + this.journalFile = new File(directory, JOURNAL_FILE); + this.journalFileTmp = new File(directory, JOURNAL_FILE_TEMP); + this.journalFileBackup = new File(directory, JOURNAL_FILE_BACKUP); + this.valueCount = valueCount; + this.maxSize = maxSize; + this.executor = executor; + } + + public synchronized void initialize() throws IOException { + assert Thread.holdsLock(this); + + if (initialized) { + return; // Already initialized. + } + + // If a bkp file exists, use it instead. + if (fileSystem.exists(journalFileBackup)) { + // If journal file also exists just delete backup file. + if (fileSystem.exists(journalFile)) { + fileSystem.delete(journalFileBackup); + } else { + fileSystem.rename(journalFileBackup, journalFile); + } + } + + // Prefer to pick up where we left off. + if (fileSystem.exists(journalFile)) { + try { + readJournal(); + processJournal(); + initialized = true; + return; + } catch (IOException journalIsCorrupt) { + Platform.get().logW("DiskLruCache " + directory + " is corrupt: " + + journalIsCorrupt.getMessage() + ", removing"); + delete(); + closed = false; + } + } + + rebuildJournal(); + + initialized = true; + } + + /** + * Create a cache which will reside in {@code directory}. This cache is lazily initialized on + * first access and will be created if it does not exist. + * + * @param directory a writable directory + * @param valueCount the number of values per cache entry. Must be positive. + * @param maxSize the maximum number of bytes this cache should use to store + */ + public static DiskLruCache create(FileSystem fileSystem, File directory, int appVersion, + int valueCount, long maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + if (valueCount <= 0) { + throw new IllegalArgumentException("valueCount <= 0"); + } + + // Use a single background thread to evict entries. + Executor executor = new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue(), Util.threadFactory("OkHttp DiskLruCache", true)); + + return new DiskLruCache(fileSystem, directory, appVersion, valueCount, maxSize, executor); + } + + private void readJournal() throws IOException { + BufferedSource source = Okio.buffer(fileSystem.source(journalFile)); + try { + String magic = source.readUtf8LineStrict(); + String version = source.readUtf8LineStrict(); + String appVersionString = source.readUtf8LineStrict(); + String valueCountString = source.readUtf8LineStrict(); + String blank = source.readUtf8LineStrict(); + if (!MAGIC.equals(magic) + || !VERSION_1.equals(version) + || !Integer.toString(appVersion).equals(appVersionString) + || !Integer.toString(valueCount).equals(valueCountString) + || !"".equals(blank)) { + throw new IOException("unexpected journal header: [" + magic + ", " + version + ", " + + valueCountString + ", " + blank + "]"); + } + + int lineCount = 0; + while (true) { + try { + readJournalLine(source.readUtf8LineStrict()); + lineCount++; + } catch (EOFException endOfJournal) { + break; + } + } + redundantOpCount = lineCount - lruEntries.size(); + + // If we ended on a truncated line, rebuild the journal before appending to it. + if (!source.exhausted()) { + rebuildJournal(); + } else { + journalWriter = newJournalWriter(); + } + } finally { + Util.closeQuietly(source); + } + } + + private BufferedSink newJournalWriter() throws FileNotFoundException { + Sink fileSink = fileSystem.appendingSink(journalFile); + Sink faultHidingSink = new FaultHidingSink(fileSink) { + @Override protected void onException(IOException e) { + assert (Thread.holdsLock(DiskLruCache.this)); + hasJournalErrors = true; + } + }; + return Okio.buffer(faultHidingSink); + } + + private void readJournalLine(String line) throws IOException { + int firstSpace = line.indexOf(' '); + if (firstSpace == -1) { + throw new IOException("unexpected journal line: " + line); + } + + int keyBegin = firstSpace + 1; + int secondSpace = line.indexOf(' ', keyBegin); + final String key; + if (secondSpace == -1) { + key = line.substring(keyBegin); + if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) { + lruEntries.remove(key); + return; + } + } else { + key = line.substring(keyBegin, secondSpace); + } + + Entry entry = lruEntries.get(key); + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } + + if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) { + String[] parts = line.substring(secondSpace + 1).split(" "); + entry.readable = true; + entry.currentEditor = null; + entry.setLengths(parts); + } else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) { + entry.currentEditor = new Editor(entry); + } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) { + // This work was already done by calling lruEntries.get(). + } else { + throw new IOException("unexpected journal line: " + line); + } + } + + /** + * Computes the initial size and collects garbage as a part of opening the cache. Dirty entries + * are assumed to be inconsistent and will be deleted. + */ + private void processJournal() throws IOException { + fileSystem.delete(journalFileTmp); + for (Iterator i = lruEntries.values().iterator(); i.hasNext(); ) { + Entry entry = i.next(); + if (entry.currentEditor == null) { + for (int t = 0; t < valueCount; t++) { + size += entry.lengths[t]; + } + } else { + entry.currentEditor = null; + for (int t = 0; t < valueCount; t++) { + fileSystem.delete(entry.cleanFiles[t]); + fileSystem.delete(entry.dirtyFiles[t]); + } + i.remove(); + } + } + } + + /** + * Creates a new journal that omits redundant information. This replaces the current journal if it + * exists. + */ + private synchronized void rebuildJournal() throws IOException { + if (journalWriter != null) { + journalWriter.close(); + } + + BufferedSink writer = Okio.buffer(fileSystem.sink(journalFileTmp)); + try { + writer.writeUtf8(MAGIC).writeByte('\n'); + writer.writeUtf8(VERSION_1).writeByte('\n'); + writer.writeDecimalLong(appVersion).writeByte('\n'); + writer.writeDecimalLong(valueCount).writeByte('\n'); + writer.writeByte('\n'); + + for (Entry entry : lruEntries.values()) { + if (entry.currentEditor != null) { + writer.writeUtf8(DIRTY).writeByte(' '); + writer.writeUtf8(entry.key); + writer.writeByte('\n'); + } else { + writer.writeUtf8(CLEAN).writeByte(' '); + writer.writeUtf8(entry.key); + entry.writeLengths(writer); + writer.writeByte('\n'); + } + } + } finally { + writer.close(); + } + + if (fileSystem.exists(journalFile)) { + fileSystem.rename(journalFile, journalFileBackup); + } + fileSystem.rename(journalFileTmp, journalFile); + fileSystem.delete(journalFileBackup); + + journalWriter = newJournalWriter(); + hasJournalErrors = false; + } + + /** + * Returns a snapshot of the entry named {@code key}, or null if it doesn't exist is not currently + * readable. If a value is returned, it is moved to the head of the LRU queue. + */ + public synchronized Snapshot get(String key) throws IOException { + initialize(); + + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null || !entry.readable) return null; + + Snapshot snapshot = entry.snapshot(); + if (snapshot == null) return null; + + redundantOpCount++; + journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n'); + if (journalRebuildRequired()) { + executor.execute(cleanupRunnable); + } + + return snapshot; + } + + /** + * Returns an editor for the entry named {@code key}, or null if another edit is in progress. + */ + public Editor edit(String key) throws IOException { + return edit(key, ANY_SEQUENCE_NUMBER); + } + + private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { + initialize(); + + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null + || entry.sequenceNumber != expectedSequenceNumber)) { + return null; // Snapshot is stale. + } + if (entry != null && entry.currentEditor != null) { + return null; // Another edit is in progress. + } + + // Flush the journal before creating files to prevent file leaks. + journalWriter.writeUtf8(DIRTY).writeByte(' ').writeUtf8(key).writeByte('\n'); + journalWriter.flush(); + + if (hasJournalErrors) { + return null; // Don't edit; the journal can't be written. + } + + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } + Editor editor = new Editor(entry); + entry.currentEditor = editor; + return editor; + } + + /** Returns the directory where this cache stores its data. */ + public File getDirectory() { + return directory; + } + + /** + * Returns the maximum number of bytes that this cache should use to store its data. + */ + public synchronized long getMaxSize() { + return maxSize; + } + + /** + * Changes the maximum number of bytes the cache can store and queues a job to trim the existing + * store, if necessary. + */ + public synchronized void setMaxSize(long maxSize) { + this.maxSize = maxSize; + if (initialized) { + executor.execute(cleanupRunnable); + } + } + + /** + * Returns the number of bytes currently being used to store the values in this cache. This may be + * greater than the max size if a background deletion is pending. + */ + public synchronized long size() throws IOException { + initialize(); + return size; + } + + private synchronized void completeEdit(Editor editor, boolean success) throws IOException { + Entry entry = editor.entry; + if (entry.currentEditor != editor) { + throw new IllegalStateException(); + } + + // If this edit is creating the entry for the first time, every index must have a value. + if (success && !entry.readable) { + for (int i = 0; i < valueCount; i++) { + if (!editor.written[i]) { + editor.abort(); + throw new IllegalStateException("Newly created entry didn't create value for index " + i); + } + if (!fileSystem.exists(entry.dirtyFiles[i])) { + editor.abort(); + return; + } + } + } + + for (int i = 0; i < valueCount; i++) { + File dirty = entry.dirtyFiles[i]; + if (success) { + if (fileSystem.exists(dirty)) { + File clean = entry.cleanFiles[i]; + fileSystem.rename(dirty, clean); + long oldLength = entry.lengths[i]; + long newLength = fileSystem.size(clean); + entry.lengths[i] = newLength; + size = size - oldLength + newLength; + } + } else { + fileSystem.delete(dirty); + } + } + + redundantOpCount++; + entry.currentEditor = null; + if (entry.readable | success) { + entry.readable = true; + journalWriter.writeUtf8(CLEAN).writeByte(' '); + journalWriter.writeUtf8(entry.key); + entry.writeLengths(journalWriter); + journalWriter.writeByte('\n'); + if (success) { + entry.sequenceNumber = nextSequenceNumber++; + } + } else { + lruEntries.remove(entry.key); + journalWriter.writeUtf8(REMOVE).writeByte(' '); + journalWriter.writeUtf8(entry.key); + journalWriter.writeByte('\n'); + } + journalWriter.flush(); + + if (size > maxSize || journalRebuildRequired()) { + executor.execute(cleanupRunnable); + } + } + + /** + * We only rebuild the journal when it will halve the size of the journal and eliminate at least + * 2000 ops. + */ + private boolean journalRebuildRequired() { + final int redundantOpCompactThreshold = 2000; + return redundantOpCount >= redundantOpCompactThreshold + && redundantOpCount >= lruEntries.size(); + } + + /** + * Drops the entry for {@code key} if it exists and can be removed. If the entry for {@code key} + * is currently being edited, that edit will complete normally but its value will not be stored. + * + * @return true if an entry was removed. + */ + public synchronized boolean remove(String key) throws IOException { + initialize(); + + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null) return false; + return removeEntry(entry); + } + + private boolean removeEntry(Entry entry) throws IOException { + if (entry.currentEditor != null) { + entry.currentEditor.hasErrors = true; // Prevent the edit from completing normally. + } + + for (int i = 0; i < valueCount; i++) { + fileSystem.delete(entry.cleanFiles[i]); + size -= entry.lengths[i]; + entry.lengths[i] = 0; + } + + redundantOpCount++; + journalWriter.writeUtf8(REMOVE).writeByte(' ').writeUtf8(entry.key).writeByte('\n'); + lruEntries.remove(entry.key); + + if (journalRebuildRequired()) { + executor.execute(cleanupRunnable); + } + + return true; + } + + /** Returns true if this cache has been closed. */ + public synchronized boolean isClosed() { + return closed; + } + + private synchronized void checkNotClosed() { + if (isClosed()) { + throw new IllegalStateException("cache is closed"); + } + } + + /** Force buffered operations to the filesystem. */ + @Override public synchronized void flush() throws IOException { + if (!initialized) return; + + checkNotClosed(); + trimToSize(); + journalWriter.flush(); + } + + /** Closes this cache. Stored values will remain on the filesystem. */ + @Override public synchronized void close() throws IOException { + if (!initialized || closed) { + closed = true; + return; + } + // Copying for safe iteration. + for (Entry entry : lruEntries.values().toArray(new Entry[lruEntries.size()])) { + if (entry.currentEditor != null) { + entry.currentEditor.abort(); + } + } + trimToSize(); + journalWriter.close(); + journalWriter = null; + closed = true; + } + + private void trimToSize() throws IOException { + while (size > maxSize) { + Entry toEvict = lruEntries.values().iterator().next(); + removeEntry(toEvict); + } + } + + /** + * Closes the cache and deletes all of its stored values. This will delete all files in the cache + * directory including files that weren't created by the cache. + */ + public void delete() throws IOException { + close(); + fileSystem.deleteContents(directory); + } + + /** + * Deletes all stored values from the cache. In-flight edits will complete normally but their + * values will not be stored. + */ + public synchronized void evictAll() throws IOException { + initialize(); + // Copying for safe iteration. + for (Entry entry : lruEntries.values().toArray(new Entry[lruEntries.size()])) { + removeEntry(entry); + } + } + + private void validateKey(String key) { + Matcher matcher = LEGAL_KEY_PATTERN.matcher(key); + if (!matcher.matches()) { + throw new IllegalArgumentException( + "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); + } + } + + /** + * Returns an iterator over the cache's current entries. This iterator doesn't throw {@code + * ConcurrentModificationException}, but if new entries are added while iterating, those new + * entries will not be returned by the iterator. If existing entries are removed during iteration, + * they will be absent (unless they were already returned). + * + *

If there are I/O problems during iteration, this iterator fails silently. For example, if + * the hosting filesystem becomes unreachable, the iterator will omit elements rather than + * throwing exceptions. + * + *

The caller must {@link Snapshot#close close} each snapshot returned by + * {@link Iterator#next}. Failing to do so leaks open files! + * + *

The returned iterator supports {@link Iterator#remove}. + */ + public synchronized Iterator snapshots() throws IOException { + initialize(); + return new Iterator() { + /** Iterate a copy of the entries to defend against concurrent modification errors. */ + final Iterator delegate = new ArrayList<>(lruEntries.values()).iterator(); + + /** The snapshot to return from {@link #next}. Null if we haven't computed that yet. */ + Snapshot nextSnapshot; + + /** The snapshot to remove with {@link #remove}. Null if removal is illegal. */ + Snapshot removeSnapshot; + + @Override public boolean hasNext() { + if (nextSnapshot != null) return true; + + synchronized (DiskLruCache.this) { + // If the cache is closed, truncate the iterator. + if (closed) return false; + + while (delegate.hasNext()) { + Entry entry = delegate.next(); + Snapshot snapshot = entry.snapshot(); + if (snapshot == null) continue; // Evicted since we copied the entries. + nextSnapshot = snapshot; + return true; + } + } + + return false; + } + + @Override public Snapshot next() { + if (!hasNext()) throw new NoSuchElementException(); + removeSnapshot = nextSnapshot; + nextSnapshot = null; + return removeSnapshot; + } + + @Override public void remove() { + if (removeSnapshot == null) throw new IllegalStateException("remove() before next()"); + try { + DiskLruCache.this.remove(removeSnapshot.key); + } catch (IOException ignored) { + // Nothing useful to do here. We failed to remove from the cache. Most likely that's + // because we couldn't update the journal, but the cached entry will still be gone. + } finally { + removeSnapshot = null; + } + } + }; + } + + /** A snapshot of the values for an entry. */ + public final class Snapshot implements Closeable { + private final String key; + private final long sequenceNumber; + private final Source[] sources; + private final long[] lengths; + + private Snapshot(String key, long sequenceNumber, Source[] sources, long[] lengths) { + this.key = key; + this.sequenceNumber = sequenceNumber; + this.sources = sources; + this.lengths = lengths; + } + + public String key() { + return key; + } + + /** + * Returns an editor for this snapshot's entry, or null if either the entry has changed since + * this snapshot was created or if another edit is in progress. + */ + public Editor edit() throws IOException { + return DiskLruCache.this.edit(key, sequenceNumber); + } + + /** Returns the unbuffered stream with the value for {@code index}. */ + public Source getSource(int index) { + return sources[index]; + } + + /** Returns the byte length of the value for {@code index}. */ + public long getLength(int index) { + return lengths[index]; + } + + public void close() { + for (Source in : sources) { + Util.closeQuietly(in); + } + } + } + + private static final Sink NULL_SINK = new Sink() { + @Override public void write(Buffer source, long byteCount) throws IOException { + source.skip(byteCount); + } + + @Override public void flush() throws IOException { + } + + @Override public Timeout timeout() { + return Timeout.NONE; + } + + @Override public void close() throws IOException { + } + }; + + /** Edits the values for an entry. */ + public final class Editor { + private final Entry entry; + private final boolean[] written; + private boolean hasErrors; + private boolean committed; + + private Editor(Entry entry) { + this.entry = entry; + this.written = (entry.readable) ? null : new boolean[valueCount]; + } + + /** + * Returns an unbuffered input stream to read the last committed value, or null if no value has + * been committed. + */ + public Source newSource(int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + return null; + } + try { + return fileSystem.source(entry.cleanFiles[index]); + } catch (FileNotFoundException e) { + return null; + } + } + } + + /** + * Returns a new unbuffered output stream to write the value at {@code index}. If the underlying + * output stream encounters errors when writing to the filesystem, this edit will be aborted + * when {@link #commit} is called. The returned output stream does not throw IOExceptions. + */ + public Sink newSink(int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + written[index] = true; + } + File dirtyFile = entry.dirtyFiles[index]; + Sink sink; + try { + sink = fileSystem.sink(dirtyFile); + } catch (FileNotFoundException e) { + return NULL_SINK; + } + return new FaultHidingSink(sink) { + @Override protected void onException(IOException e) { + synchronized (DiskLruCache.this) { + hasErrors = true; + } + } + }; + } + } + + /** + * Commits this edit so it is visible to readers. This releases the edit lock so another edit + * may be started on the same key. + */ + public void commit() throws IOException { + synchronized (DiskLruCache.this) { + if (hasErrors) { + completeEdit(this, false); + removeEntry(entry); // The previous entry is stale. + } else { + completeEdit(this, true); + } + committed = true; + } + } + + /** + * Aborts this edit. This releases the edit lock so another edit may be started on the same + * key. + */ + public void abort() throws IOException { + synchronized (DiskLruCache.this) { + completeEdit(this, false); + } + } + + public void abortUnlessCommitted() { + synchronized (DiskLruCache.this) { + if (!committed) { + try { + completeEdit(this, false); + } catch (IOException ignored) { + } + } + } + } + } + + private final class Entry { + private final String key; + + /** Lengths of this entry's files. */ + private final long[] lengths; + private final File[] cleanFiles; + private final File[] dirtyFiles; + + /** True if this entry has ever been published. */ + private boolean readable; + + /** The ongoing edit or null if this entry is not being edited. */ + private Editor currentEditor; + + /** The sequence number of the most recently committed edit to this entry. */ + private long sequenceNumber; + + private Entry(String key) { + this.key = key; + + lengths = new long[valueCount]; + cleanFiles = new File[valueCount]; + dirtyFiles = new File[valueCount]; + + // The names are repetitive so re-use the same builder to avoid allocations. + StringBuilder fileBuilder = new StringBuilder(key).append('.'); + int truncateTo = fileBuilder.length(); + for (int i = 0; i < valueCount; i++) { + fileBuilder.append(i); + cleanFiles[i] = new File(directory, fileBuilder.toString()); + fileBuilder.append(".tmp"); + dirtyFiles[i] = new File(directory, fileBuilder.toString()); + fileBuilder.setLength(truncateTo); + } + } + + /** Set lengths using decimal numbers like "10123". */ + private void setLengths(String[] strings) throws IOException { + if (strings.length != valueCount) { + throw invalidLengths(strings); + } + + try { + for (int i = 0; i < strings.length; i++) { + lengths[i] = Long.parseLong(strings[i]); + } + } catch (NumberFormatException e) { + throw invalidLengths(strings); + } + } + + /** Append space-prefixed lengths to {@code writer}. */ + void writeLengths(BufferedSink writer) throws IOException { + for (long length : lengths) { + writer.writeByte(' ').writeDecimalLong(length); + } + } + + private IOException invalidLengths(String[] strings) throws IOException { + throw new IOException("unexpected journal line: " + Arrays.toString(strings)); + } + + /** + * Returns a snapshot of this entry. This opens all streams eagerly to guarantee that we see a + * single published snapshot. If we opened streams lazily then the streams could come from + * different edits. + */ + Snapshot snapshot() { + if (!Thread.holdsLock(DiskLruCache.this)) throw new AssertionError(); + + Source[] sources = new Source[valueCount]; + long[] lengths = this.lengths.clone(); // Defensive copy since these can be zeroed out. + try { + for (int i = 0; i < valueCount; i++) { + sources[i] = fileSystem.source(cleanFiles[i]); + } + return new Snapshot(key, sequenceNumber, sources, lengths); + } catch (FileNotFoundException e) { + // A file must have been deleted manually! + for (int i = 0; i < valueCount; i++) { + if (sources[i] != null) { + Util.closeQuietly(sources[i]); + } else { + break; + } + } + return null; + } + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/FaultHidingSink.java b/okhttp3/src/main/java/okhttp3/internal/FaultHidingSink.java new file mode 100644 index 000000000..ef8430c08 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/FaultHidingSink.java @@ -0,0 +1,51 @@ +package okhttp3.internal; + +import java.io.IOException; +import okio.Buffer; +import okio.ForwardingSink; +import okio.Sink; + +/** A sink that never throws IOExceptions, even if the underlying sink does. */ +class FaultHidingSink extends ForwardingSink { + private boolean hasErrors; + + public FaultHidingSink(Sink delegate) { + super(delegate); + } + + @Override public void write(Buffer source, long byteCount) throws IOException { + if (hasErrors) { + source.skip(byteCount); + return; + } + try { + super.write(source, byteCount); + } catch (IOException e) { + hasErrors = true; + onException(e); + } + } + + @Override public void flush() throws IOException { + if (hasErrors) return; + try { + super.flush(); + } catch (IOException e) { + hasErrors = true; + onException(e); + } + } + + @Override public void close() throws IOException { + if (hasErrors) return; + try { + super.close(); + } catch (IOException e) { + hasErrors = true; + onException(e); + } + } + + protected void onException(IOException e) { + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/Internal.java b/okhttp3/src/main/java/okhttp3/internal/Internal.java new file mode 100644 index 000000000..54252084f --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/Internal.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal; + +import java.net.MalformedURLException; +import java.net.UnknownHostException; +import java.util.logging.Logger; +import javax.net.ssl.SSLSocket; +import okhttp3.Address; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.ConnectionPool; +import okhttp3.ConnectionSpec; +import okhttp3.Headers; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.internal.http.StreamAllocation; +import okhttp3.internal.io.RealConnection; + +/** + * Escalate internal APIs in {@code okhttp3} so they can be used from OkHttp's implementation + * packages. The only implementation of this interface is in {@link OkHttpClient}. + */ +public abstract class Internal { + public static final Logger logger = Logger.getLogger(OkHttpClient.class.getName()); + + public static void initializeInstanceForTests() { + // Needed in tests to ensure that the instance is actually pointing to something. + new OkHttpClient(); + } + + public static Internal instance; + + public abstract void addLenient(Headers.Builder builder, String line); + + public abstract void addLenient(Headers.Builder builder, String name, String value); + + public abstract void setCache(OkHttpClient.Builder builder, InternalCache internalCache); + + public abstract InternalCache internalCache(OkHttpClient client); + + public abstract RealConnection get( + ConnectionPool pool, Address address, StreamAllocation streamAllocation); + + public abstract void put(ConnectionPool pool, RealConnection connection); + + public abstract boolean connectionBecameIdle(ConnectionPool pool, RealConnection connection); + + public abstract RouteDatabase routeDatabase(ConnectionPool connectionPool); + + public abstract void apply(ConnectionSpec tlsConfiguration, SSLSocket sslSocket, + boolean isFallback); + + public abstract HttpUrl getHttpUrlChecked(String url) + throws MalformedURLException, UnknownHostException; + + // TODO delete the following when web sockets move into the main package. + public abstract void callEnqueue(Call call, Callback responseCallback, boolean forWebSocket); + + public abstract StreamAllocation callEngineGetStreamAllocation(Call call); +} diff --git a/okhttp3/src/main/java/okhttp3/internal/InternalCache.java b/okhttp3/src/main/java/okhttp3/internal/InternalCache.java new file mode 100644 index 000000000..9752002bc --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/InternalCache.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal; + +import java.io.IOException; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.internal.http.CacheRequest; +import okhttp3.internal.http.CacheStrategy; + +/** + * OkHttp's internal cache interface. Applications shouldn't implement this: instead use {@link + * okhttp3.Cache}. + */ +public interface InternalCache { + Response get(Request request) throws IOException; + + CacheRequest put(Response response) throws IOException; + + /** + * Remove any cache entries for the supplied {@code request}. This is invoked when the client + * invalidates the cache, such as when making POST requests. + */ + void remove(Request request) throws IOException; + + /** + * Handles a conditional request hit by updating the stored cache response with the headers from + * {@code network}. The cached response body is not updated. If the stored response has changed + * since {@code cached} was returned, this does nothing. + */ + void update(Response cached, Response network) throws IOException; + + /** Track an conditional GET that was satisfied by this cache. */ + void trackConditionalCacheHit(); + + /** Track an HTTP response being satisfied with {@code cacheStrategy}. */ + void trackResponse(CacheStrategy cacheStrategy); +} diff --git a/okhttp3/src/main/java/okhttp3/internal/NamedRunnable.java b/okhttp3/src/main/java/okhttp3/internal/NamedRunnable.java new file mode 100644 index 000000000..601ab2e72 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/NamedRunnable.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal; + +/** + * Runnable implementation which always sets its thread name. + */ +public abstract class NamedRunnable implements Runnable { + protected final String name; + + public NamedRunnable(String format, Object... args) { + this.name = String.format(format, args); + } + + @Override public final void run() { + String oldName = Thread.currentThread().getName(); + Thread.currentThread().setName(name); + try { + execute(); + } finally { + Thread.currentThread().setName(oldName); + } + } + + protected abstract void execute(); +} diff --git a/okhttp3/src/main/java/okhttp3/internal/OptionalMethod.java b/okhttp3/src/main/java/okhttp3/internal/OptionalMethod.java new file mode 100644 index 000000000..108ec292d --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/OptionalMethod.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +/** + * Duck-typing for methods: Represents a method that may or may not be present on an object. + * + * @param the type of the object the method might be on, typically an interface or base class + */ +class OptionalMethod { + + /** The return type of the method. null means "don't care". */ + private final Class returnType; + + private final String methodName; + + private final Class[] methodParams; + + /** + * Creates an optional method. + * + * @param returnType the return type to required, null if it does not matter + * @param methodName the name of the method + * @param methodParams the method parameter types + */ + public OptionalMethod(Class returnType, String methodName, Class... methodParams) { + this.returnType = returnType; + this.methodName = methodName; + this.methodParams = methodParams; + } + + /** + * Returns true if the method exists on the supplied {@code target}. + */ + public boolean isSupported(T target) { + return getMethod(target.getClass()) != null; + } + + /** + * Invokes the method on {@code target} with {@code args}. If the method does not exist or is not + * public then {@code null} is returned. See also {@link #invokeOptionalWithoutCheckedException}. + * + * @throws IllegalArgumentException if the arguments are invalid + * @throws InvocationTargetException if the invocation throws an exception + */ + public Object invokeOptional(T target, Object... args) throws InvocationTargetException { + Method m = getMethod(target.getClass()); + if (m == null) { + return null; + } + try { + return m.invoke(target, args); + } catch (IllegalAccessException e) { + return null; + } + } + + /** + * Invokes the method on {@code target}. If the method does not exist or is not public then + * {@code null} is returned. Any RuntimeException thrown by the method is thrown, checked + * exceptions are wrapped in an {@link AssertionError}. + * + * @throws IllegalArgumentException if the arguments are invalid + */ + public Object invokeOptionalWithoutCheckedException(T target, Object... args) { + try { + return invokeOptional(target, args); + } catch (InvocationTargetException e) { + Throwable targetException = e.getTargetException(); + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; + } + AssertionError error = new AssertionError("Unexpected exception"); + error.initCause(targetException); + throw error; + } + } + + /** + * Invokes the method on {@code target} with {@code args}. Throws an error if the method is not + * supported. See also {@link #invokeWithoutCheckedException(Object, Object...)}. + * + * @throws IllegalArgumentException if the arguments are invalid + * @throws InvocationTargetException if the invocation throws an exception + */ + public Object invoke(T target, Object... args) throws InvocationTargetException { + Method m = getMethod(target.getClass()); + if (m == null) { + throw new AssertionError("Method " + methodName + " not supported for object " + target); + } + try { + return m.invoke(target, args); + } catch (IllegalAccessException e) { + // Method should be public: we checked. + AssertionError error = new AssertionError("Unexpectedly could not call: " + m); + error.initCause(e); + throw error; + } + } + + /** + * Invokes the method on {@code target}. Throws an error if the method is not supported. Any + * RuntimeException thrown by the method is thrown, checked exceptions are wrapped in an {@link + * AssertionError}. + * + * @throws IllegalArgumentException if the arguments are invalid + */ + public Object invokeWithoutCheckedException(T target, Object... args) { + try { + return invoke(target, args); + } catch (InvocationTargetException e) { + Throwable targetException = e.getTargetException(); + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; + } + AssertionError error = new AssertionError("Unexpected exception"); + error.initCause(targetException); + throw error; + } + } + + /** + * Perform a lookup for the method. No caching. In order to return a method the method name and + * arguments must match those specified when the {@link OptionalMethod} was created. If the return + * type is specified (i.e. non-null) it must also be compatible. The method must also be public. + */ + private Method getMethod(Class clazz) { + Method method = null; + if (methodName != null) { + method = getPublicMethod(clazz, methodName, methodParams); + if (method != null + && returnType != null + && !returnType.isAssignableFrom(method.getReturnType())) { + + // If the return type is non-null it must be compatible. + method = null; + } + } + return method; + } + + private static Method getPublicMethod(Class clazz, String methodName, Class[] parameterTypes) { + Method method = null; + try { + method = clazz.getMethod(methodName, parameterTypes); + if ((method.getModifiers() & Modifier.PUBLIC) == 0) { + method = null; + } + } catch (NoSuchMethodException e) { + // None. + } + return method; + } +} + diff --git a/okhttp3/src/main/java/okhttp3/internal/Platform.java b/okhttp3/src/main/java/okhttp3/internal/Platform.java new file mode 100644 index 000000000..9e5e5545d --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/Platform.java @@ -0,0 +1,397 @@ +/* + * Copyright (C) 2012 Square, Inc. + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal; + +import android.util.Log; +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import javax.net.ssl.SSLSocket; +import okhttp3.Protocol; +import okio.Buffer; + +import static okhttp3.internal.Internal.logger; + +/** + * Access to platform-specific features. + * + *

Server name indication (SNI)

+ * + *

Supported on Android 2.3+. + * + *

Session Tickets

+ * + *

Supported on Android 2.3+. + * + *

Android Traffic Stats (Socket Tagging)

+ * + *

Supported on Android 4.0+. + * + *

ALPN (Application Layer Protocol Negotiation)

+ * + *

Supported on Android 5.0+. The APIs were present in Android 4.4, but that implementation was + * unstable. + * + * Supported on OpenJDK 7 and 8 (via the JettyALPN-boot library). + */ +public class Platform { + private static final Platform PLATFORM = findPlatform(); + + public static Platform get() { + return PLATFORM; + } + + /** Prefix used on custom headers. */ + public String getPrefix() { + return "OkHttp"; + } + + public void logW(String warning) { + System.out.println(warning); + } + + public void tagSocket(Socket socket) throws SocketException { + } + + public void untagSocket(Socket socket) throws SocketException { + } + + /** + * Configure TLS extensions on {@code sslSocket} for {@code route}. + * + * @param hostname non-null for client-side handshakes; null for server-side handshakes. + */ + public void configureTlsExtensions(SSLSocket sslSocket, String hostname, + List protocols) { + } + + /** + * Called after the TLS handshake to release resources allocated by {@link + * #configureTlsExtensions}. + */ + public void afterHandshake(SSLSocket sslSocket) { + } + + /** Returns the negotiated protocol, or null if no protocol was negotiated. */ + public String getSelectedProtocol(SSLSocket socket) { + return null; + } + + public void connectSocket(Socket socket, InetSocketAddress address, + int connectTimeout) throws IOException { + socket.connect(address, connectTimeout); + } + + public void log(String message) { + System.out.println(message); + } + + /** Attempt to match the host runtime to a capable Platform implementation. */ + private static Platform findPlatform() { + // Attempt to find Android 2.3+ APIs. + try { + try { + Class.forName("com.android.org.conscrypt.OpenSSLSocketImpl"); + } catch (ClassNotFoundException e) { + // Older platform before being unbundled. + Class.forName("org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl"); + } + + OptionalMethod setUseSessionTickets + = new OptionalMethod<>(null, "setUseSessionTickets", boolean.class); + OptionalMethod setHostname + = new OptionalMethod<>(null, "setHostname", String.class); + Method trafficStatsTagSocket = null; + Method trafficStatsUntagSocket = null; + OptionalMethod getAlpnSelectedProtocol = null; + OptionalMethod setAlpnProtocols = null; + + // Attempt to find Android 4.0+ APIs. + try { + Class trafficStats = Class.forName("android.net.TrafficStats"); + trafficStatsTagSocket = trafficStats.getMethod("tagSocket", Socket.class); + trafficStatsUntagSocket = trafficStats.getMethod("untagSocket", Socket.class); + + // Attempt to find Android 5.0+ APIs. + try { + Class.forName("android.net.Network"); // Arbitrary class added in Android 5.0. + getAlpnSelectedProtocol = new OptionalMethod<>(byte[].class, "getAlpnSelectedProtocol"); + setAlpnProtocols = new OptionalMethod<>(null, "setAlpnProtocols", byte[].class); + } catch (ClassNotFoundException ignored) { + } + } catch (ClassNotFoundException | NoSuchMethodException ignored) { + } + + return new Android(setUseSessionTickets, setHostname, trafficStatsTagSocket, + trafficStatsUntagSocket, getAlpnSelectedProtocol, setAlpnProtocols); + } catch (ClassNotFoundException ignored) { + // This isn't an Android runtime. + } + + // Find Jetty's ALPN extension for OpenJDK. + try { + String negoClassName = "org.eclipse.jetty.alpn.ALPN"; + Class negoClass = Class.forName(negoClassName); + Class providerClass = Class.forName(negoClassName + "$Provider"); + Class clientProviderClass = Class.forName(negoClassName + "$ClientProvider"); + Class serverProviderClass = Class.forName(negoClassName + "$ServerProvider"); + Method putMethod = negoClass.getMethod("put", SSLSocket.class, providerClass); + Method getMethod = negoClass.getMethod("get", SSLSocket.class); + Method removeMethod = negoClass.getMethod("remove", SSLSocket.class); + return new JdkWithJettyBootPlatform( + putMethod, getMethod, removeMethod, clientProviderClass, serverProviderClass); + } catch (ClassNotFoundException | NoSuchMethodException ignored) { + } + + return new Platform(); + } + + /** Android 2.3 or better. */ + private static class Android extends Platform { + private static final int MAX_LOG_LENGTH = 4000; + + private final OptionalMethod setUseSessionTickets; + private final OptionalMethod setHostname; + + // Non-null on Android 4.0+. + private final Method trafficStatsTagSocket; + private final Method trafficStatsUntagSocket; + + // Non-null on Android 5.0+. + private final OptionalMethod getAlpnSelectedProtocol; + private final OptionalMethod setAlpnProtocols; + + public Android(OptionalMethod setUseSessionTickets, OptionalMethod setHostname, + Method trafficStatsTagSocket, Method trafficStatsUntagSocket, + OptionalMethod getAlpnSelectedProtocol, OptionalMethod setAlpnProtocols) { + this.setUseSessionTickets = setUseSessionTickets; + this.setHostname = setHostname; + this.trafficStatsTagSocket = trafficStatsTagSocket; + this.trafficStatsUntagSocket = trafficStatsUntagSocket; + this.getAlpnSelectedProtocol = getAlpnSelectedProtocol; + this.setAlpnProtocols = setAlpnProtocols; + } + + @Override public void connectSocket(Socket socket, InetSocketAddress address, + int connectTimeout) throws IOException { + try { + socket.connect(address, connectTimeout); + } catch (AssertionError e) { + if (Util.isAndroidGetsocknameError(e)) throw new IOException(e); + throw e; + } catch (SecurityException e) { + // Before android 4.3, socket.connect could throw a SecurityException + // if opening a socket resulted in an EACCES error. + IOException ioException = new IOException("Exception in connect"); + ioException.initCause(e); + throw ioException; + } + } + + @Override public void configureTlsExtensions( + SSLSocket sslSocket, String hostname, List protocols) { + // Enable SNI and session tickets. + if (hostname != null) { + setUseSessionTickets.invokeOptionalWithoutCheckedException(sslSocket, true); + setHostname.invokeOptionalWithoutCheckedException(sslSocket, hostname); + } + + // Enable ALPN. + if (setAlpnProtocols != null && setAlpnProtocols.isSupported(sslSocket)) { + Object[] parameters = {concatLengthPrefixed(protocols)}; + setAlpnProtocols.invokeWithoutCheckedException(sslSocket, parameters); + } + } + + @Override public String getSelectedProtocol(SSLSocket socket) { + if (getAlpnSelectedProtocol == null) return null; + if (!getAlpnSelectedProtocol.isSupported(socket)) return null; + + byte[] alpnResult = (byte[]) getAlpnSelectedProtocol.invokeWithoutCheckedException(socket); + return alpnResult != null ? new String(alpnResult, Util.UTF_8) : null; + } + + @Override public void tagSocket(Socket socket) throws SocketException { + if (trafficStatsTagSocket == null) return; + + try { + trafficStatsTagSocket.invoke(null, socket); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e.getCause()); + } + } + + @Override public void untagSocket(Socket socket) throws SocketException { + if (trafficStatsUntagSocket == null) return; + + try { + trafficStatsUntagSocket.invoke(null, socket); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e.getCause()); + } + } + + @Override public void log(String message) { + // Split by line, then ensure each line can fit into Log's maximum length. + for (int i = 0, length = message.length(); i < length; i++) { + int newline = message.indexOf('\n', i); + newline = newline != -1 ? newline : length; + do { + int end = Math.min(newline, i + MAX_LOG_LENGTH); + Log.d("OkHttp", message.substring(i, end)); + i = end; + } while (i < newline); + } + } + } + + /** + * OpenJDK 7+ with {@code org.mortbay.jetty.alpn/alpn-boot} in the boot class path. + */ + private static class JdkWithJettyBootPlatform extends Platform { + private final Method putMethod; + private final Method getMethod; + private final Method removeMethod; + private final Class clientProviderClass; + private final Class serverProviderClass; + + public JdkWithJettyBootPlatform(Method putMethod, Method getMethod, Method removeMethod, + Class clientProviderClass, Class serverProviderClass) { + this.putMethod = putMethod; + this.getMethod = getMethod; + this.removeMethod = removeMethod; + this.clientProviderClass = clientProviderClass; + this.serverProviderClass = serverProviderClass; + } + + @Override public void configureTlsExtensions( + SSLSocket sslSocket, String hostname, List protocols) { + List names = new ArrayList<>(protocols.size()); + for (int i = 0, size = protocols.size(); i < size; i++) { + Protocol protocol = protocols.get(i); + if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for ALPN. + names.add(protocol.toString()); + } + try { + Object provider = Proxy.newProxyInstance(Platform.class.getClassLoader(), + new Class[] {clientProviderClass, serverProviderClass}, new JettyNegoProvider(names)); + putMethod.invoke(null, sslSocket, provider); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new AssertionError(e); + } + } + + @Override public void afterHandshake(SSLSocket sslSocket) { + try { + removeMethod.invoke(null, sslSocket); + } catch (IllegalAccessException | InvocationTargetException ignored) { + throw new AssertionError(); + } + } + + @Override public String getSelectedProtocol(SSLSocket socket) { + try { + JettyNegoProvider provider = + (JettyNegoProvider) Proxy.getInvocationHandler(getMethod.invoke(null, socket)); + if (!provider.unsupported && provider.selected == null) { + logger.log(Level.INFO, "ALPN callback dropped: SPDY and HTTP/2 are disabled. " + + "Is alpn-boot on the boot class path?"); + return null; + } + return provider.unsupported ? null : provider.selected; + } catch (InvocationTargetException | IllegalAccessException e) { + throw new AssertionError(); + } + } + } + + /** + * Handle the methods of ALPN's ClientProvider and ServerProvider without a compile-time + * dependency on those interfaces. + */ + private static class JettyNegoProvider implements InvocationHandler { + /** This peer's supported protocols. */ + private final List protocols; + /** Set when remote peer notifies ALPN is unsupported. */ + private boolean unsupported; + /** The protocol the server selected. */ + private String selected; + + public JettyNegoProvider(List protocols) { + this.protocols = protocols; + } + + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String methodName = method.getName(); + Class returnType = method.getReturnType(); + if (args == null) { + args = Util.EMPTY_STRING_ARRAY; + } + if (methodName.equals("supports") && boolean.class == returnType) { + return true; // ALPN is supported. + } else if (methodName.equals("unsupported") && void.class == returnType) { + this.unsupported = true; // Peer doesn't support ALPN. + return null; + } else if (methodName.equals("protocols") && args.length == 0) { + return protocols; // Client advertises these protocols. + } else if ((methodName.equals("selectProtocol") || methodName.equals("select")) + && String.class == returnType && args.length == 1 && args[0] instanceof List) { + List peerProtocols = (List) args[0]; + // Pick the first known protocol the peer advertises. + for (int i = 0, size = peerProtocols.size(); i < size; i++) { + if (protocols.contains(peerProtocols.get(i))) { + return selected = peerProtocols.get(i); + } + } + return selected = protocols.get(0); // On no intersection, try peer's first protocol. + } else if ((methodName.equals("protocolSelected") || methodName.equals("selected")) + && args.length == 1) { + this.selected = (String) args[0]; // Server selected this protocol. + return null; + } else { + return method.invoke(this, args); + } + } + } + + /** + * Returns the concatenation of 8-bit, length prefixed protocol names. + * http://tools.ietf.org/html/draft-agl-tls-nextprotoneg-04#page-4 + */ + static byte[] concatLengthPrefixed(List protocols) { + Buffer result = new Buffer(); + for (int i = 0, size = protocols.size(); i < size; i++) { + Protocol protocol = protocols.get(i); + if (protocol == Protocol.HTTP_1_0) continue; // No HTTP/1.0 for ALPN. + result.writeByte(protocol.toString().length()); + result.writeUtf8(protocol.toString()); + } + return result.readByteArray(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/RouteDatabase.java b/okhttp3/src/main/java/okhttp3/internal/RouteDatabase.java new file mode 100644 index 000000000..613e0bf21 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/RouteDatabase.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal; + +import java.util.LinkedHashSet; +import java.util.Set; +import okhttp3.Route; + +/** + * A blacklist of failed routes to avoid when creating a new connection to a target address. This is + * used so that OkHttp can learn from its mistakes: if there was a failure attempting to connect to + * a specific IP address or proxy server, that failure is remembered and alternate routes are + * preferred. + */ +public final class RouteDatabase { + private final Set failedRoutes = new LinkedHashSet<>(); + + /** Records a failure connecting to {@code failedRoute}. */ + public synchronized void failed(Route failedRoute) { + failedRoutes.add(failedRoute); + } + + /** Records success connecting to {@code failedRoute}. */ + public synchronized void connected(Route route) { + failedRoutes.remove(route); + } + + /** Returns true if {@code route} has failed recently and should be avoided. */ + public synchronized boolean shouldPostpone(Route route) { + return failedRoutes.contains(route); + } + + public synchronized int failedRoutesCount() { + return failedRoutes.size(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/Util.java b/okhttp3/src/main/java/okhttp3/internal/Util.java new file mode 100644 index 000000000..e9efad04c --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/Util.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Array; +import java.net.IDN; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import okhttp3.HttpUrl; +import okio.Buffer; +import okio.ByteString; +import okio.Source; + +/** Junk drawer of utility methods. */ +public final class Util { + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + public static final String[] EMPTY_STRING_ARRAY = new String[0]; + + /** A cheap and type-safe constant for the UTF-8 Charset. */ + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + /** GMT and UTC are equivalent for our purposes. */ + public static final TimeZone UTC = TimeZone.getTimeZone("GMT"); + + /** + * Quick and dirty pattern to differentiate IP addresses from hostnames. This is an approximation + * of Android's private InetAddress#isNumeric API. + * + *

This matches IPv6 addresses as a hex string containing at least one colon, and possibly + * including dots after the first colon. It matches IPv4 addresses as strings containing only + * decimal digits and dots. This pattern matches strings like "a:.23" and "54" that are neither IP + * addresses nor hostnames; they will be verified as IP addresses (which is a more strict + * verification). + */ + private static final Pattern VERIFY_AS_IP_ADDRESS = Pattern.compile( + "([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)"); + + private Util() { + } + + public static void checkOffsetAndCount(long arrayLength, long offset, long count) { + if ((offset | count) < 0 || offset > arrayLength || arrayLength - offset < count) { + throw new ArrayIndexOutOfBoundsException(); + } + } + + /** Returns true if two possibly-null objects are equal. */ + public static boolean equal(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + /** + * Closes {@code closeable}, ignoring any checked exceptions. Does nothing if {@code closeable} is + * null. + */ + public static void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Closes {@code socket}, ignoring any checked exceptions. Does nothing if {@code socket} is + * null. + */ + public static void closeQuietly(Socket socket) { + if (socket != null) { + try { + socket.close(); + } catch (AssertionError e) { + if (!isAndroidGetsocknameError(e)) throw e; + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Closes {@code serverSocket}, ignoring any checked exceptions. Does nothing if {@code + * serverSocket} is null. + */ + public static void closeQuietly(ServerSocket serverSocket) { + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Closes {@code a} and {@code b}. If either close fails, this completes the other close and + * rethrows the first encountered exception. + */ + public static void closeAll(Closeable a, Closeable b) throws IOException { + Throwable thrown = null; + try { + a.close(); + } catch (Throwable e) { + thrown = e; + } + try { + b.close(); + } catch (Throwable e) { + if (thrown == null) thrown = e; + } + if (thrown == null) return; + if (thrown instanceof IOException) throw (IOException) thrown; + if (thrown instanceof RuntimeException) throw (RuntimeException) thrown; + if (thrown instanceof Error) throw (Error) thrown; + throw new AssertionError(thrown); + } + + /** + * Attempts to exhaust {@code source}, returning true if successful. This is useful when reading a + * complete source is helpful, such as when doing so completes a cache body or frees a socket + * connection for reuse. + */ + public static boolean discard(Source source, int timeout, TimeUnit timeUnit) { + try { + return skipAll(source, timeout, timeUnit); + } catch (IOException e) { + return false; + } + } + + /** + * Reads until {@code in} is exhausted or the deadline has been reached. This is careful to not + * extend the deadline if one exists already. + */ + public static boolean skipAll(Source source, int duration, TimeUnit timeUnit) throws IOException { + long now = System.nanoTime(); + long originalDuration = source.timeout().hasDeadline() + ? source.timeout().deadlineNanoTime() - now + : Long.MAX_VALUE; + source.timeout().deadlineNanoTime(now + Math.min(originalDuration, timeUnit.toNanos(duration))); + try { + Buffer skipBuffer = new Buffer(); + while (source.read(skipBuffer, 2048) != -1) { + skipBuffer.clear(); + } + return true; // Success! The source has been exhausted. + } catch (InterruptedIOException e) { + return false; // We ran out of time before exhausting the source. + } finally { + if (originalDuration == Long.MAX_VALUE) { + source.timeout().clearDeadline(); + } else { + source.timeout().deadlineNanoTime(now + originalDuration); + } + } + } + + /** Returns a 32 character string containing an MD5 hash of {@code s}. */ + public static String md5Hex(String s) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("MD5"); + byte[] md5bytes = messageDigest.digest(s.getBytes("UTF-8")); + return ByteString.of(md5bytes).hex(); + } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + /** Returns a Base 64-encoded string containing a SHA-1 hash of {@code s}. */ + public static String shaBase64(String s) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + byte[] sha1Bytes = messageDigest.digest(s.getBytes("UTF-8")); + return ByteString.of(sha1Bytes).base64(); + } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + /** Returns a SHA-1 hash of {@code s}. */ + public static ByteString sha1(ByteString s) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + byte[] sha1Bytes = messageDigest.digest(s.toByteArray()); + return ByteString.of(sha1Bytes); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + /** Returns an immutable copy of {@code list}. */ + public static List immutableList(List list) { + return Collections.unmodifiableList(new ArrayList<>(list)); + } + + /** Returns an immutable list containing {@code elements}. */ + public static List immutableList(T... elements) { + return Collections.unmodifiableList(Arrays.asList(elements.clone())); + } + + /** Returns an immutable copy of {@code map}. */ + public static Map immutableMap(Map map) { + return Collections.unmodifiableMap(new LinkedHashMap<>(map)); + } + + public static ThreadFactory threadFactory(final String name, final boolean daemon) { + return new ThreadFactory() { + @Override public Thread newThread(Runnable runnable) { + Thread result = new Thread(runnable, name); + result.setDaemon(daemon); + return result; + } + }; + } + + /** + * Returns an array containing containing only elements found in {@code first} and also in {@code + * second}. The returned elements are in the same order as in {@code first}. + */ + @SuppressWarnings("unchecked") + public static T[] intersect(Class arrayType, T[] first, T[] second) { + List result = intersect(first, second); + return result.toArray((T[]) Array.newInstance(arrayType, result.size())); + } + + /** + * Returns a list containing containing only elements found in {@code first} and also in {@code + * second}. The returned elements are in the same order as in {@code first}. + */ + private static List intersect(T[] first, T[] second) { + List result = new ArrayList<>(); + for (T a : first) { + for (T b : second) { + if (a.equals(b)) { + result.add(b); + break; + } + } + } + return result; + } + + public static String hostHeader(HttpUrl url) { + // TODO: square braces for IPv6 ? + return url.port() != HttpUrl.defaultPort(url.scheme()) + ? url.host() + ":" + url.port() + : url.host(); + } + + /** Returns {@code s} with control characters and non-ASCII characters replaced with '?'. */ + public static String toHumanReadableAscii(String s) { + for (int i = 0, length = s.length(), c; i < length; i += Character.charCount(c)) { + c = s.codePointAt(i); + if (c > '\u001f' && c < '\u007f') continue; + + Buffer buffer = new Buffer(); + buffer.writeUtf8(s, 0, i); + for (int j = i; j < length; j += Character.charCount(c)) { + c = s.codePointAt(j); + buffer.writeUtf8CodePoint(c > '\u001f' && c < '\u007f' ? c : '?'); + } + return buffer.readUtf8(); + } + return s; + } + + /** + * Returns true if {@code e} is due to a firmware bug fixed after Android 4.2.2. + * https://code.google.com/p/android/issues/detail?id=54072 + */ + public static boolean isAndroidGetsocknameError(AssertionError e) { + return e.getCause() != null && e.getMessage() != null + && e.getMessage().contains("getsockname failed"); + } + + public static boolean contains(String[] array, String value) { + return Arrays.asList(array).contains(value); + } + + public static String[] concat(String[] array, String value) { + String[] result = new String[array.length + 1]; + System.arraycopy(array, 0, result, 0, array.length); + result[result.length - 1] = value; + return result; + } + + /** + * Increments {@code pos} until {@code input[pos]} is not ASCII whitespace. Stops at {@code + * limit}. + */ + public static int skipLeadingAsciiWhitespace(String input, int pos, int limit) { + for (int i = pos; i < limit; i++) { + switch (input.charAt(i)) { + case '\t': + case '\n': + case '\f': + case '\r': + case ' ': + continue; + default: + return i; + } + } + return limit; + } + + /** + * Decrements {@code limit} until {@code input[limit - 1]} is not ASCII whitespace. Stops at + * {@code pos}. + */ + public static int skipTrailingAsciiWhitespace(String input, int pos, int limit) { + for (int i = limit - 1; i >= pos; i--) { + switch (input.charAt(i)) { + case '\t': + case '\n': + case '\f': + case '\r': + case ' ': + continue; + default: + return i + 1; + } + } + return pos; + } + + /** Equivalent to {@code string.substring(pos, limit).trim()}. */ + public static String trimSubstring(String string, int pos, int limit) { + int start = skipLeadingAsciiWhitespace(string, pos, limit); + int end = skipTrailingAsciiWhitespace(string, start, limit); + return string.substring(start, end); + } + + /** + * Returns the index of the first character in {@code input} that contains a character in {@code + * delimiters}. Returns limit if there is no such character. + */ + public static int delimiterOffset(String input, int pos, int limit, String delimiters) { + for (int i = pos; i < limit; i++) { + if (delimiters.indexOf(input.charAt(i)) != -1) return i; + } + return limit; + } + + /** + * Returns the index of the first character in {@code input} that is {@code delimiter}. Returns + * limit if there is no such character. + */ + public static int delimiterOffset(String input, int pos, int limit, char delimiter) { + for (int i = pos; i < limit; i++) { + if (input.charAt(i) == delimiter) return i; + } + return limit; + } + + /** + * Performs IDN ToASCII encoding and canonicalize the result to lowercase. e.g. This converts + * {@code ☃.net} to {@code xn--n3h.net}, and {@code WwW.GoOgLe.cOm} to {@code www.google.com}. + * {@code null} will be returned if the input cannot be ToASCII encoded or if the result + * contains unsupported ASCII characters. + */ + public static String domainToAscii(String input) { + try { + String result = IDN.toASCII(input).toLowerCase(Locale.US); + if (result.isEmpty()) return null; + + // Confirm that the IDN ToASCII result doesn't contain any illegal characters. + if (containsInvalidHostnameAsciiCodes(result)) { + return null; + } + // TODO: implement all label limits. + return result; + } catch (IllegalArgumentException e) { + return null; + } + } + + private static boolean containsInvalidHostnameAsciiCodes(String hostnameAscii) { + for (int i = 0; i < hostnameAscii.length(); i++) { + char c = hostnameAscii.charAt(i); + // The WHATWG Host parsing rules accepts some character codes which are invalid by + // definition for OkHttp's host header checks (and the WHATWG Host syntax definition). Here + // we rule out characters that would cause problems in host headers. + if (c <= '\u001f' || c >= '\u007f') { + return true; + } + // Check for the characters mentioned in the WHATWG Host parsing spec: + // U+0000, U+0009, U+000A, U+000D, U+0020, "#", "%", "/", ":", "?", "@", "[", "\", and "]" + // (excluding the characters covered above). + if (" #%/:?@[\\]".indexOf(c) != -1) { + return true; + } + } + return false; + } + + /** Returns true if {@code host} is not a host name and might be an IP address. */ + public static boolean verifyAsIpAddress(String host) { + return VERIFY_AS_IP_ADDRESS.matcher(host).matches(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/Version.java b/okhttp3/src/main/java/okhttp3/internal/Version.java new file mode 100644 index 000000000..3c25ee871 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/Version.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal; + +public final class Version { + public static String userAgent() { + return "okhttp/3.0.1"; + } + + private Version() { + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/ErrorCode.java b/okhttp3/src/main/java/okhttp3/internal/framed/ErrorCode.java new file mode 100644 index 000000000..1142319d7 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/ErrorCode.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +// http://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-7 +public enum ErrorCode { + /** Not an error! For SPDY stream resets, prefer null over NO_ERROR. */ + NO_ERROR(0, -1, 0), + + PROTOCOL_ERROR(1, 1, 1), + + /** A subtype of PROTOCOL_ERROR used by SPDY. */ + INVALID_STREAM(1, 2, -1), + + /** A subtype of PROTOCOL_ERROR used by SPDY. */ + UNSUPPORTED_VERSION(1, 4, -1), + + /** A subtype of PROTOCOL_ERROR used by SPDY. */ + STREAM_IN_USE(1, 8, -1), + + /** A subtype of PROTOCOL_ERROR used by SPDY. */ + STREAM_ALREADY_CLOSED(1, 9, -1), + + INTERNAL_ERROR(2, 6, 2), + + FLOW_CONTROL_ERROR(3, 7, -1), + + STREAM_CLOSED(5, -1, -1), + + FRAME_TOO_LARGE(6, 11, -1), + + REFUSED_STREAM(7, 3, -1), + + CANCEL(8, 5, -1), + + COMPRESSION_ERROR(9, -1, -1), + + CONNECT_ERROR(10, -1, -1), + + ENHANCE_YOUR_CALM(11, -1, -1), + + INADEQUATE_SECURITY(12, -1, -1), + + HTTP_1_1_REQUIRED(13, -1, -1), + + INVALID_CREDENTIALS(-1, 10, -1); + + public final int httpCode; + public final int spdyRstCode; + public final int spdyGoAwayCode; + + private ErrorCode(int httpCode, int spdyRstCode, int spdyGoAwayCode) { + this.httpCode = httpCode; + this.spdyRstCode = spdyRstCode; + this.spdyGoAwayCode = spdyGoAwayCode; + } + + public static ErrorCode fromSpdy3Rst(int code) { + for (ErrorCode errorCode : ErrorCode.values()) { + if (errorCode.spdyRstCode == code) return errorCode; + } + return null; + } + + public static ErrorCode fromHttp2(int code) { + for (ErrorCode errorCode : ErrorCode.values()) { + if (errorCode.httpCode == code) return errorCode; + } + return null; + } + + public static ErrorCode fromSpdyGoAway(int code) { + for (ErrorCode errorCode : ErrorCode.values()) { + if (errorCode.spdyGoAwayCode == code) return errorCode; + } + return null; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/FrameReader.java b/okhttp3/src/main/java/okhttp3/internal/framed/FrameReader.java new file mode 100644 index 000000000..cfcfac0be --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/FrameReader.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.framed; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import okio.BufferedSource; +import okio.ByteString; + +/** Reads transport frames for SPDY/3 or HTTP/2. */ +public interface FrameReader extends Closeable { + void readConnectionPreface() throws IOException; + + boolean nextFrame(Handler handler) throws IOException; + + interface Handler { + void data(boolean inFinished, int streamId, BufferedSource source, int length) + throws IOException; + + /** + * Create or update incoming headers, creating the corresponding streams if necessary. Frames + * that trigger this are SPDY SYN_STREAM, HEADERS, and SYN_REPLY, and HTTP/2 HEADERS and + * PUSH_PROMISE. + * + * @param outFinished true if the receiver should not send further frames. + * @param inFinished true if the sender will not send further frames. + * @param streamId the stream owning these headers. + * @param associatedStreamId the stream that triggered the sender to create this stream. + */ + void headers(boolean outFinished, boolean inFinished, int streamId, int associatedStreamId, + List

headerBlock, HeadersMode headersMode); + + void rstStream(int streamId, ErrorCode errorCode); + + void settings(boolean clearPrevious, Settings settings); + + /** HTTP/2 only. */ + void ackSettings(); + + /** + * Read a connection-level ping from the peer. {@code ack} indicates this is a reply. Payload + * parameters are different between SPDY/3 and HTTP/2. + * + *

In SPDY/3, only the first {@code payload1} parameter is set. If the reader is a client, + * it is an unsigned even number. Likewise, a server will receive an odd number. + * + *

In HTTP/2, both {@code payload1} and {@code payload2} parameters are set. The data is + * opaque binary, and there are no rules on the content. + */ + void ping(boolean ack, int payload1, int payload2); + + /** + * The peer tells us to stop creating streams. It is safe to replay streams with {@code ID > + * lastGoodStreamId} on a new connection. In- flight streams with {@code ID <= + * lastGoodStreamId} can only be replayed on a new connection if they are idempotent. + * + * @param lastGoodStreamId the last stream ID the peer processed before sending this message. If + * {@code lastGoodStreamId} is zero, the peer processed no frames. + * @param errorCode reason for closing the connection. + * @param debugData only valid for HTTP/2; opaque debug data to send. + */ + void goAway(int lastGoodStreamId, ErrorCode errorCode, ByteString debugData); + + /** + * Notifies that an additional {@code windowSizeIncrement} bytes can be sent on {@code + * streamId}, or the connection if {@code streamId} is zero. + */ + void windowUpdate(int streamId, long windowSizeIncrement); + + /** + * Called when reading a headers or priority frame. This may be used to change the stream's + * weight from the default (16) to a new value. + * + * @param streamId stream which has a priority change. + * @param streamDependency the stream ID this stream is dependent on. + * @param weight relative proportion of priority in [1..256]. + * @param exclusive inserts this stream ID as the sole child of {@code streamDependency}. + */ + void priority(int streamId, int streamDependency, int weight, boolean exclusive); + + /** + * HTTP/2 only. Receive a push promise header block.

A push promise contains all the headers + * that pertain to a server-initiated request, and a {@code promisedStreamId} to which response + * frames will be delivered. Push promise frames are sent as a part of the response to {@code + * streamId}. + * + * @param streamId client-initiated stream ID. Must be an odd number. + * @param promisedStreamId server-initiated stream ID. Must be an even number. + * @param requestHeaders minimally includes {@code :method}, {@code :scheme}, {@code + * :authority}, and (@code :path}. + */ + void pushPromise(int streamId, int promisedStreamId, List

requestHeaders) + throws IOException; + + /** + * HTTP/2 only. Expresses that resources for the connection or a client- initiated stream are + * available from a different network location or protocol configuration. + * + *

See alt-svc + * + * @param streamId when a client-initiated stream ID (odd number), the origin of this alternate + * service is the origin of the stream. When zero, the origin is specified in the {@code origin} + * parameter. + * @param origin when present, the origin is + * typically represented as a combination of scheme, host and port. When empty, the origin is + * that of the {@code streamId}. + * @param protocol an ALPN protocol, such as {@code h2}. + * @param host an IP address or hostname. + * @param port the IP port associated with the service. + * @param maxAge time in seconds that this alternative is considered fresh. + */ + void alternateService(int streamId, String origin, ByteString protocol, String host, int port, + long maxAge); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/FrameWriter.java b/okhttp3/src/main/java/okhttp3/internal/framed/FrameWriter.java new file mode 100644 index 000000000..ca1a7e4b8 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/FrameWriter.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.framed; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import okio.Buffer; + +/** Writes transport frames for SPDY/3 or HTTP/2. */ +public interface FrameWriter extends Closeable { + /** HTTP/2 only. */ + void connectionPreface() throws IOException; + + /** Informs the peer that we've applied its latest settings. */ + void ackSettings(Settings peerSettings) throws IOException; + + /** + * HTTP/2 only. Send a push promise header block. + * + *

A push promise contains all the headers that pertain to a server-initiated request, and a + * {@code promisedStreamId} to which response frames will be delivered. Push promise frames are + * sent as a part of the response to {@code streamId}. The {@code promisedStreamId} has a priority + * of one greater than {@code streamId}. + * + * @param streamId client-initiated stream ID. Must be an odd number. + * @param promisedStreamId server-initiated stream ID. Must be an even number. + * @param requestHeaders minimally includes {@code :method}, {@code :scheme}, {@code :authority}, + * and (@code :path}. + */ + void pushPromise(int streamId, int promisedStreamId, List

requestHeaders) + throws IOException; + + /** SPDY/3 only. */ + void flush() throws IOException; + + void synStream(boolean outFinished, boolean inFinished, int streamId, int associatedStreamId, + List
headerBlock) throws IOException; + + void synReply(boolean outFinished, int streamId, List
headerBlock) + throws IOException; + + void headers(int streamId, List
headerBlock) throws IOException; + + void rstStream(int streamId, ErrorCode errorCode) throws IOException; + + /** The maximum size of bytes that may be sent in a single call to {@link #data}. */ + int maxDataLength(); + + /** + * {@code source.length} may be longer than the max length of the variant's data frame. + * Implementations must send multiple frames as necessary. + * + * @param source the buffer to draw bytes from. May be null if byteCount is 0. + * @param byteCount must be between 0 and the minimum of {code source.length} and {@link + * #maxDataLength}. + */ + void data(boolean outFinished, int streamId, Buffer source, int byteCount) throws IOException; + + /** Write okhttp's settings to the peer. */ + void settings(Settings okHttpSettings) throws IOException; + + /** + * Send a connection-level ping to the peer. {@code ack} indicates this is a reply. Payload + * parameters are different between SPDY/3 and HTTP/2. + * + *

In SPDY/3, only the first {@code payload1} parameter is sent. If the sender is a client, it + * is an unsigned odd number. Likewise, a server will send an even number. + * + *

In HTTP/2, both {@code payload1} and {@code payload2} parameters are sent. The data is + * opaque binary, and there are no rules on the content. + */ + void ping(boolean ack, int payload1, int payload2) throws IOException; + + /** + * Tell the peer to stop creating streams and that we last processed {@code lastGoodStreamId}, or + * zero if no streams were processed. + * + * @param lastGoodStreamId the last stream ID processed, or zero if no streams were processed. + * @param errorCode reason for closing the connection. + * @param debugData only valid for HTTP/2; opaque debug data to send. + */ + void goAway(int lastGoodStreamId, ErrorCode errorCode, byte[] debugData) throws IOException; + + /** + * Inform peer that an additional {@code windowSizeIncrement} bytes can be sent on {@code + * streamId}, or the connection if {@code streamId} is zero. + */ + void windowUpdate(int streamId, long windowSizeIncrement) throws IOException; +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/FramedConnection.java b/okhttp3/src/main/java/okhttp3/internal/framed/FramedConnection.java new file mode 100644 index 000000000..26d511f91 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/FramedConnection.java @@ -0,0 +1,934 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import okhttp3.Protocol; +import okhttp3.internal.NamedRunnable; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.ByteString; +import okio.Okio; + +import static okhttp3.internal.Internal.logger; +import static okhttp3.internal.framed.Settings.DEFAULT_INITIAL_WINDOW_SIZE; + +/** + * A socket connection to a remote peer. A connection hosts streams which can send and receive + * data. + * + *

Many methods in this API are synchronous: the call is completed before the + * method returns. This is typical for Java but atypical for SPDY. This is motivated by exception + * transparency: an IOException that was triggered by a certain caller can be caught and handled by + * that caller. + */ +public final class FramedConnection implements Closeable { + + // Internal state of this connection is guarded by 'this'. No blocking + // operations may be performed while holding this lock! + // + // Socket writes are guarded by frameWriter. + // + // Socket reads are unguarded but are only made by the reader thread. + // + // Certain operations (like SYN_STREAM) need to synchronize on both the + // frameWriter (to do blocking I/O) and this (to create streams). Such + // operations must synchronize on 'this' last. This ensures that we never + // wait for a blocking operation while holding 'this'. + + private static final ExecutorService executor = new ThreadPoolExecutor(0, + Integer.MAX_VALUE, 60, TimeUnit.SECONDS, new SynchronousQueue(), + Util.threadFactory("OkHttp FramedConnection", true)); + + /** The protocol variant, like {@link Spdy3}. */ + final Protocol protocol; + + /** True if this peer initiated the connection. */ + final boolean client; + + /** + * User code to run in response to incoming streams or settings. Calls to this are always invoked + * on {@link #executor}. + */ + private final Listener listener; + private final Map streams = new HashMap<>(); + private final String hostName; + private int lastGoodStreamId; + private int nextStreamId; + private boolean shutdown; + private long idleStartTimeNs = System.nanoTime(); + + /** Ensures push promise callbacks events are sent in order per stream. */ + private final ExecutorService pushExecutor; + + /** Lazily-created map of in-flight pings awaiting a response. Guarded by this. */ + private Map pings; + /** User code to run in response to push promise events. */ + private final PushObserver pushObserver; + private int nextPingId; + + /** + * The total number of bytes consumed by the application, but not yet acknowledged by sending a + * {@code WINDOW_UPDATE} frame on this connection. + */ + // Visible for testing + long unacknowledgedBytesRead = 0; + + /** + * Count of bytes that can be written on the connection before receiving a window update. + */ + // Visible for testing + long bytesLeftInWriteWindow; + + /** Settings we communicate to the peer. */ + Settings okHttpSettings = new Settings(); + + private static final int OKHTTP_CLIENT_WINDOW_SIZE = 16 * 1024 * 1024; + + /** Settings we receive from the peer. */ + // TODO: MWS will need to guard on this setting before attempting to push. + final Settings peerSettings = new Settings(); + + private boolean receivedInitialPeerSettings = false; + final Variant variant; + final Socket socket; + final FrameWriter frameWriter; + + // Visible for testing + final Reader readerRunnable; + + private FramedConnection(Builder builder) throws IOException { + protocol = builder.protocol; + pushObserver = builder.pushObserver; + client = builder.client; + listener = builder.listener; + // http://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-5.1.1 + nextStreamId = builder.client ? 1 : 2; + if (builder.client && protocol == Protocol.HTTP_2) { + nextStreamId += 2; // In HTTP/2, 1 on client is reserved for Upgrade. + } + + nextPingId = builder.client ? 1 : 2; + + // Flow control was designed more for servers, or proxies than edge clients. + // If we are a client, set the flow control window to 16MiB. This avoids + // thrashing window updates every 64KiB, yet small enough to avoid blowing + // up the heap. + if (builder.client) { + okHttpSettings.set(Settings.INITIAL_WINDOW_SIZE, 0, OKHTTP_CLIENT_WINDOW_SIZE); + } + + hostName = builder.hostName; + + if (protocol == Protocol.HTTP_2) { + variant = new Http2(); + // Like newSingleThreadExecutor, except lazy creates the thread. + pushExecutor = new ThreadPoolExecutor(0, 1, 60, TimeUnit.SECONDS, + new LinkedBlockingQueue(), + Util.threadFactory(String.format("OkHttp %s Push Observer", hostName), true)); + // 1 less than SPDY http://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-6.9.2 + peerSettings.set(Settings.INITIAL_WINDOW_SIZE, 0, 65535); + peerSettings.set(Settings.MAX_FRAME_SIZE, 0, Http2.INITIAL_MAX_FRAME_SIZE); + } else if (protocol == Protocol.SPDY_3) { + variant = new Spdy3(); + pushExecutor = null; + } else { + throw new AssertionError(protocol); + } + bytesLeftInWriteWindow = peerSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE); + socket = builder.socket; + frameWriter = variant.newWriter(builder.sink, client); + + readerRunnable = new Reader(variant.newReader(builder.source, client)); + new Thread(readerRunnable).start(); // Not a daemon thread. + } + + /** The protocol as selected using ALPN. */ + public Protocol getProtocol() { + return protocol; + } + + /** + * Returns the number of {@link FramedStream#isOpen() open streams} on this connection. + */ + public synchronized int openStreamCount() { + return streams.size(); + } + + synchronized FramedStream getStream(int id) { + return streams.get(id); + } + + synchronized FramedStream removeStream(int streamId) { + FramedStream stream = streams.remove(streamId); + if (stream != null && streams.isEmpty()) { + setIdle(true); + } + notifyAll(); // The removed stream may be blocked on a connection-wide window update. + return stream; + } + + private synchronized void setIdle(boolean value) { + idleStartTimeNs = value ? System.nanoTime() : Long.MAX_VALUE; + } + + /** Returns true if this connection is idle. */ + public synchronized boolean isIdle() { + return idleStartTimeNs != Long.MAX_VALUE; + } + + public synchronized int maxConcurrentStreams() { + return peerSettings.getMaxConcurrentStreams(Integer.MAX_VALUE); + } + + /** + * Returns the time in ns when this connection became idle or Long.MAX_VALUE if connection is not + * idle. + */ + public synchronized long getIdleStartTimeNs() { + return idleStartTimeNs; + } + + /** + * Returns a new server-initiated stream. + * + * @param associatedStreamId the stream that triggered the sender to create this stream. + * @param out true to create an output stream that we can use to send data to the remote peer. + * Corresponds to {@code FLAG_FIN}. + */ + public FramedStream pushStream(int associatedStreamId, List

requestHeaders, boolean out) + throws IOException { + if (client) throw new IllegalStateException("Client cannot push requests."); + if (protocol != Protocol.HTTP_2) throw new IllegalStateException("protocol != HTTP_2"); + return newStream(associatedStreamId, requestHeaders, out, false); + } + + /** + * Returns a new locally-initiated stream. + * + * @param out true to create an output stream that we can use to send data to the remote peer. + * Corresponds to {@code FLAG_FIN}. + * @param in true to create an input stream that the remote peer can use to send data to us. + * Corresponds to {@code FLAG_UNIDIRECTIONAL}. + */ + public FramedStream newStream(List
requestHeaders, boolean out, boolean in) + throws IOException { + return newStream(0, requestHeaders, out, in); + } + + private FramedStream newStream(int associatedStreamId, List
requestHeaders, boolean out, + boolean in) throws IOException { + boolean outFinished = !out; + boolean inFinished = !in; + FramedStream stream; + int streamId; + + synchronized (frameWriter) { + synchronized (this) { + if (shutdown) { + throw new IOException("shutdown"); + } + streamId = nextStreamId; + nextStreamId += 2; + stream = new FramedStream(streamId, this, outFinished, inFinished, requestHeaders); + if (stream.isOpen()) { + streams.put(streamId, stream); + setIdle(false); + } + } + if (associatedStreamId == 0) { + frameWriter.synStream(outFinished, inFinished, streamId, associatedStreamId, + requestHeaders); + } else if (client) { + throw new IllegalArgumentException("client streams shouldn't have associated stream IDs"); + } else { // HTTP/2 has a PUSH_PROMISE frame. + frameWriter.pushPromise(associatedStreamId, streamId, requestHeaders); + } + } + + if (!out) { + frameWriter.flush(); + } + + return stream; + } + + void writeSynReply(int streamId, boolean outFinished, List
alternating) + throws IOException { + frameWriter.synReply(outFinished, streamId, alternating); + } + + /** + * Callers of this method are not thread safe, and sometimes on application threads. Most often, + * this method will be called to send a buffer worth of data to the peer. + * + *

Writes are subject to the write window of the stream and the connection. Until there is a + * window sufficient to send {@code byteCount}, the caller will block. For example, a user of + * {@code HttpURLConnection} who flushes more bytes to the output stream than the connection's + * write window will block. + * + *

Zero {@code byteCount} writes are not subject to flow control and will not block. The only + * use case for zero {@code byteCount} is closing a flushed output stream. + */ + public void writeData(int streamId, boolean outFinished, Buffer buffer, long byteCount) + throws IOException { + if (byteCount == 0) { // Empty data frames are not flow-controlled. + frameWriter.data(outFinished, streamId, buffer, 0); + return; + } + + while (byteCount > 0) { + int toWrite; + synchronized (FramedConnection.this) { + try { + while (bytesLeftInWriteWindow <= 0) { + // Before blocking, confirm that the stream we're writing is still open. It's possible + // that the stream has since been closed (such as if this write timed out.) + if (!streams.containsKey(streamId)) { + throw new IOException("stream closed"); + } + FramedConnection.this.wait(); // Wait until we receive a WINDOW_UPDATE. + } + } catch (InterruptedException e) { + throw new InterruptedIOException(); + } + + toWrite = (int) Math.min(byteCount, bytesLeftInWriteWindow); + toWrite = Math.min(toWrite, frameWriter.maxDataLength()); + bytesLeftInWriteWindow -= toWrite; + } + + byteCount -= toWrite; + frameWriter.data(outFinished && byteCount == 0, streamId, buffer, toWrite); + } + } + + /** + * {@code delta} will be negative if a settings frame initial window is smaller than the last. + */ + void addBytesToWriteWindow(long delta) { + bytesLeftInWriteWindow += delta; + if (delta > 0) FramedConnection.this.notifyAll(); + } + + void writeSynResetLater(final int streamId, final ErrorCode errorCode) { + executor.submit(new NamedRunnable("OkHttp %s stream %d", hostName, streamId) { + @Override public void execute() { + try { + writeSynReset(streamId, errorCode); + } catch (IOException ignored) { + } + } + }); + } + + void writeSynReset(int streamId, ErrorCode statusCode) throws IOException { + frameWriter.rstStream(streamId, statusCode); + } + + void writeWindowUpdateLater(final int streamId, final long unacknowledgedBytesRead) { + executor.execute(new NamedRunnable("OkHttp Window Update %s stream %d", hostName, streamId) { + @Override public void execute() { + try { + frameWriter.windowUpdate(streamId, unacknowledgedBytesRead); + } catch (IOException ignored) { + } + } + }); + } + + /** + * Sends a ping frame to the peer. Use the returned object to await the ping's response and + * observe its round trip time. + */ + public Ping ping() throws IOException { + Ping ping = new Ping(); + int pingId; + synchronized (this) { + if (shutdown) { + throw new IOException("shutdown"); + } + pingId = nextPingId; + nextPingId += 2; + if (pings == null) pings = new HashMap<>(); + pings.put(pingId, ping); + } + writePing(false, pingId, 0x4f4b6f6b /* ASCII "OKok" */, ping); + return ping; + } + + private void writePingLater( + final boolean reply, final int payload1, final int payload2, final Ping ping) { + executor.execute(new NamedRunnable("OkHttp %s ping %08x%08x", + hostName, payload1, payload2) { + @Override public void execute() { + try { + writePing(reply, payload1, payload2, ping); + } catch (IOException ignored) { + } + } + }); + } + + private void writePing(boolean reply, int payload1, int payload2, Ping ping) throws IOException { + synchronized (frameWriter) { + // Observe the sent time immediately before performing I/O. + if (ping != null) ping.send(); + frameWriter.ping(reply, payload1, payload2); + } + } + + private synchronized Ping removePing(int id) { + return pings != null ? pings.remove(id) : null; + } + + public void flush() throws IOException { + frameWriter.flush(); + } + + /** + * Degrades this connection such that new streams can neither be created locally, nor accepted + * from the remote peer. Existing streams are not impacted. This is intended to permit an endpoint + * to gracefully stop accepting new requests without harming previously established streams. + */ + public void shutdown(ErrorCode statusCode) throws IOException { + synchronized (frameWriter) { + int lastGoodStreamId; + synchronized (this) { + if (shutdown) { + return; + } + shutdown = true; + lastGoodStreamId = this.lastGoodStreamId; + } + // TODO: propagate exception message into debugData + frameWriter.goAway(lastGoodStreamId, statusCode, Util.EMPTY_BYTE_ARRAY); + } + } + + /** + * Closes this connection. This cancels all open streams and unanswered pings. It closes the + * underlying input and output streams and shuts down internal executor services. + */ + @Override public void close() throws IOException { + close(ErrorCode.NO_ERROR, ErrorCode.CANCEL); + } + + private void close(ErrorCode connectionCode, ErrorCode streamCode) throws IOException { + assert (!Thread.holdsLock(this)); + IOException thrown = null; + try { + shutdown(connectionCode); + } catch (IOException e) { + thrown = e; + } + + FramedStream[] streamsToClose = null; + Ping[] pingsToCancel = null; + synchronized (this) { + if (!streams.isEmpty()) { + streamsToClose = streams.values().toArray(new FramedStream[streams.size()]); + streams.clear(); + setIdle(false); + } + if (pings != null) { + pingsToCancel = pings.values().toArray(new Ping[pings.size()]); + pings = null; + } + } + + if (streamsToClose != null) { + for (FramedStream stream : streamsToClose) { + try { + stream.close(streamCode); + } catch (IOException e) { + if (thrown != null) thrown = e; + } + } + } + + if (pingsToCancel != null) { + for (Ping ping : pingsToCancel) { + ping.cancel(); + } + } + + // Close the writer to release its resources (such as deflaters). + try { + frameWriter.close(); + } catch (IOException e) { + if (thrown == null) thrown = e; + } + + // Close the socket to break out the reader thread, which will clean up after itself. + try { + socket.close(); + } catch (IOException e) { + thrown = e; + } + + if (thrown != null) throw thrown; + } + + /** + * Sends a connection header if the current variant requires it. This should be called after + * {@link Builder#build} for all new connections. + */ + public void sendConnectionPreface() throws IOException { + frameWriter.connectionPreface(); + frameWriter.settings(okHttpSettings); + int windowSize = okHttpSettings.getInitialWindowSize(Settings.DEFAULT_INITIAL_WINDOW_SIZE); + if (windowSize != Settings.DEFAULT_INITIAL_WINDOW_SIZE) { + frameWriter.windowUpdate(0, windowSize - Settings.DEFAULT_INITIAL_WINDOW_SIZE); + } + } + + /** Merges {@code settings} into this peer's settings and sends them to the remote peer. */ + public void setSettings(Settings settings) throws IOException { + synchronized (frameWriter) { + synchronized (this) { + if (shutdown) { + throw new IOException("shutdown"); + } + okHttpSettings.merge(settings); + frameWriter.settings(settings); + } + } + } + + public static class Builder { + private Socket socket; + private String hostName; + private BufferedSource source; + private BufferedSink sink; + private Listener listener = Listener.REFUSE_INCOMING_STREAMS; + private Protocol protocol = Protocol.SPDY_3; + private PushObserver pushObserver = PushObserver.CANCEL; + private boolean client; + + /** + * @param client true if this peer initiated the connection; false if this peer accepted the + * connection. + */ + public Builder(boolean client) throws IOException { + this.client = client; + } + + public Builder socket(Socket socket) throws IOException { + return socket(socket, ((InetSocketAddress) socket.getRemoteSocketAddress()).getHostName(), + Okio.buffer(Okio.source(socket)), Okio.buffer(Okio.sink(socket))); + } + + public Builder socket( + Socket socket, String hostName, BufferedSource source, BufferedSink sink) { + this.socket = socket; + this.hostName = hostName; + this.source = source; + this.sink = sink; + return this; + } + + public Builder listener(Listener listener) { + this.listener = listener; + return this; + } + + public Builder protocol(Protocol protocol) { + this.protocol = protocol; + return this; + } + + public Builder pushObserver(PushObserver pushObserver) { + this.pushObserver = pushObserver; + return this; + } + + public FramedConnection build() throws IOException { + return new FramedConnection(this); + } + } + + /** + * Methods in this class must not lock FrameWriter. If a method needs to write a frame, create an + * async task to do so. + */ + class Reader extends NamedRunnable implements FrameReader.Handler { + final FrameReader frameReader; + + private Reader(FrameReader frameReader) { + super("OkHttp %s", hostName); + this.frameReader = frameReader; + } + + @Override protected void execute() { + ErrorCode connectionErrorCode = ErrorCode.INTERNAL_ERROR; + ErrorCode streamErrorCode = ErrorCode.INTERNAL_ERROR; + try { + if (!client) { + frameReader.readConnectionPreface(); + } + while (frameReader.nextFrame(this)) { + } + connectionErrorCode = ErrorCode.NO_ERROR; + streamErrorCode = ErrorCode.CANCEL; + } catch (IOException e) { + connectionErrorCode = ErrorCode.PROTOCOL_ERROR; + streamErrorCode = ErrorCode.PROTOCOL_ERROR; + } finally { + try { + close(connectionErrorCode, streamErrorCode); + } catch (IOException ignored) { + } + Util.closeQuietly(frameReader); + } + } + + @Override public void data(boolean inFinished, int streamId, BufferedSource source, int length) + throws IOException { + if (pushedStream(streamId)) { + pushDataLater(streamId, source, length, inFinished); + return; + } + FramedStream dataStream = getStream(streamId); + if (dataStream == null) { + writeSynResetLater(streamId, ErrorCode.INVALID_STREAM); + source.skip(length); + return; + } + dataStream.receiveData(source, length); + if (inFinished) { + dataStream.receiveFin(); + } + } + + @Override public void headers(boolean outFinished, boolean inFinished, int streamId, + int associatedStreamId, List

headerBlock, HeadersMode headersMode) { + if (pushedStream(streamId)) { + pushHeadersLater(streamId, headerBlock, inFinished); + return; + } + FramedStream stream; + synchronized (FramedConnection.this) { + // If we're shutdown, don't bother with this stream. + if (shutdown) return; + + stream = getStream(streamId); + + if (stream == null) { + // The headers claim to be for an existing stream, but we don't have one. + if (headersMode.failIfStreamAbsent()) { + writeSynResetLater(streamId, ErrorCode.INVALID_STREAM); + return; + } + + // If the stream ID is less than the last created ID, assume it's already closed. + if (streamId <= lastGoodStreamId) return; + + // If the stream ID is in the client's namespace, assume it's already closed. + if (streamId % 2 == nextStreamId % 2) return; + + // Create a stream. + final FramedStream + newStream = new FramedStream(streamId, FramedConnection.this, outFinished, + inFinished, headerBlock); + lastGoodStreamId = streamId; + streams.put(streamId, newStream); + executor.execute(new NamedRunnable("OkHttp %s stream %d", hostName, streamId) { + @Override public void execute() { + try { + listener.onStream(newStream); + } catch (IOException e) { + logger.log(Level.INFO, "FramedConnection.Listener failure for " + hostName, e); + try { + newStream.close(ErrorCode.PROTOCOL_ERROR); + } catch (IOException ignored) { + } + } + } + }); + return; + } + } + + // The headers claim to be for a new stream, but we already have one. + if (headersMode.failIfStreamPresent()) { + stream.closeLater(ErrorCode.PROTOCOL_ERROR); + removeStream(streamId); + return; + } + + // Update an existing stream. + stream.receiveHeaders(headerBlock, headersMode); + if (inFinished) stream.receiveFin(); + } + + @Override public void rstStream(int streamId, ErrorCode errorCode) { + if (pushedStream(streamId)) { + pushResetLater(streamId, errorCode); + return; + } + FramedStream rstStream = removeStream(streamId); + if (rstStream != null) { + rstStream.receiveRstStream(errorCode); + } + } + + @Override public void settings(boolean clearPrevious, Settings newSettings) { + long delta = 0; + FramedStream[] streamsToNotify = null; + synchronized (FramedConnection.this) { + int priorWriteWindowSize = peerSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE); + if (clearPrevious) peerSettings.clear(); + peerSettings.merge(newSettings); + if (getProtocol() == Protocol.HTTP_2) { + ackSettingsLater(newSettings); + } + int peerInitialWindowSize = peerSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE); + if (peerInitialWindowSize != -1 && peerInitialWindowSize != priorWriteWindowSize) { + delta = peerInitialWindowSize - priorWriteWindowSize; + if (!receivedInitialPeerSettings) { + addBytesToWriteWindow(delta); + receivedInitialPeerSettings = true; + } + if (!streams.isEmpty()) { + streamsToNotify = streams.values().toArray(new FramedStream[streams.size()]); + } + } + executor.execute(new NamedRunnable("OkHttp %s settings", hostName) { + @Override public void execute() { + listener.onSettings(FramedConnection.this); + } + }); + } + if (streamsToNotify != null && delta != 0) { + for (FramedStream stream : streamsToNotify) { + synchronized (stream) { + stream.addBytesToWriteWindow(delta); + } + } + } + } + + private void ackSettingsLater(final Settings peerSettings) { + executor.execute(new NamedRunnable("OkHttp %s ACK Settings", hostName) { + @Override public void execute() { + try { + frameWriter.ackSettings(peerSettings); + } catch (IOException ignored) { + } + } + }); + } + + @Override public void ackSettings() { + // TODO: If we don't get this callback after sending settings to the peer, SETTINGS_TIMEOUT. + } + + @Override public void ping(boolean reply, int payload1, int payload2) { + if (reply) { + Ping ping = removePing(payload1); + if (ping != null) { + ping.receive(); + } + } else { + // Send a reply to a client ping if this is a server and vice versa. + writePingLater(true, payload1, payload2, null); + } + } + + @Override public void goAway(int lastGoodStreamId, ErrorCode errorCode, ByteString debugData) { + if (debugData.size() > 0) { // TODO: log the debugData + } + + // Copy the streams first. We don't want to hold a lock when we call receiveRstStream(). + FramedStream[] streamsCopy; + synchronized (FramedConnection.this) { + streamsCopy = streams.values().toArray(new FramedStream[streams.size()]); + shutdown = true; + } + + // Fail all streams created after the last good stream ID. + for (FramedStream framedStream : streamsCopy) { + if (framedStream.getId() > lastGoodStreamId && framedStream.isLocallyInitiated()) { + framedStream.receiveRstStream(ErrorCode.REFUSED_STREAM); + removeStream(framedStream.getId()); + } + } + } + + @Override public void windowUpdate(int streamId, long windowSizeIncrement) { + if (streamId == 0) { + synchronized (FramedConnection.this) { + bytesLeftInWriteWindow += windowSizeIncrement; + FramedConnection.this.notifyAll(); + } + } else { + FramedStream stream = getStream(streamId); + if (stream != null) { + synchronized (stream) { + stream.addBytesToWriteWindow(windowSizeIncrement); + } + } + } + } + + @Override public void priority(int streamId, int streamDependency, int weight, + boolean exclusive) { + // TODO: honor priority. + } + + @Override + public void pushPromise(int streamId, int promisedStreamId, List
requestHeaders) { + pushRequestLater(promisedStreamId, requestHeaders); + } + + @Override public void alternateService(int streamId, String origin, ByteString protocol, + String host, int port, long maxAge) { + // TODO: register alternate service. + } + } + + /** Even, positive numbered streams are pushed streams in HTTP/2. */ + private boolean pushedStream(int streamId) { + return protocol == Protocol.HTTP_2 && streamId != 0 && (streamId & 1) == 0; + } + + // Guarded by this. + private final Set currentPushRequests = new LinkedHashSet<>(); + + private void pushRequestLater(final int streamId, final List
requestHeaders) { + synchronized (this) { + if (currentPushRequests.contains(streamId)) { + writeSynResetLater(streamId, ErrorCode.PROTOCOL_ERROR); + return; + } + currentPushRequests.add(streamId); + } + pushExecutor.execute(new NamedRunnable("OkHttp %s Push Request[%s]", hostName, streamId) { + @Override public void execute() { + boolean cancel = pushObserver.onRequest(streamId, requestHeaders); + try { + if (cancel) { + frameWriter.rstStream(streamId, ErrorCode.CANCEL); + synchronized (FramedConnection.this) { + currentPushRequests.remove(streamId); + } + } + } catch (IOException ignored) { + } + } + }); + } + + private void pushHeadersLater(final int streamId, final List
requestHeaders, + final boolean inFinished) { + pushExecutor.execute(new NamedRunnable("OkHttp %s Push Headers[%s]", hostName, streamId) { + @Override public void execute() { + boolean cancel = pushObserver.onHeaders(streamId, requestHeaders, inFinished); + try { + if (cancel) frameWriter.rstStream(streamId, ErrorCode.CANCEL); + if (cancel || inFinished) { + synchronized (FramedConnection.this) { + currentPushRequests.remove(streamId); + } + } + } catch (IOException ignored) { + } + } + }); + } + + /** + * Eagerly reads {@code byteCount} bytes from the source before launching a background task to + * process the data. This avoids corrupting the stream. + */ + private void pushDataLater(final int streamId, final BufferedSource source, final int byteCount, + final boolean inFinished) throws IOException { + final Buffer buffer = new Buffer(); + source.require(byteCount); // Eagerly read the frame before firing client thread. + source.read(buffer, byteCount); + if (buffer.size() != byteCount) throw new IOException(buffer.size() + " != " + byteCount); + pushExecutor.execute(new NamedRunnable("OkHttp %s Push Data[%s]", hostName, streamId) { + @Override public void execute() { + try { + boolean cancel = pushObserver.onData(streamId, buffer, byteCount, inFinished); + if (cancel) frameWriter.rstStream(streamId, ErrorCode.CANCEL); + if (cancel || inFinished) { + synchronized (FramedConnection.this) { + currentPushRequests.remove(streamId); + } + } + } catch (IOException ignored) { + } + } + }); + } + + private void pushResetLater(final int streamId, final ErrorCode errorCode) { + pushExecutor.execute(new NamedRunnable("OkHttp %s Push Reset[%s]", hostName, streamId) { + @Override public void execute() { + pushObserver.onReset(streamId, errorCode); + synchronized (FramedConnection.this) { + currentPushRequests.remove(streamId); + } + } + }); + } + + /** Listener of streams and settings initiated by the peer. */ + public abstract static class Listener { + public static final Listener REFUSE_INCOMING_STREAMS = new Listener() { + @Override public void onStream(FramedStream stream) throws IOException { + stream.close(ErrorCode.REFUSED_STREAM); + } + }; + + /** + * Handle a new stream from this connection's peer. Implementations should respond by either + * {@linkplain FramedStream#reply replying to the stream} or {@linkplain FramedStream#close + * closing it}. This response does not need to be synchronous. + */ + public abstract void onStream(FramedStream stream) throws IOException; + + /** + * Notification that the connection's peer's settings may have changed. Implementations should + * take appropriate action to handle the updated settings. + * + *

It is the implementation's responsibility to handle concurrent calls to this method. A + * remote peer that sends multiple settings frames will trigger multiple calls to this method, + * and those calls are not necessarily serialized. + */ + public void onSettings(FramedConnection connection) { + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/FramedStream.java b/okhttp3/src/main/java/okhttp3/internal/framed/FramedStream.java new file mode 100644 index 000000000..857a99c3c --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/FramedStream.java @@ -0,0 +1,612 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.framed; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import okio.AsyncTimeout; +import okio.Buffer; +import okio.BufferedSource; +import okio.Sink; +import okio.Source; +import okio.Timeout; + +import static okhttp3.internal.framed.Settings.DEFAULT_INITIAL_WINDOW_SIZE; + +/** A logical bidirectional stream. */ +public final class FramedStream { + // Internal state is guarded by this. No long-running or potentially + // blocking operations are performed while the lock is held. + + /** + * The total number of bytes consumed by the application (with {@link FramedDataSource#read}), but + * not yet acknowledged by sending a {@code WINDOW_UPDATE} frame on this stream. + */ + // Visible for testing + long unacknowledgedBytesRead = 0; + + /** + * Count of bytes that can be written on the stream before receiving a window update. Even if this + * is positive, writes will block until there available bytes in {@code + * connection.bytesLeftInWriteWindow}. + */ + // guarded by this + long bytesLeftInWriteWindow; + + private final int id; + private final FramedConnection connection; + + /** Headers sent by the stream initiator. Immutable and non null. */ + private final List

requestHeaders; + + /** Headers sent in the stream reply. Null if reply is either not sent or not sent yet. */ + private List
responseHeaders; + + private final FramedDataSource source; + final FramedDataSink sink; + private final StreamTimeout readTimeout = new StreamTimeout(); + private final StreamTimeout writeTimeout = new StreamTimeout(); + + /** + * The reason why this stream was abnormally closed. If there are multiple reasons to abnormally + * close this stream (such as both peers closing it near-simultaneously) then this is the first + * reason known to this peer. + */ + private ErrorCode errorCode = null; + + FramedStream(int id, FramedConnection connection, boolean outFinished, boolean inFinished, + List
requestHeaders) { + if (connection == null) throw new NullPointerException("connection == null"); + if (requestHeaders == null) throw new NullPointerException("requestHeaders == null"); + this.id = id; + this.connection = connection; + this.bytesLeftInWriteWindow = + connection.peerSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE); + this.source = new FramedDataSource( + connection.okHttpSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE)); + this.sink = new FramedDataSink(); + this.source.finished = inFinished; + this.sink.finished = outFinished; + this.requestHeaders = requestHeaders; + } + + public int getId() { + return id; + } + + /** + * Returns true if this stream is open. A stream is open until either: + * + *
    + *
  • A {@code SYN_RESET} frame abnormally terminates the stream. + *
  • Both input and output streams have transmitted all data and headers. + *
+ * + *

Note that the input stream may continue to yield data even after a stream reports itself as + * not open. This is because input data is buffered. + */ + public synchronized boolean isOpen() { + if (errorCode != null) { + return false; + } + if ((source.finished || source.closed) + && (sink.finished || sink.closed) + && responseHeaders != null) { + return false; + } + return true; + } + + /** Returns true if this stream was created by this peer. */ + public boolean isLocallyInitiated() { + boolean streamIsClient = ((id & 1) == 1); + return connection.client == streamIsClient; + } + + public FramedConnection getConnection() { + return connection; + } + + public List

getRequestHeaders() { + return requestHeaders; + } + + /** + * Returns the stream's response headers, blocking if necessary if they have not been received + * yet. + */ + public synchronized List
getResponseHeaders() throws IOException { + readTimeout.enter(); + try { + while (responseHeaders == null && errorCode == null) { + waitForIo(); + } + } finally { + readTimeout.exitAndThrowIfTimedOut(); + } + if (responseHeaders != null) return responseHeaders; + throw new IOException("stream was reset: " + errorCode); + } + + /** + * Returns the reason why this stream was closed, or null if it closed normally or has not yet + * been closed. + */ + public synchronized ErrorCode getErrorCode() { + return errorCode; + } + + /** + * Sends a reply to an incoming stream. + * + * @param out true to create an output stream that we can use to send data to the remote peer. + * Corresponds to {@code FLAG_FIN}. + */ + public void reply(List
responseHeaders, boolean out) throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + boolean outFinished = false; + synchronized (this) { + if (responseHeaders == null) { + throw new NullPointerException("responseHeaders == null"); + } + if (this.responseHeaders != null) { + throw new IllegalStateException("reply already sent"); + } + this.responseHeaders = responseHeaders; + if (!out) { + this.sink.finished = true; + outFinished = true; + } + } + connection.writeSynReply(id, outFinished, responseHeaders); + + if (outFinished) { + connection.flush(); + } + } + + public Timeout readTimeout() { + return readTimeout; + } + + public Timeout writeTimeout() { + return writeTimeout; + } + + /** Returns a source that reads data from the peer. */ + public Source getSource() { + return source; + } + + /** + * Returns a sink that can be used to write data to the peer. + * + * @throws IllegalStateException if this stream was initiated by the peer and a {@link #reply} has + * not yet been sent. + */ + public Sink getSink() { + synchronized (this) { + if (responseHeaders == null && !isLocallyInitiated()) { + throw new IllegalStateException("reply before requesting the sink"); + } + } + return sink; + } + + /** + * Abnormally terminate this stream. This blocks until the {@code RST_STREAM} frame has been + * transmitted. + */ + public void close(ErrorCode rstStatusCode) throws IOException { + if (!closeInternal(rstStatusCode)) { + return; // Already closed. + } + connection.writeSynReset(id, rstStatusCode); + } + + /** + * Abnormally terminate this stream. This enqueues a {@code RST_STREAM} frame and returns + * immediately. + */ + public void closeLater(ErrorCode errorCode) { + if (!closeInternal(errorCode)) { + return; // Already closed. + } + connection.writeSynResetLater(id, errorCode); + } + + /** Returns true if this stream was closed. */ + private boolean closeInternal(ErrorCode errorCode) { + assert (!Thread.holdsLock(this)); + synchronized (this) { + if (this.errorCode != null) { + return false; + } + if (source.finished && sink.finished) { + return false; + } + this.errorCode = errorCode; + notifyAll(); + } + connection.removeStream(id); + return true; + } + + void receiveHeaders(List
headers, HeadersMode headersMode) { + assert (!Thread.holdsLock(FramedStream.this)); + ErrorCode errorCode = null; + boolean open = true; + synchronized (this) { + if (responseHeaders == null) { + if (headersMode.failIfHeadersAbsent()) { + errorCode = ErrorCode.PROTOCOL_ERROR; + } else { + responseHeaders = headers; + open = isOpen(); + notifyAll(); + } + } else { + if (headersMode.failIfHeadersPresent()) { + errorCode = ErrorCode.STREAM_IN_USE; + } else { + List
newHeaders = new ArrayList<>(); + newHeaders.addAll(responseHeaders); + newHeaders.addAll(headers); + this.responseHeaders = newHeaders; + } + } + } + if (errorCode != null) { + closeLater(errorCode); + } else if (!open) { + connection.removeStream(id); + } + } + + void receiveData(BufferedSource in, int length) throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + this.source.receive(in, length); + } + + void receiveFin() { + assert (!Thread.holdsLock(FramedStream.this)); + boolean open; + synchronized (this) { + this.source.finished = true; + open = isOpen(); + notifyAll(); + } + if (!open) { + connection.removeStream(id); + } + } + + synchronized void receiveRstStream(ErrorCode errorCode) { + if (this.errorCode == null) { + this.errorCode = errorCode; + notifyAll(); + } + } + + /** + * A source that reads the incoming data frames of a stream. Although this class uses + * synchronization to safely receive incoming data frames, it is not intended for use by multiple + * readers. + */ + private final class FramedDataSource implements Source { + /** Buffer to receive data from the network into. Only accessed by the reader thread. */ + private final Buffer receiveBuffer = new Buffer(); + + /** Buffer with readable data. Guarded by FramedStream.this. */ + private final Buffer readBuffer = new Buffer(); + + /** Maximum number of bytes to buffer before reporting a flow control error. */ + private final long maxByteCount; + + /** True if the caller has closed this stream. */ + private boolean closed; + + /** + * True if either side has cleanly shut down this stream. We will receive no more bytes beyond + * those already in the buffer. + */ + private boolean finished; + + private FramedDataSource(long maxByteCount) { + this.maxByteCount = maxByteCount; + } + + @Override public long read(Buffer sink, long byteCount) + throws IOException { + if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount); + + long read; + synchronized (FramedStream.this) { + waitUntilReadable(); + checkNotClosed(); + if (readBuffer.size() == 0) return -1; // This source is exhausted. + + // Move bytes from the read buffer into the caller's buffer. + read = readBuffer.read(sink, Math.min(byteCount, readBuffer.size())); + + // Flow control: notify the peer that we're ready for more data! + unacknowledgedBytesRead += read; + if (unacknowledgedBytesRead + >= connection.okHttpSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE) / 2) { + connection.writeWindowUpdateLater(id, unacknowledgedBytesRead); + unacknowledgedBytesRead = 0; + } + } + + // Update connection.unacknowledgedBytesRead outside the stream lock. + synchronized (connection) { // Multiple application threads may hit this section. + connection.unacknowledgedBytesRead += read; + if (connection.unacknowledgedBytesRead + >= connection.okHttpSettings.getInitialWindowSize(DEFAULT_INITIAL_WINDOW_SIZE) / 2) { + connection.writeWindowUpdateLater(0, connection.unacknowledgedBytesRead); + connection.unacknowledgedBytesRead = 0; + } + } + + return read; + } + + /** Returns once the source is either readable or finished. */ + private void waitUntilReadable() throws IOException { + readTimeout.enter(); + try { + while (readBuffer.size() == 0 && !finished && !closed && errorCode == null) { + waitForIo(); + } + } finally { + readTimeout.exitAndThrowIfTimedOut(); + } + } + + void receive(BufferedSource in, long byteCount) throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + + while (byteCount > 0) { + boolean finished; + boolean flowControlError; + synchronized (FramedStream.this) { + finished = this.finished; + flowControlError = byteCount + readBuffer.size() > maxByteCount; + } + + // If the peer sends more data than we can handle, discard it and close the connection. + if (flowControlError) { + in.skip(byteCount); + closeLater(ErrorCode.FLOW_CONTROL_ERROR); + return; + } + + // Discard data received after the stream is finished. It's probably a benign race. + if (finished) { + in.skip(byteCount); + return; + } + + // Fill the receive buffer without holding any locks. + long read = in.read(receiveBuffer, byteCount); + if (read == -1) throw new EOFException(); + byteCount -= read; + + // Move the received data to the read buffer to the reader can read it. + synchronized (FramedStream.this) { + boolean wasEmpty = readBuffer.size() == 0; + readBuffer.writeAll(receiveBuffer); + if (wasEmpty) { + FramedStream.this.notifyAll(); + } + } + } + } + + @Override public Timeout timeout() { + return readTimeout; + } + + @Override public void close() throws IOException { + synchronized (FramedStream.this) { + closed = true; + readBuffer.clear(); + FramedStream.this.notifyAll(); + } + cancelStreamIfNecessary(); + } + + private void checkNotClosed() throws IOException { + if (closed) { + throw new IOException("stream closed"); + } + if (errorCode != null) { + throw new IOException("stream was reset: " + errorCode); + } + } + } + + private void cancelStreamIfNecessary() throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + boolean open; + boolean cancel; + synchronized (this) { + cancel = !source.finished && source.closed && (sink.finished || sink.closed); + open = isOpen(); + } + if (cancel) { + // RST this stream to prevent additional data from being sent. This + // is safe because the input stream is closed (we won't use any + // further bytes) and the output stream is either finished or closed + // (so RSTing both streams doesn't cause harm). + FramedStream.this.close(ErrorCode.CANCEL); + } else if (!open) { + connection.removeStream(id); + } + } + + /** + * A sink that writes outgoing data frames of a stream. This class is not thread safe. + */ + final class FramedDataSink implements Sink { + private static final long EMIT_BUFFER_SIZE = 16384; + + /** + * Buffer of outgoing data. This batches writes of small writes into this sink as larges frames + * written to the outgoing connection. Batching saves the (small) framing overhead. + */ + private final Buffer sendBuffer = new Buffer(); + + private boolean closed; + + /** + * True if either side has cleanly shut down this stream. We shall send no more bytes. + */ + private boolean finished; + + @Override public void write(Buffer source, long byteCount) throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + sendBuffer.write(source, byteCount); + while (sendBuffer.size() >= EMIT_BUFFER_SIZE) { + emitDataFrame(false); + } + } + + /** + * Emit a single data frame to the connection. The frame's size be limited by this stream's + * write window. This method will block until the write window is nonempty. + */ + private void emitDataFrame(boolean outFinished) throws IOException { + long toWrite; + synchronized (FramedStream.this) { + writeTimeout.enter(); + try { + while (bytesLeftInWriteWindow <= 0 && !finished && !closed && errorCode == null) { + waitForIo(); // Wait until we receive a WINDOW_UPDATE for this stream. + } + } finally { + writeTimeout.exitAndThrowIfTimedOut(); + } + + checkOutNotClosed(); // Kick out if the stream was reset or closed while waiting. + toWrite = Math.min(bytesLeftInWriteWindow, sendBuffer.size()); + bytesLeftInWriteWindow -= toWrite; + } + + writeTimeout.enter(); + try { + connection.writeData(id, outFinished && toWrite == sendBuffer.size(), sendBuffer, toWrite); + } finally { + writeTimeout.exitAndThrowIfTimedOut(); + } + } + + @Override public void flush() throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + synchronized (FramedStream.this) { + checkOutNotClosed(); + } + while (sendBuffer.size() > 0) { + emitDataFrame(false); + connection.flush(); + } + } + + @Override public Timeout timeout() { + return writeTimeout; + } + + @Override public void close() throws IOException { + assert (!Thread.holdsLock(FramedStream.this)); + synchronized (FramedStream.this) { + if (closed) return; + } + if (!sink.finished) { + // Emit the remaining data, setting the END_STREAM flag on the last frame. + if (sendBuffer.size() > 0) { + while (sendBuffer.size() > 0) { + emitDataFrame(true); + } + } else { + // Send an empty frame just so we can set the END_STREAM flag. + connection.writeData(id, true, null, 0); + } + } + synchronized (FramedStream.this) { + closed = true; + } + connection.flush(); + cancelStreamIfNecessary(); + } + } + + /** + * {@code delta} will be negative if a settings frame initial window is smaller than the last. + */ + void addBytesToWriteWindow(long delta) { + bytesLeftInWriteWindow += delta; + if (delta > 0) FramedStream.this.notifyAll(); + } + + private void checkOutNotClosed() throws IOException { + if (sink.closed) { + throw new IOException("stream closed"); + } else if (sink.finished) { + throw new IOException("stream finished"); + } else if (errorCode != null) { + throw new IOException("stream was reset: " + errorCode); + } + } + + /** + * Like {@link #wait}, but throws an {@code InterruptedIOException} when interrupted instead of + * the more awkward {@link InterruptedException}. + */ + private void waitForIo() throws InterruptedIOException { + try { + wait(); + } catch (InterruptedException e) { + throw new InterruptedIOException(); + } + } + + /** + * The Okio timeout watchdog will call {@link #timedOut} if the timeout is reached. In that case + * we close the stream (asynchronously) which will notify the waiting thread. + */ + class StreamTimeout extends AsyncTimeout { + @Override protected void timedOut() { + closeLater(ErrorCode.CANCEL); + } + + @Override protected IOException newTimeoutException(IOException cause) { + SocketTimeoutException socketTimeoutException = new SocketTimeoutException("timeout"); + if (cause != null) { + socketTimeoutException.initCause(cause); + } + return socketTimeoutException; + } + + public void exitAndThrowIfTimedOut() throws IOException { + if (exit()) throw newTimeoutException(null /* cause */); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Header.java b/okhttp3/src/main/java/okhttp3/internal/framed/Header.java new file mode 100644 index 000000000..38ac6a686 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Header.java @@ -0,0 +1,56 @@ +package okhttp3.internal.framed; + +import okio.ByteString; + +/** HTTP header: the name is an ASCII string, but the value can be UTF-8. */ +public final class Header { + // Special header names defined in the SPDY and HTTP/2 specs. + public static final ByteString RESPONSE_STATUS = ByteString.encodeUtf8(":status"); + public static final ByteString TARGET_METHOD = ByteString.encodeUtf8(":method"); + public static final ByteString TARGET_PATH = ByteString.encodeUtf8(":path"); + public static final ByteString TARGET_SCHEME = ByteString.encodeUtf8(":scheme"); + public static final ByteString TARGET_AUTHORITY = ByteString.encodeUtf8(":authority"); // HTTP/2 + public static final ByteString TARGET_HOST = ByteString.encodeUtf8(":host"); // spdy/3 + public static final ByteString VERSION = ByteString.encodeUtf8(":version"); // spdy/3 + + /** Name in case-insensitive ASCII encoding. */ + public final ByteString name; + /** Value in UTF-8 encoding. */ + public final ByteString value; + final int hpackSize; + + // TODO: search for toLowerCase and consider moving logic here. + public Header(String name, String value) { + this(ByteString.encodeUtf8(name), ByteString.encodeUtf8(value)); + } + + public Header(ByteString name, String value) { + this(name, ByteString.encodeUtf8(value)); + } + + public Header(ByteString name, ByteString value) { + this.name = name; + this.value = value; + this.hpackSize = 32 + name.size() + value.size(); + } + + @Override public boolean equals(Object other) { + if (other instanceof Header) { + Header that = (Header) other; + return this.name.equals(that.name) + && this.value.equals(that.value); + } + return false; + } + + @Override public int hashCode() { + int result = 17; + result = 31 * result + name.hashCode(); + result = 31 * result + value.hashCode(); + return result; + } + + @Override public String toString() { + return String.format("%s: %s", name.utf8(), value.utf8()); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/HeadersMode.java b/okhttp3/src/main/java/okhttp3/internal/framed/HeadersMode.java new file mode 100644 index 000000000..bc851ed78 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/HeadersMode.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +public enum HeadersMode { + SPDY_SYN_STREAM, + SPDY_REPLY, + SPDY_HEADERS, + HTTP_20_HEADERS; + + /** Returns true if it is an error these headers to create a new stream. */ + public boolean failIfStreamAbsent() { + return this == SPDY_REPLY || this == SPDY_HEADERS; + } + + /** Returns true if it is an error these headers to update an existing stream. */ + public boolean failIfStreamPresent() { + return this == SPDY_SYN_STREAM; + } + + /** + * Returns true if it is an error these headers to be the initial headers of a response. + */ + public boolean failIfHeadersAbsent() { + return this == SPDY_HEADERS; + } + + /** + * Returns true if it is an error these headers to be update existing headers of a response. + */ + public boolean failIfHeadersPresent() { + return this == SPDY_REPLY; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Hpack.java b/okhttp3/src/main/java/okhttp3/internal/framed/Hpack.java new file mode 100644 index 000000000..2b49784e9 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Hpack.java @@ -0,0 +1,435 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import okio.Buffer; +import okio.BufferedSource; +import okio.ByteString; +import okio.Okio; +import okio.Source; + +/** + * Read and write HPACK v10. + * + * http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12 + * + * This implementation uses an array for the dynamic table and a list for indexed entries. Dynamic + * entries are added to the array, starting in the last position moving forward. When the array + * fills, it is doubled. + */ +final class Hpack { + private static final int PREFIX_4_BITS = 0x0f; + private static final int PREFIX_5_BITS = 0x1f; + private static final int PREFIX_6_BITS = 0x3f; + private static final int PREFIX_7_BITS = 0x7f; + + private static final Header[] STATIC_HEADER_TABLE = new Header[] { + new Header(Header.TARGET_AUTHORITY, ""), + new Header(Header.TARGET_METHOD, "GET"), + new Header(Header.TARGET_METHOD, "POST"), + new Header(Header.TARGET_PATH, "/"), + new Header(Header.TARGET_PATH, "/index.html"), + new Header(Header.TARGET_SCHEME, "http"), + new Header(Header.TARGET_SCHEME, "https"), + new Header(Header.RESPONSE_STATUS, "200"), + new Header(Header.RESPONSE_STATUS, "204"), + new Header(Header.RESPONSE_STATUS, "206"), + new Header(Header.RESPONSE_STATUS, "304"), + new Header(Header.RESPONSE_STATUS, "400"), + new Header(Header.RESPONSE_STATUS, "404"), + new Header(Header.RESPONSE_STATUS, "500"), + new Header("accept-charset", ""), + new Header("accept-encoding", "gzip, deflate"), + new Header("accept-language", ""), + new Header("accept-ranges", ""), + new Header("accept", ""), + new Header("access-control-allow-origin", ""), + new Header("age", ""), + new Header("allow", ""), + new Header("authorization", ""), + new Header("cache-control", ""), + new Header("content-disposition", ""), + new Header("content-encoding", ""), + new Header("content-language", ""), + new Header("content-length", ""), + new Header("content-location", ""), + new Header("content-range", ""), + new Header("content-type", ""), + new Header("cookie", ""), + new Header("date", ""), + new Header("etag", ""), + new Header("expect", ""), + new Header("expires", ""), + new Header("from", ""), + new Header("host", ""), + new Header("if-match", ""), + new Header("if-modified-since", ""), + new Header("if-none-match", ""), + new Header("if-range", ""), + new Header("if-unmodified-since", ""), + new Header("last-modified", ""), + new Header("link", ""), + new Header("location", ""), + new Header("max-forwards", ""), + new Header("proxy-authenticate", ""), + new Header("proxy-authorization", ""), + new Header("range", ""), + new Header("referer", ""), + new Header("refresh", ""), + new Header("retry-after", ""), + new Header("server", ""), + new Header("set-cookie", ""), + new Header("strict-transport-security", ""), + new Header("transfer-encoding", ""), + new Header("user-agent", ""), + new Header("vary", ""), + new Header("via", ""), + new Header("www-authenticate", "") + }; + + private Hpack() { + } + + // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12#section-3.1 + static final class Reader { + + private final List
headerList = new ArrayList<>(); + private final BufferedSource source; + + private int headerTableSizeSetting; + private int maxDynamicTableByteCount; + // Visible for testing. + Header[] dynamicTable = new Header[8]; + // Array is populated back to front, so new entries always have lowest index. + int nextHeaderIndex = dynamicTable.length - 1; + int headerCount = 0; + int dynamicTableByteCount = 0; + + Reader(int headerTableSizeSetting, Source source) { + this.headerTableSizeSetting = headerTableSizeSetting; + this.maxDynamicTableByteCount = headerTableSizeSetting; + this.source = Okio.buffer(source); + } + + int maxDynamicTableByteCount() { + return maxDynamicTableByteCount; + } + + /** + * Called by the reader when the peer sent {@link Settings#HEADER_TABLE_SIZE}. While this + * establishes the maximum dynamic table size, the {@link #maxDynamicTableByteCount} set during + * processing may limit the table size to a smaller amount. + * + *

Evicts entries or clears the table as needed. + */ + void headerTableSizeSetting(int headerTableSizeSetting) { + this.headerTableSizeSetting = headerTableSizeSetting; + this.maxDynamicTableByteCount = headerTableSizeSetting; + adjustDynamicTableByteCount(); + } + + private void adjustDynamicTableByteCount() { + if (maxDynamicTableByteCount < dynamicTableByteCount) { + if (maxDynamicTableByteCount == 0) { + clearDynamicTable(); + } else { + evictToRecoverBytes(dynamicTableByteCount - maxDynamicTableByteCount); + } + } + } + + private void clearDynamicTable() { + headerList.clear(); + Arrays.fill(dynamicTable, null); + nextHeaderIndex = dynamicTable.length - 1; + headerCount = 0; + dynamicTableByteCount = 0; + } + + /** Returns the count of entries evicted. */ + private int evictToRecoverBytes(int bytesToRecover) { + int entriesToEvict = 0; + if (bytesToRecover > 0) { + // determine how many headers need to be evicted. + for (int j = dynamicTable.length - 1; j >= nextHeaderIndex && bytesToRecover > 0; j--) { + bytesToRecover -= dynamicTable[j].hpackSize; + dynamicTableByteCount -= dynamicTable[j].hpackSize; + headerCount--; + entriesToEvict++; + } + System.arraycopy(dynamicTable, nextHeaderIndex + 1, dynamicTable, + nextHeaderIndex + 1 + entriesToEvict, headerCount); + nextHeaderIndex += entriesToEvict; + } + return entriesToEvict; + } + + /** + * Read {@code byteCount} bytes of headers from the source stream. This implementation does not + * propagate the never indexed flag of a header. + */ + void readHeaders() throws IOException { + while (!source.exhausted()) { + int b = source.readByte() & 0xff; + if (b == 0x80) { // 10000000 + throw new IOException("index == 0"); + } else if ((b & 0x80) == 0x80) { // 1NNNNNNN + int index = readInt(b, PREFIX_7_BITS); + readIndexedHeader(index - 1); + } else if (b == 0x40) { // 01000000 + readLiteralHeaderWithIncrementalIndexingNewName(); + } else if ((b & 0x40) == 0x40) { // 01NNNNNN + int index = readInt(b, PREFIX_6_BITS); + readLiteralHeaderWithIncrementalIndexingIndexedName(index - 1); + } else if ((b & 0x20) == 0x20) { // 001NNNNN + maxDynamicTableByteCount = readInt(b, PREFIX_5_BITS); + if (maxDynamicTableByteCount < 0 + || maxDynamicTableByteCount > headerTableSizeSetting) { + throw new IOException("Invalid dynamic table size update " + maxDynamicTableByteCount); + } + adjustDynamicTableByteCount(); + } else if (b == 0x10 || b == 0) { // 000?0000 - Ignore never indexed bit. + readLiteralHeaderWithoutIndexingNewName(); + } else { // 000?NNNN - Ignore never indexed bit. + int index = readInt(b, PREFIX_4_BITS); + readLiteralHeaderWithoutIndexingIndexedName(index - 1); + } + } + } + + public List

getAndResetHeaderList() { + List
result = new ArrayList<>(headerList); + headerList.clear(); + return result; + } + + private void readIndexedHeader(int index) throws IOException { + if (isStaticHeader(index)) { + Header staticEntry = STATIC_HEADER_TABLE[index]; + headerList.add(staticEntry); + } else { + int dynamicTableIndex = dynamicTableIndex(index - STATIC_HEADER_TABLE.length); + if (dynamicTableIndex < 0 || dynamicTableIndex > dynamicTable.length - 1) { + throw new IOException("Header index too large " + (index + 1)); + } + headerList.add(dynamicTable[dynamicTableIndex]); + } + } + + // referencedHeaders is relative to nextHeaderIndex + 1. + private int dynamicTableIndex(int index) { + return nextHeaderIndex + 1 + index; + } + + private void readLiteralHeaderWithoutIndexingIndexedName(int index) throws IOException { + ByteString name = getName(index); + ByteString value = readByteString(); + headerList.add(new Header(name, value)); + } + + private void readLiteralHeaderWithoutIndexingNewName() throws IOException { + ByteString name = checkLowercase(readByteString()); + ByteString value = readByteString(); + headerList.add(new Header(name, value)); + } + + private void readLiteralHeaderWithIncrementalIndexingIndexedName(int nameIndex) + throws IOException { + ByteString name = getName(nameIndex); + ByteString value = readByteString(); + insertIntoDynamicTable(-1, new Header(name, value)); + } + + private void readLiteralHeaderWithIncrementalIndexingNewName() throws IOException { + ByteString name = checkLowercase(readByteString()); + ByteString value = readByteString(); + insertIntoDynamicTable(-1, new Header(name, value)); + } + + private ByteString getName(int index) { + if (isStaticHeader(index)) { + return STATIC_HEADER_TABLE[index].name; + } else { + return dynamicTable[dynamicTableIndex(index - STATIC_HEADER_TABLE.length)].name; + } + } + + private boolean isStaticHeader(int index) { + return index >= 0 && index <= STATIC_HEADER_TABLE.length - 1; + } + + /** index == -1 when new. */ + private void insertIntoDynamicTable(int index, Header entry) { + headerList.add(entry); + + int delta = entry.hpackSize; + if (index != -1) { // Index -1 == new header. + delta -= dynamicTable[dynamicTableIndex(index)].hpackSize; + } + + // if the new or replacement header is too big, drop all entries. + if (delta > maxDynamicTableByteCount) { + clearDynamicTable(); + return; + } + + // Evict headers to the required length. + int bytesToRecover = (dynamicTableByteCount + delta) - maxDynamicTableByteCount; + int entriesEvicted = evictToRecoverBytes(bytesToRecover); + + if (index == -1) { // Adding a value to the dynamic table. + if (headerCount + 1 > dynamicTable.length) { // Need to grow the dynamic table. + Header[] doubled = new Header[dynamicTable.length * 2]; + System.arraycopy(dynamicTable, 0, doubled, dynamicTable.length, dynamicTable.length); + nextHeaderIndex = dynamicTable.length - 1; + dynamicTable = doubled; + } + index = nextHeaderIndex--; + dynamicTable[index] = entry; + headerCount++; + } else { // Replace value at same position. + index += dynamicTableIndex(index) + entriesEvicted; + dynamicTable[index] = entry; + } + dynamicTableByteCount += delta; + } + + private int readByte() throws IOException { + return source.readByte() & 0xff; + } + + int readInt(int firstByte, int prefixMask) throws IOException { + int prefix = firstByte & prefixMask; + if (prefix < prefixMask) { + return prefix; // This was a single byte value. + } + + // This is a multibyte value. Read 7 bits at a time. + int result = prefixMask; + int shift = 0; + while (true) { + int b = readByte(); + if ((b & 0x80) != 0) { // Equivalent to (b >= 128) since b is in [0..255]. + result += (b & 0x7f) << shift; + shift += 7; + } else { + result += b << shift; // Last byte. + break; + } + } + return result; + } + + /** Reads a potentially Huffman encoded byte string. */ + ByteString readByteString() throws IOException { + int firstByte = readByte(); + boolean huffmanDecode = (firstByte & 0x80) == 0x80; // 1NNNNNNN + int length = readInt(firstByte, PREFIX_7_BITS); + + if (huffmanDecode) { + return ByteString.of(Huffman.get().decode(source.readByteArray(length))); + } else { + return source.readByteString(length); + } + } + } + + private static final Map NAME_TO_FIRST_INDEX = nameToFirstIndex(); + + private static Map nameToFirstIndex() { + Map result = new LinkedHashMap<>(STATIC_HEADER_TABLE.length); + for (int i = 0; i < STATIC_HEADER_TABLE.length; i++) { + if (!result.containsKey(STATIC_HEADER_TABLE[i].name)) { + result.put(STATIC_HEADER_TABLE[i].name, i); + } + } + return Collections.unmodifiableMap(result); + } + + static final class Writer { + private final Buffer out; + + Writer(Buffer out) { + this.out = out; + } + + /** This does not use "never indexed" semantics for sensitive headers. */ + // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12#section-6.2.3 + void writeHeaders(List
headerBlock) throws IOException { + // TODO: implement index tracking + for (int i = 0, size = headerBlock.size(); i < size; i++) { + ByteString name = headerBlock.get(i).name.toAsciiLowercase(); + Integer staticIndex = NAME_TO_FIRST_INDEX.get(name); + if (staticIndex != null) { + // Literal Header Field without Indexing - Indexed Name. + writeInt(staticIndex + 1, PREFIX_4_BITS, 0); + writeByteString(headerBlock.get(i).value); + } else { + out.writeByte(0x00); // Literal Header without Indexing - New Name. + writeByteString(name); + writeByteString(headerBlock.get(i).value); + } + } + } + + // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12#section-4.1.1 + void writeInt(int value, int prefixMask, int bits) throws IOException { + // Write the raw value for a single byte value. + if (value < prefixMask) { + out.writeByte(bits | value); + return; + } + + // Write the mask to start a multibyte value. + out.writeByte(bits | prefixMask); + value -= prefixMask; + + // Write 7 bits at a time 'til we're done. + while (value >= 0x80) { + int b = value & 0x7f; + out.writeByte(b | 0x80); + value >>>= 7; + } + out.writeByte(value); + } + + void writeByteString(ByteString data) throws IOException { + writeInt(data.size(), PREFIX_7_BITS, 0); + out.write(data); + } + } + + /** + * An HTTP/2 response cannot contain uppercase header characters and must be treated as + * malformed. + */ + private static ByteString checkLowercase(ByteString name) throws IOException { + for (int i = 0, length = name.size(); i < length; i++) { + byte c = name.getByte(i); + if (c >= 'A' && c <= 'Z') { + throw new IOException("PROTOCOL_ERROR response malformed: mixed case name: " + name.utf8()); + } + } + return name; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Http2.java b/okhttp3/src/main/java/okhttp3/internal/framed/Http2.java new file mode 100644 index 000000000..78ca9ce79 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Http2.java @@ -0,0 +1,770 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.IOException; +import java.util.List; +import java.util.logging.Logger; +import okhttp3.Protocol; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.ByteString; +import okio.Source; +import okio.Timeout; + +import static java.lang.String.format; +import static java.util.logging.Level.FINE; +import static okhttp3.internal.framed.Http2.FrameLogger.formatHeader; +import static okio.ByteString.EMPTY; + +/** + * Read and write HTTP/2 frames. + * + *

This implementation assumes we do not send an increased {@link Settings#getMaxFrameSize frame + * size setting} to the peer. Hence, we expect all frames to have a max length of {@link + * #INITIAL_MAX_FRAME_SIZE}.

http://tools.ietf.org/html/draft-ietf-httpbis-http2-17 + */ +public final class Http2 implements Variant { + private static final Logger logger = Logger.getLogger(FrameLogger.class.getName()); + + @Override public Protocol getProtocol() { + return Protocol.HTTP_2; + } + + private static final ByteString CONNECTION_PREFACE + = ByteString.encodeUtf8("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + + /** The initial max frame size, applied independently writing to, or reading from the peer. */ + static final int INITIAL_MAX_FRAME_SIZE = 0x4000; // 16384 + + static final byte TYPE_DATA = 0x0; + static final byte TYPE_HEADERS = 0x1; + static final byte TYPE_PRIORITY = 0x2; + static final byte TYPE_RST_STREAM = 0x3; + static final byte TYPE_SETTINGS = 0x4; + static final byte TYPE_PUSH_PROMISE = 0x5; + static final byte TYPE_PING = 0x6; + static final byte TYPE_GOAWAY = 0x7; + static final byte TYPE_WINDOW_UPDATE = 0x8; + static final byte TYPE_CONTINUATION = 0x9; + + static final byte FLAG_NONE = 0x0; + static final byte FLAG_ACK = 0x1; // Used for settings and ping. + static final byte FLAG_END_STREAM = 0x1; // Used for headers and data. + static final byte FLAG_END_HEADERS = 0x4; // Used for headers and continuation. + static final byte FLAG_END_PUSH_PROMISE = 0x4; + static final byte FLAG_PADDED = 0x8; // Used for headers and data. + static final byte FLAG_PRIORITY = 0x20; // Used for headers. + static final byte FLAG_COMPRESSED = 0x20; // Used for data. + + /** + * Creates a frame reader with max header table size of 4096 and data frame compression disabled. + */ + @Override public FrameReader newReader(BufferedSource source, boolean client) { + return new Reader(source, 4096, client); + } + + @Override public FrameWriter newWriter(BufferedSink sink, boolean client) { + return new Writer(sink, client); + } + + static final class Reader implements FrameReader { + private final BufferedSource source; + private final ContinuationSource continuation; + private final boolean client; + + // Visible for testing. + final Hpack.Reader hpackReader; + + Reader(BufferedSource source, int headerTableSize, boolean client) { + this.source = source; + this.client = client; + this.continuation = new ContinuationSource(this.source); + this.hpackReader = new Hpack.Reader(headerTableSize, continuation); + } + + @Override public void readConnectionPreface() throws IOException { + if (client) return; // Nothing to read; servers doesn't send a connection preface! + ByteString connectionPreface = source.readByteString(CONNECTION_PREFACE.size()); + if (logger.isLoggable(FINE)) logger.fine(format("<< CONNECTION %s", connectionPreface.hex())); + if (!CONNECTION_PREFACE.equals(connectionPreface)) { + throw ioException("Expected a connection header but was %s", connectionPreface.utf8()); + } + } + + @Override public boolean nextFrame(Handler handler) throws IOException { + try { + source.require(9); // Frame header size + } catch (IOException e) { + return false; // This might be a normal socket close. + } + + /* 0 1 2 3 + * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + * | Length (24) | + * +---------------+---------------+---------------+ + * | Type (8) | Flags (8) | + * +-+-+-----------+---------------+-------------------------------+ + * |R| Stream Identifier (31) | + * +=+=============================================================+ + * | Frame Payload (0...) ... + * +---------------------------------------------------------------+ + */ + int length = readMedium(source); + if (length < 0 || length > INITIAL_MAX_FRAME_SIZE) { + throw ioException("FRAME_SIZE_ERROR: %s", length); + } + byte type = (byte) (source.readByte() & 0xff); + byte flags = (byte) (source.readByte() & 0xff); + int streamId = (source.readInt() & 0x7fffffff); // Ignore reserved bit. + if (logger.isLoggable(FINE)) logger.fine(formatHeader(true, streamId, length, type, flags)); + + switch (type) { + case TYPE_DATA: + readData(handler, length, flags, streamId); + break; + + case TYPE_HEADERS: + readHeaders(handler, length, flags, streamId); + break; + + case TYPE_PRIORITY: + readPriority(handler, length, flags, streamId); + break; + + case TYPE_RST_STREAM: + readRstStream(handler, length, flags, streamId); + break; + + case TYPE_SETTINGS: + readSettings(handler, length, flags, streamId); + break; + + case TYPE_PUSH_PROMISE: + readPushPromise(handler, length, flags, streamId); + break; + + case TYPE_PING: + readPing(handler, length, flags, streamId); + break; + + case TYPE_GOAWAY: + readGoAway(handler, length, flags, streamId); + break; + + case TYPE_WINDOW_UPDATE: + readWindowUpdate(handler, length, flags, streamId); + break; + + default: + // Implementations MUST discard frames that have unknown or unsupported types. + source.skip(length); + } + return true; + } + + private void readHeaders(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (streamId == 0) throw ioException("PROTOCOL_ERROR: TYPE_HEADERS streamId == 0"); + + boolean endStream = (flags & FLAG_END_STREAM) != 0; + + short padding = (flags & FLAG_PADDED) != 0 ? (short) (source.readByte() & 0xff) : 0; + + if ((flags & FLAG_PRIORITY) != 0) { + readPriority(handler, streamId); + length -= 5; // account for above read. + } + + length = lengthWithoutPadding(length, flags, padding); + + List

headerBlock = readHeaderBlock(length, padding, flags, streamId); + + handler.headers(false, endStream, streamId, -1, headerBlock, HeadersMode.HTTP_20_HEADERS); + } + + private List
readHeaderBlock(int length, short padding, byte flags, int streamId) + throws IOException { + continuation.length = continuation.left = length; + continuation.padding = padding; + continuation.flags = flags; + continuation.streamId = streamId; + + // TODO: Concat multi-value headers with 0x0, except COOKIE, which uses 0x3B, 0x20. + // http://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-8.1.2.5 + hpackReader.readHeaders(); + return hpackReader.getAndResetHeaderList(); + } + + private void readData(Handler handler, int length, byte flags, int streamId) + throws IOException { + // TODO: checkState open or half-closed (local) or raise STREAM_CLOSED + boolean inFinished = (flags & FLAG_END_STREAM) != 0; + boolean gzipped = (flags & FLAG_COMPRESSED) != 0; + if (gzipped) { + throw ioException("PROTOCOL_ERROR: FLAG_COMPRESSED without SETTINGS_COMPRESS_DATA"); + } + + short padding = (flags & FLAG_PADDED) != 0 ? (short) (source.readByte() & 0xff) : 0; + length = lengthWithoutPadding(length, flags, padding); + + handler.data(inFinished, streamId, source, length); + source.skip(padding); + } + + private void readPriority(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (length != 5) throw ioException("TYPE_PRIORITY length: %d != 5", length); + if (streamId == 0) throw ioException("TYPE_PRIORITY streamId == 0"); + readPriority(handler, streamId); + } + + private void readPriority(Handler handler, int streamId) throws IOException { + int w1 = source.readInt(); + boolean exclusive = (w1 & 0x80000000) != 0; + int streamDependency = (w1 & 0x7fffffff); + int weight = (source.readByte() & 0xff) + 1; + handler.priority(streamId, streamDependency, weight, exclusive); + } + + private void readRstStream(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (length != 4) throw ioException("TYPE_RST_STREAM length: %d != 4", length); + if (streamId == 0) throw ioException("TYPE_RST_STREAM streamId == 0"); + int errorCodeInt = source.readInt(); + ErrorCode errorCode = ErrorCode.fromHttp2(errorCodeInt); + if (errorCode == null) { + throw ioException("TYPE_RST_STREAM unexpected error code: %d", errorCodeInt); + } + handler.rstStream(streamId, errorCode); + } + + private void readSettings(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (streamId != 0) throw ioException("TYPE_SETTINGS streamId != 0"); + if ((flags & FLAG_ACK) != 0) { + if (length != 0) throw ioException("FRAME_SIZE_ERROR ack frame should be empty!"); + handler.ackSettings(); + return; + } + + if (length % 6 != 0) throw ioException("TYPE_SETTINGS length %% 6 != 0: %s", length); + Settings settings = new Settings(); + for (int i = 0; i < length; i += 6) { + short id = source.readShort(); + int value = source.readInt(); + + switch (id) { + case 1: // SETTINGS_HEADER_TABLE_SIZE + break; + case 2: // SETTINGS_ENABLE_PUSH + if (value != 0 && value != 1) { + throw ioException("PROTOCOL_ERROR SETTINGS_ENABLE_PUSH != 0 or 1"); + } + break; + case 3: // SETTINGS_MAX_CONCURRENT_STREAMS + id = 4; // Renumbered in draft 10. + break; + case 4: // SETTINGS_INITIAL_WINDOW_SIZE + id = 7; // Renumbered in draft 10. + if (value < 0) { + throw ioException("PROTOCOL_ERROR SETTINGS_INITIAL_WINDOW_SIZE > 2^31 - 1"); + } + break; + case 5: // SETTINGS_MAX_FRAME_SIZE + if (value < INITIAL_MAX_FRAME_SIZE || value > 16777215) { + throw ioException("PROTOCOL_ERROR SETTINGS_MAX_FRAME_SIZE: %s", value); + } + break; + case 6: // SETTINGS_MAX_HEADER_LIST_SIZE + break; // Advisory only, so ignored. + default: + throw ioException("PROTOCOL_ERROR invalid settings id: %s", id); + } + settings.set(id, 0, value); + } + handler.settings(false, settings); + if (settings.getHeaderTableSize() >= 0) { + hpackReader.headerTableSizeSetting(settings.getHeaderTableSize()); + } + } + + private void readPushPromise(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (streamId == 0) { + throw ioException("PROTOCOL_ERROR: TYPE_PUSH_PROMISE streamId == 0"); + } + short padding = (flags & FLAG_PADDED) != 0 ? (short) (source.readByte() & 0xff) : 0; + int promisedStreamId = source.readInt() & 0x7fffffff; + length -= 4; // account for above read. + length = lengthWithoutPadding(length, flags, padding); + List
headerBlock = readHeaderBlock(length, padding, flags, streamId); + handler.pushPromise(streamId, promisedStreamId, headerBlock); + } + + private void readPing(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (length != 8) throw ioException("TYPE_PING length != 8: %s", length); + if (streamId != 0) throw ioException("TYPE_PING streamId != 0"); + int payload1 = source.readInt(); + int payload2 = source.readInt(); + boolean ack = (flags & FLAG_ACK) != 0; + handler.ping(ack, payload1, payload2); + } + + private void readGoAway(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (length < 8) throw ioException("TYPE_GOAWAY length < 8: %s", length); + if (streamId != 0) throw ioException("TYPE_GOAWAY streamId != 0"); + int lastStreamId = source.readInt(); + int errorCodeInt = source.readInt(); + int opaqueDataLength = length - 8; + ErrorCode errorCode = ErrorCode.fromHttp2(errorCodeInt); + if (errorCode == null) { + throw ioException("TYPE_GOAWAY unexpected error code: %d", errorCodeInt); + } + ByteString debugData = EMPTY; + if (opaqueDataLength > 0) { // Must read debug data in order to not corrupt the connection. + debugData = source.readByteString(opaqueDataLength); + } + handler.goAway(lastStreamId, errorCode, debugData); + } + + private void readWindowUpdate(Handler handler, int length, byte flags, int streamId) + throws IOException { + if (length != 4) throw ioException("TYPE_WINDOW_UPDATE length !=4: %s", length); + long increment = (source.readInt() & 0x7fffffffL); + if (increment == 0) throw ioException("windowSizeIncrement was 0", increment); + handler.windowUpdate(streamId, increment); + } + + @Override public void close() throws IOException { + source.close(); + } + } + + static final class Writer implements FrameWriter { + private final BufferedSink sink; + private final boolean client; + private final Buffer hpackBuffer; + private final Hpack.Writer hpackWriter; + private int maxFrameSize; + private boolean closed; + + Writer(BufferedSink sink, boolean client) { + this.sink = sink; + this.client = client; + this.hpackBuffer = new Buffer(); + this.hpackWriter = new Hpack.Writer(hpackBuffer); + this.maxFrameSize = INITIAL_MAX_FRAME_SIZE; + } + + @Override public synchronized void flush() throws IOException { + if (closed) throw new IOException("closed"); + sink.flush(); + } + + @Override public synchronized void ackSettings(Settings peerSettings) throws IOException { + if (closed) throw new IOException("closed"); + this.maxFrameSize = peerSettings.getMaxFrameSize(maxFrameSize); + int length = 0; + byte type = TYPE_SETTINGS; + byte flags = FLAG_ACK; + int streamId = 0; + frameHeader(streamId, length, type, flags); + sink.flush(); + } + + @Override public synchronized void connectionPreface() throws IOException { + if (closed) throw new IOException("closed"); + if (!client) return; // Nothing to write; servers don't send connection headers! + if (logger.isLoggable(FINE)) { + logger.fine(format(">> CONNECTION %s", CONNECTION_PREFACE.hex())); + } + sink.write(CONNECTION_PREFACE.toByteArray()); + sink.flush(); + } + + @Override public synchronized void synStream(boolean outFinished, boolean inFinished, + int streamId, int associatedStreamId, List
headerBlock) + throws IOException { + if (inFinished) throw new UnsupportedOperationException(); + if (closed) throw new IOException("closed"); + headers(outFinished, streamId, headerBlock); + } + + @Override public synchronized void synReply(boolean outFinished, int streamId, + List
headerBlock) throws IOException { + if (closed) throw new IOException("closed"); + headers(outFinished, streamId, headerBlock); + } + + @Override public synchronized void headers(int streamId, List
headerBlock) + throws IOException { + if (closed) throw new IOException("closed"); + headers(false, streamId, headerBlock); + } + + @Override public synchronized void pushPromise(int streamId, int promisedStreamId, + List
requestHeaders) throws IOException { + if (closed) throw new IOException("closed"); + hpackWriter.writeHeaders(requestHeaders); + + long byteCount = hpackBuffer.size(); + int length = (int) Math.min(maxFrameSize - 4, byteCount); + byte type = TYPE_PUSH_PROMISE; + byte flags = byteCount == length ? FLAG_END_HEADERS : 0; + frameHeader(streamId, length + 4, type, flags); + sink.writeInt(promisedStreamId & 0x7fffffff); + sink.write(hpackBuffer, length); + + if (byteCount > length) writeContinuationFrames(streamId, byteCount - length); + } + + void headers(boolean outFinished, int streamId, List
headerBlock) throws IOException { + if (closed) throw new IOException("closed"); + hpackWriter.writeHeaders(headerBlock); + + long byteCount = hpackBuffer.size(); + int length = (int) Math.min(maxFrameSize, byteCount); + byte type = TYPE_HEADERS; + byte flags = byteCount == length ? FLAG_END_HEADERS : 0; + if (outFinished) flags |= FLAG_END_STREAM; + frameHeader(streamId, length, type, flags); + sink.write(hpackBuffer, length); + + if (byteCount > length) writeContinuationFrames(streamId, byteCount - length); + } + + private void writeContinuationFrames(int streamId, long byteCount) throws IOException { + while (byteCount > 0) { + int length = (int) Math.min(maxFrameSize, byteCount); + byteCount -= length; + frameHeader(streamId, length, TYPE_CONTINUATION, byteCount == 0 ? FLAG_END_HEADERS : 0); + sink.write(hpackBuffer, length); + } + } + + @Override public synchronized void rstStream(int streamId, ErrorCode errorCode) + throws IOException { + if (closed) throw new IOException("closed"); + if (errorCode.httpCode == -1) throw new IllegalArgumentException(); + + int length = 4; + byte type = TYPE_RST_STREAM; + byte flags = FLAG_NONE; + frameHeader(streamId, length, type, flags); + sink.writeInt(errorCode.httpCode); + sink.flush(); + } + + @Override public int maxDataLength() { + return maxFrameSize; + } + + @Override public synchronized void data(boolean outFinished, int streamId, Buffer source, + int byteCount) throws IOException { + if (closed) throw new IOException("closed"); + byte flags = FLAG_NONE; + if (outFinished) flags |= FLAG_END_STREAM; + dataFrame(streamId, flags, source, byteCount); + } + + void dataFrame(int streamId, byte flags, Buffer buffer, int byteCount) throws IOException { + byte type = TYPE_DATA; + frameHeader(streamId, byteCount, type, flags); + if (byteCount > 0) { + sink.write(buffer, byteCount); + } + } + + @Override public synchronized void settings(Settings settings) throws IOException { + if (closed) throw new IOException("closed"); + int length = settings.size() * 6; + byte type = TYPE_SETTINGS; + byte flags = FLAG_NONE; + int streamId = 0; + frameHeader(streamId, length, type, flags); + for (int i = 0; i < Settings.COUNT; i++) { + if (!settings.isSet(i)) continue; + int id = i; + if (id == 4) { + id = 3; // SETTINGS_MAX_CONCURRENT_STREAMS renumbered. + } else if (id == 7) { + id = 4; // SETTINGS_INITIAL_WINDOW_SIZE renumbered. + } + sink.writeShort(id); + sink.writeInt(settings.get(i)); + } + sink.flush(); + } + + @Override public synchronized void ping(boolean ack, int payload1, int payload2) + throws IOException { + if (closed) throw new IOException("closed"); + int length = 8; + byte type = TYPE_PING; + byte flags = ack ? FLAG_ACK : FLAG_NONE; + int streamId = 0; + frameHeader(streamId, length, type, flags); + sink.writeInt(payload1); + sink.writeInt(payload2); + sink.flush(); + } + + @Override public synchronized void goAway(int lastGoodStreamId, ErrorCode errorCode, + byte[] debugData) throws IOException { + if (closed) throw new IOException("closed"); + if (errorCode.httpCode == -1) throw illegalArgument("errorCode.httpCode == -1"); + int length = 8 + debugData.length; + byte type = TYPE_GOAWAY; + byte flags = FLAG_NONE; + int streamId = 0; + frameHeader(streamId, length, type, flags); + sink.writeInt(lastGoodStreamId); + sink.writeInt(errorCode.httpCode); + if (debugData.length > 0) { + sink.write(debugData); + } + sink.flush(); + } + + @Override public synchronized void windowUpdate(int streamId, long windowSizeIncrement) + throws IOException { + if (closed) throw new IOException("closed"); + if (windowSizeIncrement == 0 || windowSizeIncrement > 0x7fffffffL) { + throw illegalArgument("windowSizeIncrement == 0 || windowSizeIncrement > 0x7fffffffL: %s", + windowSizeIncrement); + } + int length = 4; + byte type = TYPE_WINDOW_UPDATE; + byte flags = FLAG_NONE; + frameHeader(streamId, length, type, flags); + sink.writeInt((int) windowSizeIncrement); + sink.flush(); + } + + @Override public synchronized void close() throws IOException { + closed = true; + sink.close(); + } + + void frameHeader(int streamId, int length, byte type, byte flags) throws IOException { + if (logger.isLoggable(FINE)) logger.fine(formatHeader(false, streamId, length, type, flags)); + if (length > maxFrameSize) { + throw illegalArgument("FRAME_SIZE_ERROR length > %d: %d", maxFrameSize, length); + } + if ((streamId & 0x80000000) != 0) throw illegalArgument("reserved bit set: %s", streamId); + writeMedium(sink, length); + sink.writeByte(type & 0xff); + sink.writeByte(flags & 0xff); + sink.writeInt(streamId & 0x7fffffff); + } + } + + private static IllegalArgumentException illegalArgument(String message, Object... args) { + throw new IllegalArgumentException(format(message, args)); + } + + private static IOException ioException(String message, Object... args) throws IOException { + throw new IOException(format(message, args)); + } + + /** + * Decompression of the header block occurs above the framing layer. This class lazily reads + * continuation frames as they are needed by {@link Hpack.Reader#readHeaders()}. + */ + static final class ContinuationSource implements Source { + private final BufferedSource source; + + int length; + byte flags; + int streamId; + + int left; + short padding; + + public ContinuationSource(BufferedSource source) { + this.source = source; + } + + @Override public long read(Buffer sink, long byteCount) throws IOException { + while (left == 0) { + source.skip(padding); + padding = 0; + if ((flags & FLAG_END_HEADERS) != 0) return -1; + readContinuationHeader(); + // TODO: test case for empty continuation header? + } + + long read = source.read(sink, Math.min(byteCount, left)); + if (read == -1) return -1; + left -= read; + return read; + } + + @Override public Timeout timeout() { + return source.timeout(); + } + + @Override public void close() throws IOException { + } + + private void readContinuationHeader() throws IOException { + int previousStreamId = streamId; + + length = left = readMedium(source); + byte type = (byte) (source.readByte() & 0xff); + flags = (byte) (source.readByte() & 0xff); + if (logger.isLoggable(FINE)) logger.fine(formatHeader(true, streamId, length, type, flags)); + streamId = (source.readInt() & 0x7fffffff); + if (type != TYPE_CONTINUATION) throw ioException("%s != TYPE_CONTINUATION", type); + if (streamId != previousStreamId) throw ioException("TYPE_CONTINUATION streamId changed"); + } + } + + private static int lengthWithoutPadding(int length, byte flags, short padding) + throws IOException { + if ((flags & FLAG_PADDED) != 0) length--; // Account for reading the padding length. + if (padding > length) { + throw ioException("PROTOCOL_ERROR padding %s > remaining length %s", padding, length); + } + return (short) (length - padding); + } + + /** + * Logs a human-readable representation of HTTP/2 frame headers. + * + *

The format is: + * + *

+   *   direction streamID length type flags
+   * 
+ * Where direction is {@code <<} for inbound and {@code >>} for outbound. + * + *

For example, the following would indicate a HEAD request sent from the client. + *

+   * {@code
+   *   << 0x0000000f    12 HEADERS       END_HEADERS|END_STREAM
+   * }
+   * 
+ */ + static final class FrameLogger { + + static String formatHeader(boolean inbound, int streamId, int length, byte type, byte flags) { + String formattedType = type < TYPES.length ? TYPES[type] : format("0x%02x", type); + String formattedFlags = formatFlags(type, flags); + return format("%s 0x%08x %5d %-13s %s", inbound ? "<<" : ">>", streamId, length, + formattedType, formattedFlags); + } + + /** + * Looks up valid string representing flags from the table. Invalid combinations are represented + * in binary. + */ + // Visible for testing. + static String formatFlags(byte type, byte flags) { + if (flags == 0) return ""; + switch (type) { // Special case types that have 0 or 1 flag. + case TYPE_SETTINGS: + case TYPE_PING: + return flags == FLAG_ACK ? "ACK" : BINARY[flags]; + case TYPE_PRIORITY: + case TYPE_RST_STREAM: + case TYPE_GOAWAY: + case TYPE_WINDOW_UPDATE: + return BINARY[flags]; + } + String result = flags < FLAGS.length ? FLAGS[flags] : BINARY[flags]; + // Special case types that have overlap flag values. + if (type == TYPE_PUSH_PROMISE && (flags & FLAG_END_PUSH_PROMISE) != 0) { + return result.replace("HEADERS", "PUSH_PROMISE"); // TODO: Avoid allocation. + } else if (type == TYPE_DATA && (flags & FLAG_COMPRESSED) != 0) { + return result.replace("PRIORITY", "COMPRESSED"); // TODO: Avoid allocation. + } + return result; + } + + /** Lookup table for valid frame types. */ + private static final String[] TYPES = new String[] { + "DATA", + "HEADERS", + "PRIORITY", + "RST_STREAM", + "SETTINGS", + "PUSH_PROMISE", + "PING", + "GOAWAY", + "WINDOW_UPDATE", + "CONTINUATION" + }; + + /** + * Lookup table for valid flags for DATA, HEADERS, CONTINUATION. Invalid combinations are + * represented in binary. + */ + private static final String[] FLAGS = new String[0x40]; // Highest bit flag is 0x20. + private static final String[] BINARY = new String[256]; + + static { + for (int i = 0; i < BINARY.length; i++) { + BINARY[i] = format("%8s", Integer.toBinaryString(i)).replace(' ', '0'); + } + + FLAGS[FLAG_NONE] = ""; + FLAGS[FLAG_END_STREAM] = "END_STREAM"; + + int[] prefixFlags = new int[] {FLAG_END_STREAM}; + + FLAGS[FLAG_PADDED] = "PADDED"; + for (int prefixFlag : prefixFlags) { + FLAGS[prefixFlag | FLAG_PADDED] = FLAGS[prefixFlag] + "|PADDED"; + } + + FLAGS[FLAG_END_HEADERS] = "END_HEADERS"; // Same as END_PUSH_PROMISE. + FLAGS[FLAG_PRIORITY] = "PRIORITY"; // Same as FLAG_COMPRESSED. + FLAGS[FLAG_END_HEADERS | FLAG_PRIORITY] = "END_HEADERS|PRIORITY"; // Only valid on HEADERS. + int[] frameFlags = + new int[] {FLAG_END_HEADERS, FLAG_PRIORITY, FLAG_END_HEADERS | FLAG_PRIORITY}; + + for (int frameFlag : frameFlags) { + for (int prefixFlag : prefixFlags) { + FLAGS[prefixFlag | frameFlag] = FLAGS[prefixFlag] + '|' + FLAGS[frameFlag]; + FLAGS[prefixFlag | frameFlag | FLAG_PADDED] = + FLAGS[prefixFlag] + '|' + FLAGS[frameFlag] + "|PADDED"; + } + } + + for (int i = 0; i < FLAGS.length; i++) { // Fill in holes with binary representation. + if (FLAGS[i] == null) FLAGS[i] = BINARY[i]; + } + } + } + + private static int readMedium(BufferedSource source) throws IOException { + return (source.readByte() & 0xff) << 16 + | (source.readByte() & 0xff) << 8 + | (source.readByte() & 0xff); + } + + private static void writeMedium(BufferedSink sink, int i) throws IOException { + sink.writeByte((i >>> 16) & 0xff); + sink.writeByte((i >>> 8) & 0xff); + sink.writeByte(i & 0xff); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Huffman.java b/okhttp3/src/main/java/okhttp3/internal/framed/Huffman.java new file mode 100644 index 000000000..0c7f3012a --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Huffman.java @@ -0,0 +1,226 @@ +/* + * Copyright 2013 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * This class was originally composed from the following classes in Twitter Hpack. + * + *
    + *
  • {@code com.twitter.hpack.HuffmanEncoder}
  • + *
  • {@code com.twitter.hpack.HuffmanDecoder}
  • + *
  • {@code com.twitter.hpack.HpackUtil}
  • + *
+ */ +class Huffman { + + // Appendix C: Huffman Codes + // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12#appendix-B + private static final int[] CODES = { + 0x1ff8, 0x7fffd8, 0xfffffe2, 0xfffffe3, 0xfffffe4, 0xfffffe5, 0xfffffe6, 0xfffffe7, 0xfffffe8, + 0xffffea, 0x3ffffffc, 0xfffffe9, 0xfffffea, 0x3ffffffd, 0xfffffeb, 0xfffffec, 0xfffffed, + 0xfffffee, 0xfffffef, 0xffffff0, 0xffffff1, 0xffffff2, 0x3ffffffe, 0xffffff3, 0xffffff4, + 0xffffff5, 0xffffff6, 0xffffff7, 0xffffff8, 0xffffff9, 0xffffffa, 0xffffffb, 0x14, 0x3f8, + 0x3f9, 0xffa, 0x1ff9, 0x15, 0xf8, 0x7fa, 0x3fa, 0x3fb, 0xf9, 0x7fb, 0xfa, 0x16, 0x17, 0x18, + 0x0, 0x1, 0x2, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x5c, 0xfb, 0x7ffc, 0x20, 0xffb, + 0x3fc, 0x1ffa, 0x21, 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0xfc, 0x73, 0xfd, 0x1ffb, 0x7fff0, + 0x1ffc, 0x3ffc, 0x22, 0x7ffd, 0x3, 0x23, 0x4, 0x24, 0x5, 0x25, 0x26, 0x27, 0x6, 0x74, 0x75, + 0x28, 0x29, 0x2a, 0x7, 0x2b, 0x76, 0x2c, 0x8, 0x9, 0x2d, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7ffe, + 0x7fc, 0x3ffd, 0x1ffd, 0xffffffc, 0xfffe6, 0x3fffd2, 0xfffe7, 0xfffe8, 0x3fffd3, 0x3fffd4, + 0x3fffd5, 0x7fffd9, 0x3fffd6, 0x7fffda, 0x7fffdb, 0x7fffdc, 0x7fffdd, 0x7fffde, 0xffffeb, + 0x7fffdf, 0xffffec, 0xffffed, 0x3fffd7, 0x7fffe0, 0xffffee, 0x7fffe1, 0x7fffe2, 0x7fffe3, + 0x7fffe4, 0x1fffdc, 0x3fffd8, 0x7fffe5, 0x3fffd9, 0x7fffe6, 0x7fffe7, 0xffffef, 0x3fffda, + 0x1fffdd, 0xfffe9, 0x3fffdb, 0x3fffdc, 0x7fffe8, 0x7fffe9, 0x1fffde, 0x7fffea, 0x3fffdd, + 0x3fffde, 0xfffff0, 0x1fffdf, 0x3fffdf, 0x7fffeb, 0x7fffec, 0x1fffe0, 0x1fffe1, 0x3fffe0, + 0x1fffe2, 0x7fffed, 0x3fffe1, 0x7fffee, 0x7fffef, 0xfffea, 0x3fffe2, 0x3fffe3, 0x3fffe4, + 0x7ffff0, 0x3fffe5, 0x3fffe6, 0x7ffff1, 0x3ffffe0, 0x3ffffe1, 0xfffeb, 0x7fff1, 0x3fffe7, + 0x7ffff2, 0x3fffe8, 0x1ffffec, 0x3ffffe2, 0x3ffffe3, 0x3ffffe4, 0x7ffffde, 0x7ffffdf, + 0x3ffffe5, 0xfffff1, 0x1ffffed, 0x7fff2, 0x1fffe3, 0x3ffffe6, 0x7ffffe0, 0x7ffffe1, 0x3ffffe7, + 0x7ffffe2, 0xfffff2, 0x1fffe4, 0x1fffe5, 0x3ffffe8, 0x3ffffe9, 0xffffffd, 0x7ffffe3, + 0x7ffffe4, 0x7ffffe5, 0xfffec, 0xfffff3, 0xfffed, 0x1fffe6, 0x3fffe9, 0x1fffe7, 0x1fffe8, + 0x7ffff3, 0x3fffea, 0x3fffeb, 0x1ffffee, 0x1ffffef, 0xfffff4, 0xfffff5, 0x3ffffea, 0x7ffff4, + 0x3ffffeb, 0x7ffffe6, 0x3ffffec, 0x3ffffed, 0x7ffffe7, 0x7ffffe8, 0x7ffffe9, 0x7ffffea, + 0x7ffffeb, 0xffffffe, 0x7ffffec, 0x7ffffed, 0x7ffffee, 0x7ffffef, 0x7fffff0, 0x3ffffee + }; + + private static final byte[] CODE_LENGTHS = { + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 30, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6, 5, + 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10, 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, 15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, + 6, 6, 5, 6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28, 20, 22, 20, 20, 22, 22, 22, 23, + 22, 23, 23, 23, 23, 23, 24, 23, 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, + 24, 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, 21, 21, 22, 21, 23, 22, + 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, 26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, + 26, 24, 25, 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27, 20, 24, 20, 21, + 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, + 27, 27, 27, 27, 26 + }; + + private static final Huffman INSTANCE = new Huffman(); + + public static Huffman get() { + return INSTANCE; + } + + private final Node root = new Node(); + + private Huffman() { + buildTree(); + } + + void encode(byte[] data, OutputStream out) throws IOException { + long current = 0; + int n = 0; + + for (int i = 0; i < data.length; i++) { + int b = data[i] & 0xFF; + int code = CODES[b]; + int nbits = CODE_LENGTHS[b]; + + current <<= nbits; + current |= code; + n += nbits; + + while (n >= 8) { + n -= 8; + out.write(((int) (current >> n))); + } + } + + if (n > 0) { + current <<= (8 - n); + current |= (0xFF >>> n); + out.write((int) current); + } + } + + int encodedLength(byte[] bytes) { + long len = 0; + + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i] & 0xFF; + len += CODE_LENGTHS[b]; + } + + return (int) ((len + 7) >> 3); + } + + byte[] decode(byte[] buf) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Node node = root; + int current = 0; + int nbits = 0; + for (int i = 0; i < buf.length; i++) { + int b = buf[i] & 0xFF; + current = (current << 8) | b; + nbits += 8; + while (nbits >= 8) { + int c = (current >>> (nbits - 8)) & 0xFF; + node = node.children[c]; + if (node.children == null) { + // terminal node + baos.write(node.symbol); + nbits -= node.terminalBits; + node = root; + } else { + // non-terminal node + nbits -= 8; + } + } + } + + while (nbits > 0) { + int c = (current << (8 - nbits)) & 0xFF; + node = node.children[c]; + if (node.children != null || node.terminalBits > nbits) { + break; + } + baos.write(node.symbol); + nbits -= node.terminalBits; + node = root; + } + + return baos.toByteArray(); + } + + private void buildTree() { + for (int i = 0; i < CODE_LENGTHS.length; i++) { + addCode(i, CODES[i], CODE_LENGTHS[i]); + } + } + + private void addCode(int sym, int code, byte len) { + Node terminal = new Node(sym, len); + + Node current = root; + while (len > 8) { + len -= 8; + int i = ((code >>> len) & 0xFF); + if (current.children == null) { + throw new IllegalStateException("invalid dictionary: prefix not unique"); + } + if (current.children[i] == null) { + current.children[i] = new Node(); + } + current = current.children[i]; + } + + int shift = 8 - len; + int start = (code << shift) & 0xFF; + int end = 1 << shift; + for (int i = start; i < start + end; i++) { + current.children[i] = terminal; + } + } + + private static final class Node { + + // Null if terminal. + private final Node[] children; + + // Terminal nodes have a symbol. + private final int symbol; + + // Number of bits represented in the terminal node. + private final int terminalBits; + + /** Construct an internal node. */ + Node() { + this.children = new Node[256]; + this.symbol = 0; // Not read. + this.terminalBits = 0; // Not read. + } + + /** + * Construct a terminal node. + * + * @param symbol symbol the node represents + * @param bits length of Huffman code in bits + */ + Node(int symbol, int bits) { + this.children = null; + this.symbol = symbol; + int b = bits & 0x07; + this.terminalBits = b == 0 ? 8 : b; + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/NameValueBlockReader.java b/okhttp3/src/main/java/okhttp3/internal/framed/NameValueBlockReader.java new file mode 100644 index 000000000..75cd9594f --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/NameValueBlockReader.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; +import okio.Buffer; +import okio.BufferedSource; +import okio.ByteString; +import okio.ForwardingSource; +import okio.InflaterSource; +import okio.Okio; +import okio.Source; + +/** + * Reads a SPDY/3 Name/Value header block. This class is made complicated by the requirement that + * we're strict with which bytes we put in the compressed bytes buffer. We need to put all + * compressed bytes into that buffer -- but no other bytes. + */ +class NameValueBlockReader { + /** This source transforms compressed bytes into uncompressed bytes. */ + private final InflaterSource inflaterSource; + + /** + * How many compressed bytes must be read into inflaterSource before {@link #readNameValueBlock} + * returns. + */ + private int compressedLimit; + + /** This source holds inflated bytes. */ + private final BufferedSource source; + + public NameValueBlockReader(BufferedSource source) { + // Limit the inflater input stream to only those bytes in the Name/Value + // block. We cut the inflater off at its source because we can't predict the + // ratio of compressed bytes to uncompressed bytes. + Source throttleSource = new ForwardingSource(source) { + @Override public long read(Buffer sink, long byteCount) throws IOException { + if (compressedLimit == 0) return -1; // Out of data for the current block. + long read = super.read(sink, Math.min(byteCount, compressedLimit)); + if (read == -1) return -1; + compressedLimit -= read; + return read; + } + }; + + // Subclass inflater to install a dictionary when it's needed. + Inflater inflater = new Inflater() { + @Override public int inflate(byte[] buffer, int offset, int count) + throws DataFormatException { + int result = super.inflate(buffer, offset, count); + if (result == 0 && needsDictionary()) { + setDictionary(Spdy3.DICTIONARY); + result = super.inflate(buffer, offset, count); + } + return result; + } + }; + + this.inflaterSource = new InflaterSource(throttleSource, inflater); + this.source = Okio.buffer(inflaterSource); + } + + public List
readNameValueBlock(int length) throws IOException { + this.compressedLimit += length; + + int numberOfPairs = source.readInt(); + if (numberOfPairs < 0) throw new IOException("numberOfPairs < 0: " + numberOfPairs); + if (numberOfPairs > 1024) throw new IOException("numberOfPairs > 1024: " + numberOfPairs); + + List
entries = new ArrayList<>(numberOfPairs); + for (int i = 0; i < numberOfPairs; i++) { + ByteString name = readByteString().toAsciiLowercase(); + ByteString values = readByteString(); + if (name.size() == 0) throw new IOException("name.size == 0"); + entries.add(new Header(name, values)); + } + + doneReading(); + return entries; + } + + private ByteString readByteString() throws IOException { + int length = source.readInt(); + return source.readByteString(length); + } + + private void doneReading() throws IOException { + // Move any outstanding unread bytes into the inflater. One side-effect of + // deflate compression is that sometimes there are bytes remaining in the + // stream after we've consumed all of the content. + if (compressedLimit > 0) { + inflaterSource.refill(); + if (compressedLimit != 0) throw new IOException("compressedLimit > 0: " + compressedLimit); + } + } + + public void close() throws IOException { + source.close(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Ping.java b/okhttp3/src/main/java/okhttp3/internal/framed/Ping.java new file mode 100644 index 000000000..d3e0b428d --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Ping.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * A locally-originated ping. + */ +public final class Ping { + private final CountDownLatch latch = new CountDownLatch(1); + private long sent = -1; + private long received = -1; + + Ping() { + } + + void send() { + if (sent != -1) throw new IllegalStateException(); + sent = System.nanoTime(); + } + + void receive() { + if (received != -1 || sent == -1) throw new IllegalStateException(); + received = System.nanoTime(); + latch.countDown(); + } + + void cancel() { + if (received != -1 || sent == -1) throw new IllegalStateException(); + received = sent - 1; + latch.countDown(); + } + + /** + * Returns the round trip time for this ping in nanoseconds, waiting for the response to arrive if + * necessary. Returns -1 if the response was canceled. + */ + public long roundTripTime() throws InterruptedException { + latch.await(); + return received - sent; + } + + /** + * Returns the round trip time for this ping in nanoseconds, or -1 if the response was canceled, + * or -2 if the timeout elapsed before the round trip completed. + */ + public long roundTripTime(long timeout, TimeUnit unit) throws InterruptedException { + if (latch.await(timeout, unit)) { + return received - sent; + } else { + return -2; + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/PushObserver.java b/okhttp3/src/main/java/okhttp3/internal/framed/PushObserver.java new file mode 100644 index 000000000..95dd41a5b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/PushObserver.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.IOException; +import java.util.List; +import okhttp3.Protocol; +import okio.BufferedSource; + +/** + * {@link Protocol#HTTP_2 HTTP/2} only. Processes server-initiated HTTP requests on the client. + * Implementations must quickly dispatch callbacks to avoid creating a bottleneck. + * + *

While {@link #onReset} may occur at any time, the following callbacks are expected in order, + * correlated by stream ID. + * + *

    + *
  • {@link #onRequest}
  • {@link #onHeaders} (unless canceled) + *
  • {@link #onData} (optional sequence of data frames) + *
+ * + *

As a stream ID is scoped to a single HTTP/2 connection, implementations which target multiple + * connections should expect repetition of stream IDs. + * + *

Return true to request cancellation of a pushed stream. Note that this does not guarantee + * future frames won't arrive on the stream ID. + */ +public interface PushObserver { + /** + * Describes the request that the server intends to push a response for. + * + * @param streamId server-initiated stream ID: an even number. + * @param requestHeaders minimally includes {@code :method}, {@code :scheme}, {@code :authority}, + * and (@code :path}. + */ + boolean onRequest(int streamId, List

requestHeaders); + + /** + * The response headers corresponding to a pushed request. When {@code last} is true, there are + * no data frames to follow. + * + * @param streamId server-initiated stream ID: an even number. + * @param responseHeaders minimally includes {@code :status}. + * @param last when true, there is no response data. + */ + boolean onHeaders(int streamId, List
responseHeaders, boolean last); + + /** + * A chunk of response data corresponding to a pushed request. This data must either be read or + * skipped. + * + * @param streamId server-initiated stream ID: an even number. + * @param source location of data corresponding with this stream ID. + * @param byteCount number of bytes to read or skip from the source. + * @param last when true, there are no data frames to follow. + */ + boolean onData(int streamId, BufferedSource source, int byteCount, boolean last) + throws IOException; + + /** Indicates the reason why this stream was canceled. */ + void onReset(int streamId, ErrorCode errorCode); + + PushObserver CANCEL = new PushObserver() { + + @Override public boolean onRequest(int streamId, List
requestHeaders) { + return true; + } + + @Override public boolean onHeaders(int streamId, List
responseHeaders, boolean last) { + return true; + } + + @Override public boolean onData(int streamId, BufferedSource source, int byteCount, + boolean last) throws IOException { + source.skip(byteCount); + return true; + } + + @Override public void onReset(int streamId, ErrorCode errorCode) { + } + }; +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Settings.java b/okhttp3/src/main/java/okhttp3/internal/framed/Settings.java new file mode 100644 index 000000000..f4eab0173 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Settings.java @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.util.Arrays; + +/** + * Settings describe characteristics of the sending peer, which are used by the receiving peer. + * Settings are {@link FramedConnection connection} scoped. + */ +public final class Settings { + /** + * From the SPDY/3 and HTTP/2 specs, the default initial window size for all streams is 64 KiB. + * (Chrome 25 uses 10 MiB). + */ + static final int DEFAULT_INITIAL_WINDOW_SIZE = 64 * 1024; + + /** Peer request to clear durable settings. */ + static final int FLAG_CLEAR_PREVIOUSLY_PERSISTED_SETTINGS = 0x1; + + /** Sent by servers only. The peer requests this setting persisted for future connections. */ + static final int PERSIST_VALUE = 0x1; + /** Sent by clients only. The client is reminding the server of a persisted value. */ + static final int PERSISTED = 0x2; + + /** spdy/3: Sender's estimate of max incoming kbps. */ + static final int UPLOAD_BANDWIDTH = 1; + /** HTTP/2: Size in bytes of the table used to decode the sender's header blocks. */ + static final int HEADER_TABLE_SIZE = 1; + /** spdy/3: Sender's estimate of max outgoing kbps. */ + static final int DOWNLOAD_BANDWIDTH = 2; + /** HTTP/2: The peer must not send a PUSH_PROMISE frame when this is 0. */ + static final int ENABLE_PUSH = 2; + /** spdy/3: Sender's estimate of millis between sending a request and receiving a response. */ + static final int ROUND_TRIP_TIME = 3; + /** Sender's maximum number of concurrent streams. */ + static final int MAX_CONCURRENT_STREAMS = 4; + /** spdy/3: Current CWND in Packets. */ + static final int CURRENT_CWND = 5; + /** HTTP/2: Size in bytes of the largest frame payload the sender will accept. */ + static final int MAX_FRAME_SIZE = 5; + /** spdy/3: Retransmission rate. Percentage */ + static final int DOWNLOAD_RETRANS_RATE = 6; + /** HTTP/2: Advisory only. Size in bytes of the largest header list the sender will accept. */ + static final int MAX_HEADER_LIST_SIZE = 6; + /** Window size in bytes. */ + static final int INITIAL_WINDOW_SIZE = 7; + /** spdy/3: Size of the client certificate vector. Unsupported. */ + static final int CLIENT_CERTIFICATE_VECTOR_SIZE = 8; + /** Flow control options. */ + static final int FLOW_CONTROL_OPTIONS = 10; + + /** Total number of settings. */ + static final int COUNT = 10; + + /** If set, flow control is disabled for streams directed to the sender of these settings. */ + static final int FLOW_CONTROL_OPTIONS_DISABLED = 0x1; + + /** Bitfield of which flags that values. */ + private int set; + + /** Bitfield of flags that have {@link #PERSIST_VALUE}. */ + private int persistValue; + + /** Bitfield of flags that have {@link #PERSISTED}. */ + private int persisted; + + /** Flag values. */ + private final int[] values = new int[COUNT]; + + void clear() { + set = persistValue = persisted = 0; + Arrays.fill(values, 0); + } + + Settings set(int id, int idFlags, int value) { + if (id >= values.length) { + return this; // Discard unknown settings. + } + + int bit = 1 << id; + set |= bit; + if ((idFlags & PERSIST_VALUE) != 0) { + persistValue |= bit; + } else { + persistValue &= ~bit; + } + if ((idFlags & PERSISTED) != 0) { + persisted |= bit; + } else { + persisted &= ~bit; + } + + values[id] = value; + return this; + } + + /** Returns true if a value has been assigned for the setting {@code id}. */ + boolean isSet(int id) { + int bit = 1 << id; + return (set & bit) != 0; + } + + /** Returns the value for the setting {@code id}, or 0 if unset. */ + int get(int id) { + return values[id]; + } + + /** Returns the flags for the setting {@code id}, or 0 if unset. */ + int flags(int id) { + int result = 0; + if (isPersisted(id)) result |= Settings.PERSISTED; + if (persistValue(id)) result |= Settings.PERSIST_VALUE; + return result; + } + + /** Returns the number of settings that have values assigned. */ + int size() { + return Integer.bitCount(set); + } + + /** spdy/3 only. */ + int getUploadBandwidth(int defaultValue) { + int bit = 1 << UPLOAD_BANDWIDTH; + return (bit & set) != 0 ? values[UPLOAD_BANDWIDTH] : defaultValue; + } + + /** HTTP/2 only. Returns -1 if unset. */ + int getHeaderTableSize() { + int bit = 1 << HEADER_TABLE_SIZE; + return (bit & set) != 0 ? values[HEADER_TABLE_SIZE] : -1; + } + + /** spdy/3 only. */ + int getDownloadBandwidth(int defaultValue) { + int bit = 1 << DOWNLOAD_BANDWIDTH; + return (bit & set) != 0 ? values[DOWNLOAD_BANDWIDTH] : defaultValue; + } + + /** HTTP/2 only. */ + // TODO: honor this setting in HTTP/2. + boolean getEnablePush(boolean defaultValue) { + int bit = 1 << ENABLE_PUSH; + return ((bit & set) != 0 ? values[ENABLE_PUSH] : defaultValue ? 1 : 0) == 1; + } + + /** spdy/3 only. */ + int getRoundTripTime(int defaultValue) { + int bit = 1 << ROUND_TRIP_TIME; + return (bit & set) != 0 ? values[ROUND_TRIP_TIME] : defaultValue; + } + + // TODO: honor this setting in spdy/3 and HTTP/2. + int getMaxConcurrentStreams(int defaultValue) { + int bit = 1 << MAX_CONCURRENT_STREAMS; + return (bit & set) != 0 ? values[MAX_CONCURRENT_STREAMS] : defaultValue; + } + + /** spdy/3 only. */ + int getCurrentCwnd(int defaultValue) { + int bit = 1 << CURRENT_CWND; + return (bit & set) != 0 ? values[CURRENT_CWND] : defaultValue; + } + + /** HTTP/2 only. */ + int getMaxFrameSize(int defaultValue) { + int bit = 1 << MAX_FRAME_SIZE; + return (bit & set) != 0 ? values[MAX_FRAME_SIZE] : defaultValue; + } + + /** spdy/3 only. */ + int getDownloadRetransRate(int defaultValue) { + int bit = 1 << DOWNLOAD_RETRANS_RATE; + return (bit & set) != 0 ? values[DOWNLOAD_RETRANS_RATE] : defaultValue; + } + + /** HTTP/2 only. */ + int getMaxHeaderListSize(int defaultValue) { + int bit = 1 << MAX_HEADER_LIST_SIZE; + return (bit & set) != 0 ? values[MAX_HEADER_LIST_SIZE] : defaultValue; + } + + int getInitialWindowSize(int defaultValue) { + int bit = 1 << INITIAL_WINDOW_SIZE; + return (bit & set) != 0 ? values[INITIAL_WINDOW_SIZE] : defaultValue; + } + + /** spdy/3 only. */ + int getClientCertificateVectorSize(int defaultValue) { + int bit = 1 << CLIENT_CERTIFICATE_VECTOR_SIZE; + return (bit & set) != 0 ? values[CLIENT_CERTIFICATE_VECTOR_SIZE] : defaultValue; + } + + // TODO: honor this setting in spdy/3 and HTTP/2. + boolean isFlowControlDisabled() { + int bit = 1 << FLOW_CONTROL_OPTIONS; + int value = (bit & set) != 0 ? values[FLOW_CONTROL_OPTIONS] : 0; + return (value & FLOW_CONTROL_OPTIONS_DISABLED) != 0; + } + + /** + * Returns true if this user agent should use this setting in future spdy/3 connections to the + * same host. + */ + boolean persistValue(int id) { + int bit = 1 << id; + return (persistValue & bit) != 0; + } + + /** Returns true if this setting was persisted. */ + boolean isPersisted(int id) { + int bit = 1 << id; + return (persisted & bit) != 0; + } + + /** + * Writes {@code other} into this. If any setting is populated by this and {@code other}, the + * value and flags from {@code other} will be kept. + */ + void merge(Settings other) { + for (int i = 0; i < COUNT; i++) { + if (!other.isSet(i)) continue; + set(i, other.flags(i), other.get(i)); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Spdy3.java b/okhttp3/src/main/java/okhttp3/internal/framed/Spdy3.java new file mode 100644 index 000000000..72053e006 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Spdy3.java @@ -0,0 +1,491 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.ProtocolException; +import java.util.List; +import java.util.zip.Deflater; +import okhttp3.Protocol; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.ByteString; +import okio.DeflaterSink; +import okio.Okio; + +/** + * Read and write spdy/3.1 frames. http://www.chromium.org/spdy/spdy-protocol/spdy-protocol-draft3-1 + */ +public final class Spdy3 implements Variant { + + @Override public Protocol getProtocol() { + return Protocol.SPDY_3; + } + + static final int TYPE_DATA = 0x0; + static final int TYPE_SYN_STREAM = 0x1; + static final int TYPE_SYN_REPLY = 0x2; + static final int TYPE_RST_STREAM = 0x3; + static final int TYPE_SETTINGS = 0x4; + static final int TYPE_PING = 0x6; + static final int TYPE_GOAWAY = 0x7; + static final int TYPE_HEADERS = 0x8; + static final int TYPE_WINDOW_UPDATE = 0x9; + + static final int FLAG_FIN = 0x1; + static final int FLAG_UNIDIRECTIONAL = 0x2; + + static final int VERSION = 3; + + static final byte[] DICTIONARY; + + static { + try { + DICTIONARY = ("\u0000\u0000\u0000\u0007options\u0000\u0000\u0000\u0004hea" + + "d\u0000\u0000\u0000\u0004post\u0000\u0000\u0000\u0003put\u0000\u0000\u0000\u0006dele" + + "te\u0000\u0000\u0000\u0005trace\u0000\u0000\u0000\u0006accept\u0000\u0000\u0000" + + "\u000Eaccept-charset\u0000\u0000\u0000\u000Faccept-encoding\u0000\u0000\u0000\u000Fa" + + "ccept-language\u0000\u0000\u0000\raccept-ranges\u0000\u0000\u0000\u0003age\u0000" + + "\u0000\u0000\u0005allow\u0000\u0000\u0000\rauthorization\u0000\u0000\u0000\rcache-co" + + "ntrol\u0000\u0000\u0000\nconnection\u0000\u0000\u0000\fcontent-base\u0000\u0000" + + "\u0000\u0010content-encoding\u0000\u0000\u0000\u0010content-language\u0000\u0000" + + "\u0000\u000Econtent-length\u0000\u0000\u0000\u0010content-location\u0000\u0000\u0000" + + "\u000Bcontent-md5\u0000\u0000\u0000\rcontent-range\u0000\u0000\u0000\fcontent-type" + + "\u0000\u0000\u0000\u0004date\u0000\u0000\u0000\u0004etag\u0000\u0000\u0000\u0006expe" + + "ct\u0000\u0000\u0000\u0007expires\u0000\u0000\u0000\u0004from\u0000\u0000\u0000" + + "\u0004host\u0000\u0000\u0000\bif-match\u0000\u0000\u0000\u0011if-modified-since" + + "\u0000\u0000\u0000\rif-none-match\u0000\u0000\u0000\bif-range\u0000\u0000\u0000" + + "\u0013if-unmodified-since\u0000\u0000\u0000\rlast-modified\u0000\u0000\u0000\blocati" + + "on\u0000\u0000\u0000\fmax-forwards\u0000\u0000\u0000\u0006pragma\u0000\u0000\u0000" + + "\u0012proxy-authenticate\u0000\u0000\u0000\u0013proxy-authorization\u0000\u0000" + + "\u0000\u0005range\u0000\u0000\u0000\u0007referer\u0000\u0000\u0000\u000Bretry-after" + + "\u0000\u0000\u0000\u0006server\u0000\u0000\u0000\u0002te\u0000\u0000\u0000\u0007trai" + + "ler\u0000\u0000\u0000\u0011transfer-encoding\u0000\u0000\u0000\u0007upgrade\u0000" + + "\u0000\u0000\nuser-agent\u0000\u0000\u0000\u0004vary\u0000\u0000\u0000\u0003via" + + "\u0000\u0000\u0000\u0007warning\u0000\u0000\u0000\u0010www-authenticate\u0000\u0000" + + "\u0000\u0006method\u0000\u0000\u0000\u0003get\u0000\u0000\u0000\u0006status\u0000" + + "\u0000\u0000\u0006200 OK\u0000\u0000\u0000\u0007version\u0000\u0000\u0000\bHTTP/1.1" + + "\u0000\u0000\u0000\u0003url\u0000\u0000\u0000\u0006public\u0000\u0000\u0000\nset-coo" + + "kie\u0000\u0000\u0000\nkeep-alive\u0000\u0000\u0000\u0006origin100101201202205206300" + + "302303304305306307402405406407408409410411412413414415416417502504505203 Non-Authori" + + "tative Information204 No Content301 Moved Permanently400 Bad Request401 Unauthorized" + + "403 Forbidden404 Not Found500 Internal Server Error501 Not Implemented503 Service Un" + + "availableJan Feb Mar Apr May Jun Jul Aug Sept Oct Nov Dec 00:00:00 Mon, Tue, Wed, Th" + + "u, Fri, Sat, Sun, GMTchunked,text/html,image/png,image/jpg,image/gif,application/xml" + + ",application/xhtml+xml,text/plain,text/javascript,publicprivatemax-age=gzip,deflate," + + "sdchcharset=utf-8charset=iso-8859-1,utf-,*,enq=0.").getBytes(Util.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(); + } + } + + @Override public FrameReader newReader(BufferedSource source, boolean client) { + return new Reader(source, client); + } + + @Override public FrameWriter newWriter(BufferedSink sink, boolean client) { + return new Writer(sink, client); + } + + /** Read spdy/3 frames. */ + static final class Reader implements FrameReader { + private final BufferedSource source; + private final boolean client; + private final NameValueBlockReader headerBlockReader; + + Reader(BufferedSource source, boolean client) { + this.source = source; + this.headerBlockReader = new NameValueBlockReader(this.source); + this.client = client; + } + + @Override public void readConnectionPreface() { + } + + /** + * Send the next frame to {@code handler}. Returns true unless there are no more frames on the + * stream. + */ + @Override public boolean nextFrame(Handler handler) throws IOException { + int w1; + int w2; + try { + w1 = source.readInt(); + w2 = source.readInt(); + } catch (IOException e) { + return false; // This might be a normal socket close. + } + + boolean control = (w1 & 0x80000000) != 0; + int flags = (w2 & 0xff000000) >>> 24; + int length = (w2 & 0xffffff); + + if (control) { + int version = (w1 & 0x7fff0000) >>> 16; + int type = (w1 & 0xffff); + + if (version != 3) { + throw new ProtocolException("version != 3: " + version); + } + + switch (type) { + case TYPE_SYN_STREAM: + readSynStream(handler, flags, length); + return true; + + case TYPE_SYN_REPLY: + readSynReply(handler, flags, length); + return true; + + case TYPE_RST_STREAM: + readRstStream(handler, flags, length); + return true; + + case TYPE_SETTINGS: + readSettings(handler, flags, length); + return true; + + case TYPE_PING: + readPing(handler, flags, length); + return true; + + case TYPE_GOAWAY: + readGoAway(handler, flags, length); + return true; + + case TYPE_HEADERS: + readHeaders(handler, flags, length); + return true; + + case TYPE_WINDOW_UPDATE: + readWindowUpdate(handler, flags, length); + return true; + + default: + source.skip(length); + return true; + } + } else { + int streamId = w1 & 0x7fffffff; + boolean inFinished = (flags & FLAG_FIN) != 0; + handler.data(inFinished, streamId, source, length); + return true; + } + } + + private void readSynStream(Handler handler, int flags, int length) throws IOException { + int w1 = source.readInt(); + int w2 = source.readInt(); + int streamId = w1 & 0x7fffffff; + int associatedStreamId = w2 & 0x7fffffff; + source.readShort(); // int priority = (s3 & 0xe000) >>> 13; int slot = s3 & 0xff; + List
headerBlock = headerBlockReader.readNameValueBlock(length - 10); + + boolean inFinished = (flags & FLAG_FIN) != 0; + boolean outFinished = (flags & FLAG_UNIDIRECTIONAL) != 0; + handler.headers(outFinished, inFinished, streamId, associatedStreamId, headerBlock, + HeadersMode.SPDY_SYN_STREAM); + } + + private void readSynReply(Handler handler, int flags, int length) throws IOException { + int w1 = source.readInt(); + int streamId = w1 & 0x7fffffff; + List
headerBlock = headerBlockReader.readNameValueBlock(length - 4); + boolean inFinished = (flags & FLAG_FIN) != 0; + handler.headers(false, inFinished, streamId, -1, headerBlock, HeadersMode.SPDY_REPLY); + } + + private void readRstStream(Handler handler, int flags, int length) throws IOException { + if (length != 8) throw ioException("TYPE_RST_STREAM length: %d != 8", length); + int streamId = source.readInt() & 0x7fffffff; + int errorCodeInt = source.readInt(); + ErrorCode errorCode = ErrorCode.fromSpdy3Rst(errorCodeInt); + if (errorCode == null) { + throw ioException("TYPE_RST_STREAM unexpected error code: %d", errorCodeInt); + } + handler.rstStream(streamId, errorCode); + } + + private void readHeaders(Handler handler, int flags, int length) throws IOException { + int w1 = source.readInt(); + int streamId = w1 & 0x7fffffff; + List
headerBlock = headerBlockReader.readNameValueBlock(length - 4); + handler.headers(false, false, streamId, -1, headerBlock, HeadersMode.SPDY_HEADERS); + } + + private void readWindowUpdate(Handler handler, int flags, int length) throws IOException { + if (length != 8) throw ioException("TYPE_WINDOW_UPDATE length: %d != 8", length); + int w1 = source.readInt(); + int w2 = source.readInt(); + int streamId = w1 & 0x7fffffff; + long increment = w2 & 0x7fffffff; + if (increment == 0) throw ioException("windowSizeIncrement was 0", increment); + handler.windowUpdate(streamId, increment); + } + + private void readPing(Handler handler, int flags, int length) throws IOException { + if (length != 4) throw ioException("TYPE_PING length: %d != 4", length); + int id = source.readInt(); + boolean ack = client == ((id & 1) == 1); + handler.ping(ack, id, 0); + } + + private void readGoAway(Handler handler, int flags, int length) throws IOException { + if (length != 8) throw ioException("TYPE_GOAWAY length: %d != 8", length); + int lastGoodStreamId = source.readInt() & 0x7fffffff; + int errorCodeInt = source.readInt(); + ErrorCode errorCode = ErrorCode.fromSpdyGoAway(errorCodeInt); + if (errorCode == null) { + throw ioException("TYPE_GOAWAY unexpected error code: %d", errorCodeInt); + } + handler.goAway(lastGoodStreamId, errorCode, ByteString.EMPTY); + } + + private void readSettings(Handler handler, int flags, int length) throws IOException { + int numberOfEntries = source.readInt(); + if (length != 4 + 8 * numberOfEntries) { + throw ioException("TYPE_SETTINGS length: %d != 4 + 8 * %d", length, numberOfEntries); + } + Settings settings = new Settings(); + for (int i = 0; i < numberOfEntries; i++) { + int w1 = source.readInt(); + int value = source.readInt(); + int idFlags = (w1 & 0xff000000) >>> 24; + int id = w1 & 0xffffff; + settings.set(id, idFlags, value); + } + boolean clearPrevious = (flags & Settings.FLAG_CLEAR_PREVIOUSLY_PERSISTED_SETTINGS) != 0; + handler.settings(clearPrevious, settings); + } + + private static IOException ioException(String message, Object... args) throws IOException { + throw new IOException(String.format(message, args)); + } + + @Override public void close() throws IOException { + headerBlockReader.close(); + } + } + + /** Write spdy/3 frames. */ + static final class Writer implements FrameWriter { + private final BufferedSink sink; + private final Buffer headerBlockBuffer; + private final BufferedSink headerBlockOut; + private final boolean client; + private boolean closed; + + Writer(BufferedSink sink, boolean client) { + this.sink = sink; + this.client = client; + + Deflater deflater = new Deflater(); + deflater.setDictionary(DICTIONARY); + headerBlockBuffer = new Buffer(); + headerBlockOut = Okio.buffer(new DeflaterSink(headerBlockBuffer, deflater)); + } + + @Override public void ackSettings(Settings peerSettings) { + // Do nothing: no ACK for SPDY/3 settings. + } + + @Override + public void pushPromise(int streamId, int promisedStreamId, List
requestHeaders) + throws IOException { + // Do nothing: no push promise for SPDY/3. + } + + @Override public synchronized void connectionPreface() { + // Do nothing: no connection preface for SPDY/3. + } + + @Override public synchronized void flush() throws IOException { + if (closed) throw new IOException("closed"); + sink.flush(); + } + + @Override public synchronized void synStream(boolean outFinished, boolean inFinished, + int streamId, int associatedStreamId, List
headerBlock) + throws IOException { + if (closed) throw new IOException("closed"); + writeNameValueBlockToBuffer(headerBlock); + int length = (int) (10 + headerBlockBuffer.size()); + int type = TYPE_SYN_STREAM; + int flags = (outFinished ? FLAG_FIN : 0) | (inFinished ? FLAG_UNIDIRECTIONAL : 0); + + int unused = 0; + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(streamId & 0x7fffffff); + sink.writeInt(associatedStreamId & 0x7fffffff); + sink.writeShort((unused & 0x7) << 13 | (unused & 0x1f) << 8 | (unused & 0xff)); + sink.writeAll(headerBlockBuffer); + sink.flush(); + } + + @Override public synchronized void synReply(boolean outFinished, int streamId, + List
headerBlock) throws IOException { + if (closed) throw new IOException("closed"); + writeNameValueBlockToBuffer(headerBlock); + int type = TYPE_SYN_REPLY; + int flags = (outFinished ? FLAG_FIN : 0); + int length = (int) (headerBlockBuffer.size() + 4); + + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(streamId & 0x7fffffff); + sink.writeAll(headerBlockBuffer); + sink.flush(); + } + + @Override public synchronized void headers(int streamId, List
headerBlock) + throws IOException { + if (closed) throw new IOException("closed"); + writeNameValueBlockToBuffer(headerBlock); + int flags = 0; + int type = TYPE_HEADERS; + int length = (int) (headerBlockBuffer.size() + 4); + + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(streamId & 0x7fffffff); + sink.writeAll(headerBlockBuffer); + } + + @Override public synchronized void rstStream(int streamId, ErrorCode errorCode) + throws IOException { + if (closed) throw new IOException("closed"); + if (errorCode.spdyRstCode == -1) throw new IllegalArgumentException(); + int flags = 0; + int type = TYPE_RST_STREAM; + int length = 8; + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(streamId & 0x7fffffff); + sink.writeInt(errorCode.spdyRstCode); + sink.flush(); + } + + @Override public int maxDataLength() { + return 16383; + } + + @Override public synchronized void data(boolean outFinished, int streamId, Buffer source, + int byteCount) throws IOException { + int flags = (outFinished ? FLAG_FIN : 0); + sendDataFrame(streamId, flags, source, byteCount); + } + + void sendDataFrame(int streamId, int flags, Buffer buffer, int byteCount) + throws IOException { + if (closed) throw new IOException("closed"); + if (byteCount > 0xffffffL) { + throw new IllegalArgumentException("FRAME_TOO_LARGE max size is 16Mib: " + byteCount); + } + sink.writeInt(streamId & 0x7fffffff); + sink.writeInt((flags & 0xff) << 24 | byteCount & 0xffffff); + if (byteCount > 0) { + sink.write(buffer, byteCount); + } + } + + private void writeNameValueBlockToBuffer(List
headerBlock) throws IOException { + headerBlockOut.writeInt(headerBlock.size()); + for (int i = 0, size = headerBlock.size(); i < size; i++) { + ByteString name = headerBlock.get(i).name; + headerBlockOut.writeInt(name.size()); + headerBlockOut.write(name); + ByteString value = headerBlock.get(i).value; + headerBlockOut.writeInt(value.size()); + headerBlockOut.write(value); + } + headerBlockOut.flush(); + } + + @Override public synchronized void settings(Settings settings) throws IOException { + if (closed) throw new IOException("closed"); + int type = TYPE_SETTINGS; + int flags = 0; + int size = settings.size(); + int length = 4 + size * 8; + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(size); + for (int i = 0; i <= Settings.COUNT; i++) { + if (!settings.isSet(i)) continue; + int settingsFlags = settings.flags(i); + sink.writeInt((settingsFlags & 0xff) << 24 | (i & 0xffffff)); + sink.writeInt(settings.get(i)); + } + sink.flush(); + } + + @Override public synchronized void ping(boolean reply, int payload1, int payload2) + throws IOException { + if (closed) throw new IOException("closed"); + boolean payloadIsReply = client != ((payload1 & 1) == 1); + if (reply != payloadIsReply) throw new IllegalArgumentException("payload != reply"); + int type = TYPE_PING; + int flags = 0; + int length = 4; + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(payload1); + sink.flush(); + } + + @Override public synchronized void goAway(int lastGoodStreamId, ErrorCode errorCode, + byte[] ignored) throws IOException { + if (closed) throw new IOException("closed"); + if (errorCode.spdyGoAwayCode == -1) { + throw new IllegalArgumentException("errorCode.spdyGoAwayCode == -1"); + } + int type = TYPE_GOAWAY; + int flags = 0; + int length = 8; + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(lastGoodStreamId); + sink.writeInt(errorCode.spdyGoAwayCode); + sink.flush(); + } + + @Override public synchronized void windowUpdate(int streamId, long increment) + throws IOException { + if (closed) throw new IOException("closed"); + if (increment == 0 || increment > 0x7fffffffL) { + throw new IllegalArgumentException( + "windowSizeIncrement must be between 1 and 0x7fffffff: " + increment); + } + int type = TYPE_WINDOW_UPDATE; + int flags = 0; + int length = 8; + sink.writeInt(0x80000000 | (VERSION & 0x7fff) << 16 | type & 0xffff); + sink.writeInt((flags & 0xff) << 24 | length & 0xffffff); + sink.writeInt(streamId); + sink.writeInt((int) increment); + sink.flush(); + } + + @Override public synchronized void close() throws IOException { + closed = true; + Util.closeAll(sink, headerBlockOut); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/framed/Variant.java b/okhttp3/src/main/java/okhttp3/internal/framed/Variant.java new file mode 100644 index 000000000..4ff379400 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/framed/Variant.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.framed; + +import okhttp3.Protocol; +import okio.BufferedSink; +import okio.BufferedSource; + +/** A version and dialect of the framed socket protocol. */ +public interface Variant { + + /** The protocol as selected using ALPN. */ + Protocol getProtocol(); + + /** + * @param client true if this is the HTTP client's reader, reading frames from a server. + */ + FrameReader newReader(BufferedSource source, boolean client); + + /** + * @param client true if this is the HTTP client's writer, writing frames to a server. + */ + FrameWriter newWriter(BufferedSink sink, boolean client); +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/CacheRequest.java b/okhttp3/src/main/java/okhttp3/internal/http/CacheRequest.java new file mode 100644 index 000000000..12c3134e8 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/CacheRequest.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import java.io.IOException; +import okio.Sink; + +public interface CacheRequest { + Sink body() throws IOException; + + void abort(); +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/CacheStrategy.java b/okhttp3/src/main/java/okhttp3/internal/http/CacheStrategy.java new file mode 100644 index 000000000..a2f513d1d --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/CacheStrategy.java @@ -0,0 +1,297 @@ +package okhttp3.internal.http; + +import java.util.Date; +import okhttp3.CacheControl; +import okhttp3.Headers; +import okhttp3.Request; +import okhttp3.Response; + +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_GONE; +import static java.net.HttpURLConnection.HTTP_MOVED_PERM; +import static java.net.HttpURLConnection.HTTP_MOVED_TEMP; +import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; +import static java.net.HttpURLConnection.HTTP_NOT_AUTHORITATIVE; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_REQ_TOO_LONG; +import static java.util.concurrent.TimeUnit.SECONDS; + +/** + * Given a request and cached response, this figures out whether to use the network, the cache, or + * both. + * + *

Selecting a cache strategy may add conditions to the request (like the "If-Modified-Since" + * header for conditional GETs) or warnings to the cached response (if the cached data is + * potentially stale). + */ +public final class CacheStrategy { + /** The request to send on the network, or null if this call doesn't use the network. */ + public final Request networkRequest; + + /** The cached response to return or validate; or null if this call doesn't use a cache. */ + public final Response cacheResponse; + + private CacheStrategy(Request networkRequest, Response cacheResponse) { + this.networkRequest = networkRequest; + this.cacheResponse = cacheResponse; + } + + /** Returns true if {@code response} can be stored to later serve another request. */ + public static boolean isCacheable(Response response, Request request) { + // Always go to network for uncacheable response codes (RFC 7231 section 6.1), + // This implementation doesn't support caching partial content. + switch (response.code()) { + case HTTP_OK: + case HTTP_NOT_AUTHORITATIVE: + case HTTP_NO_CONTENT: + case HTTP_MULT_CHOICE: + case HTTP_MOVED_PERM: + case HTTP_NOT_FOUND: + case HTTP_BAD_METHOD: + case HTTP_GONE: + case HTTP_REQ_TOO_LONG: + case HTTP_NOT_IMPLEMENTED: + case StatusLine.HTTP_PERM_REDIRECT: + // These codes can be cached unless headers forbid it. + break; + + case HTTP_MOVED_TEMP: + case StatusLine.HTTP_TEMP_REDIRECT: + // These codes can only be cached with the right response headers. + // http://tools.ietf.org/html/rfc7234#section-3 + // s-maxage is not checked because OkHttp is a private cache that should ignore s-maxage. + if (response.header("Expires") != null + || response.cacheControl().maxAgeSeconds() != -1 + || response.cacheControl().isPublic() + || response.cacheControl().isPrivate()) { + break; + } + // Fall-through. + + default: + // All other codes cannot be cached. + return false; + } + + // A 'no-store' directive on request or response prevents the response from being cached. + return !response.cacheControl().noStore() && !request.cacheControl().noStore(); + } + + public static class Factory { + final long nowMillis; + final Request request; + final Response cacheResponse; + + /** The server's time when the cached response was served, if known. */ + private Date servedDate; + private String servedDateString; + + /** The last modified date of the cached response, if known. */ + private Date lastModified; + private String lastModifiedString; + + /** + * The expiration date of the cached response, if known. If both this field and the max age are + * set, the max age is preferred. + */ + private Date expires; + + /** + * Extension header set by OkHttp specifying the timestamp when the cached HTTP request was + * first initiated. + */ + private long sentRequestMillis; + + /** + * Extension header set by OkHttp specifying the timestamp when the cached HTTP response was + * first received. + */ + private long receivedResponseMillis; + + /** Etag of the cached response. */ + private String etag; + + /** Age of the cached response. */ + private int ageSeconds = -1; + + public Factory(long nowMillis, Request request, Response cacheResponse) { + this.nowMillis = nowMillis; + this.request = request; + this.cacheResponse = cacheResponse; + + if (cacheResponse != null) { + Headers headers = cacheResponse.headers(); + for (int i = 0, size = headers.size(); i < size; i++) { + String fieldName = headers.name(i); + String value = headers.value(i); + if ("Date".equalsIgnoreCase(fieldName)) { + servedDate = HttpDate.parse(value); + servedDateString = value; + } else if ("Expires".equalsIgnoreCase(fieldName)) { + expires = HttpDate.parse(value); + } else if ("Last-Modified".equalsIgnoreCase(fieldName)) { + lastModified = HttpDate.parse(value); + lastModifiedString = value; + } else if ("ETag".equalsIgnoreCase(fieldName)) { + etag = value; + } else if ("Age".equalsIgnoreCase(fieldName)) { + ageSeconds = HeaderParser.parseSeconds(value, -1); + } else if (OkHeaders.SENT_MILLIS.equalsIgnoreCase(fieldName)) { + sentRequestMillis = Long.parseLong(value); + } else if (OkHeaders.RECEIVED_MILLIS.equalsIgnoreCase(fieldName)) { + receivedResponseMillis = Long.parseLong(value); + } + } + } + } + + /** + * Returns a strategy to satisfy {@code request} using the a cached response {@code response}. + */ + public CacheStrategy get() { + CacheStrategy candidate = getCandidate(); + + if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) { + // We're forbidden from using the network and the cache is insufficient. + return new CacheStrategy(null, null); + } + + return candidate; + } + + /** Returns a strategy to use assuming the request can use the network. */ + private CacheStrategy getCandidate() { + // No cached response. + if (cacheResponse == null) { + return new CacheStrategy(request, null); + } + + // Drop the cached response if it's missing a required handshake. + if (request.isHttps() && cacheResponse.handshake() == null) { + return new CacheStrategy(request, null); + } + + // If this response shouldn't have been stored, it should never be used + // as a response source. This check should be redundant as long as the + // persistence store is well-behaved and the rules are constant. + if (!isCacheable(cacheResponse, request)) { + return new CacheStrategy(request, null); + } + + CacheControl requestCaching = request.cacheControl(); + if (requestCaching.noCache() || hasConditions(request)) { + return new CacheStrategy(request, null); + } + + long ageMillis = cacheResponseAge(); + long freshMillis = computeFreshnessLifetime(); + + if (requestCaching.maxAgeSeconds() != -1) { + freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds())); + } + + long minFreshMillis = 0; + if (requestCaching.minFreshSeconds() != -1) { + minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds()); + } + + long maxStaleMillis = 0; + CacheControl responseCaching = cacheResponse.cacheControl(); + if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) { + maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds()); + } + + if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) { + Response.Builder builder = cacheResponse.newBuilder(); + if (ageMillis + minFreshMillis >= freshMillis) { + builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\""); + } + long oneDayMillis = 24 * 60 * 60 * 1000L; + if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) { + builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\""); + } + return new CacheStrategy(null, builder.build()); + } + + Request.Builder conditionalRequestBuilder = request.newBuilder(); + + if (etag != null) { + conditionalRequestBuilder.header("If-None-Match", etag); + } else if (lastModified != null) { + conditionalRequestBuilder.header("If-Modified-Since", lastModifiedString); + } else if (servedDate != null) { + conditionalRequestBuilder.header("If-Modified-Since", servedDateString); + } + + Request conditionalRequest = conditionalRequestBuilder.build(); + return hasConditions(conditionalRequest) + ? new CacheStrategy(conditionalRequest, cacheResponse) + : new CacheStrategy(conditionalRequest, null); + } + + /** + * Returns the number of milliseconds that the response was fresh for, starting from the served + * date. + */ + private long computeFreshnessLifetime() { + CacheControl responseCaching = cacheResponse.cacheControl(); + if (responseCaching.maxAgeSeconds() != -1) { + return SECONDS.toMillis(responseCaching.maxAgeSeconds()); + } else if (expires != null) { + long servedMillis = servedDate != null + ? servedDate.getTime() + : receivedResponseMillis; + long delta = expires.getTime() - servedMillis; + return delta > 0 ? delta : 0; + } else if (lastModified != null + && cacheResponse.request().url().query() == null) { + // As recommended by the HTTP RFC and implemented in Firefox, the + // max age of a document should be defaulted to 10% of the + // document's age at the time it was served. Default expiration + // dates aren't used for URIs containing a query. + long servedMillis = servedDate != null + ? servedDate.getTime() + : sentRequestMillis; + long delta = servedMillis - lastModified.getTime(); + return delta > 0 ? (delta / 10) : 0; + } + return 0; + } + + /** + * Returns the current age of the response, in milliseconds. The calculation is specified by RFC + * 2616, 13.2.3 Age Calculations. + */ + private long cacheResponseAge() { + long apparentReceivedAge = servedDate != null + ? Math.max(0, receivedResponseMillis - servedDate.getTime()) + : 0; + long receivedAge = ageSeconds != -1 + ? Math.max(apparentReceivedAge, SECONDS.toMillis(ageSeconds)) + : apparentReceivedAge; + long responseDuration = receivedResponseMillis - sentRequestMillis; + long residentDuration = nowMillis - receivedResponseMillis; + return receivedAge + responseDuration + residentDuration; + } + + /** + * Returns true if computeFreshnessLifetime used a heuristic. If we used a heuristic to serve a + * cached response older than 24 hours, we are required to attach a warning. + */ + private boolean isFreshnessLifetimeHeuristic() { + return cacheResponse.cacheControl().maxAgeSeconds() == -1 && expires == null; + } + + /** + * Returns true if the request contains conditions that save the server from sending a response + * that the client has locally. When a request is enqueued with its own conditions, the built-in + * response cache won't be used. + */ + private static boolean hasConditions(Request request) { + return request.header("If-Modified-Since") != null || request.header("If-None-Match") != null; + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/HeaderParser.java b/okhttp3/src/main/java/okhttp3/internal/http/HeaderParser.java new file mode 100644 index 000000000..6cb323280 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/HeaderParser.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +public final class HeaderParser { + /** + * Returns the next index in {@code input} at or after {@code pos} that contains a character from + * {@code characters}. Returns the input length if none of the requested characters can be found. + */ + public static int skipUntil(String input, int pos, String characters) { + for (; pos < input.length(); pos++) { + if (characters.indexOf(input.charAt(pos)) != -1) { + break; + } + } + return pos; + } + + /** + * Returns the next non-whitespace character in {@code input} that is white space. Result is + * undefined if input contains newline characters. + */ + public static int skipWhitespace(String input, int pos) { + for (; pos < input.length(); pos++) { + char c = input.charAt(pos); + if (c != ' ' && c != '\t') { + break; + } + } + return pos; + } + + /** + * Returns {@code value} as a positive integer, or 0 if it is negative, or {@code defaultValue} if + * it cannot be parsed. + */ + public static int parseSeconds(String value, int defaultValue) { + try { + long seconds = Long.parseLong(value); + if (seconds > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } else if (seconds < 0) { + return 0; + } else { + return (int) seconds; + } + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private HeaderParser() { + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/Http1xStream.java b/okhttp3/src/main/java/okhttp3/internal/http/Http1xStream.java new file mode 100644 index 000000000..39ff4d478 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/Http1xStream.java @@ -0,0 +1,494 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +import java.io.EOFException; +import java.io.IOException; +import java.net.ProtocolException; +import okhttp3.Headers; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.internal.Internal; +import okhttp3.internal.Util; +import okhttp3.internal.io.RealConnection; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.ForwardingTimeout; +import okio.Okio; +import okio.Sink; +import okio.Source; +import okio.Timeout; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static okhttp3.internal.Util.checkOffsetAndCount; +import static okhttp3.internal.http.StatusLine.HTTP_CONTINUE; + +/** + * A socket connection that can be used to send HTTP/1.1 messages. This class strictly enforces the + * following lifecycle: + * + *

    + *
  1. {@linkplain #writeRequest Send request headers}. + *
  2. Open a sink to write the request body. Either {@linkplain #newFixedLengthSink + * fixed-length} or {@link #newChunkedSink chunked}. + *
  3. Write to and then close that sink. + *
  4. {@linkplain #readResponse Read response headers}. + *
  5. Open a source to read the response body. Either {@linkplain #newFixedLengthSource + * fixed-length}, {@linkplain #newChunkedSource chunked} or {@linkplain + * #newUnknownLengthSource unknown length}. + *
  6. Read from and close that source. + *
+ * + *

Exchanges that do not have a request body may skip creating and closing the request body. + * Exchanges that do not have a response body can call {@link #newFixedLengthSource(long) + * newFixedLengthSource(0)} and may skip reading and closing that source. + */ +public final class Http1xStream implements HttpStream { + private static final int STATE_IDLE = 0; // Idle connections are ready to write request headers. + private static final int STATE_OPEN_REQUEST_BODY = 1; + private static final int STATE_WRITING_REQUEST_BODY = 2; + private static final int STATE_READ_RESPONSE_HEADERS = 3; + private static final int STATE_OPEN_RESPONSE_BODY = 4; + private static final int STATE_READING_RESPONSE_BODY = 5; + private static final int STATE_CLOSED = 6; + + /** The stream allocation that owns this stream. May be null for HTTPS proxy tunnels. */ + private final StreamAllocation streamAllocation; + private final BufferedSource source; + private final BufferedSink sink; + private HttpEngine httpEngine; + private int state = STATE_IDLE; + + public Http1xStream(StreamAllocation streamAllocation, BufferedSource source, BufferedSink sink) { + this.streamAllocation = streamAllocation; + this.source = source; + this.sink = sink; + } + + @Override public void setHttpEngine(HttpEngine httpEngine) { + this.httpEngine = httpEngine; + } + + @Override public Sink createRequestBody(Request request, long contentLength) throws IOException { + if ("chunked".equalsIgnoreCase(request.header("Transfer-Encoding"))) { + // Stream a request body of unknown length. + return newChunkedSink(); + } + + if (contentLength != -1) { + // Stream a request body of a known length. + return newFixedLengthSink(contentLength); + } + + throw new IllegalStateException( + "Cannot stream a request body without chunked encoding or a known content length!"); + } + + @Override public void cancel() { + RealConnection connection = streamAllocation.connection(); + if (connection != null) connection.cancel(); + } + + /** + * Prepares the HTTP headers and sends them to the server. + * + *

For streaming requests with a body, headers must be prepared before the + * output stream has been written to. Otherwise the body would need to be buffered! + * + *

For non-streaming requests with a body, headers must be prepared after the + * output stream has been written to and closed. This ensures that the {@code Content-Length} + * header field receives the proper value. + */ + @Override public void writeRequestHeaders(Request request) throws IOException { + httpEngine.writingRequestHeaders(); + String requestLine = RequestLine.get( + request, httpEngine.getConnection().route().proxy().type()); + writeRequest(request.headers(), requestLine); + } + + @Override public Response.Builder readResponseHeaders() throws IOException { + return readResponse(); + } + + @Override public ResponseBody openResponseBody(Response response) throws IOException { + Source source = getTransferStream(response); + return new RealResponseBody(response.headers(), Okio.buffer(source)); + } + + private Source getTransferStream(Response response) throws IOException { + if (!HttpEngine.hasBody(response)) { + return newFixedLengthSource(0); + } + + if ("chunked".equalsIgnoreCase(response.header("Transfer-Encoding"))) { + return newChunkedSource(httpEngine); + } + + long contentLength = OkHeaders.contentLength(response); + if (contentLength != -1) { + return newFixedLengthSource(contentLength); + } + + // Wrap the input stream from the connection (rather than just returning + // "socketIn" directly here), so that we can control its use after the + // reference escapes. + return newUnknownLengthSource(); + } + + /** Returns true if this connection is closed. */ + public boolean isClosed() { + return state == STATE_CLOSED; + } + + @Override public void finishRequest() throws IOException { + sink.flush(); + } + + /** Returns bytes of a request header for sending on an HTTP transport. */ + public void writeRequest(Headers headers, String requestLine) throws IOException { + if (state != STATE_IDLE) throw new IllegalStateException("state: " + state); + sink.writeUtf8(requestLine).writeUtf8("\r\n"); + for (int i = 0, size = headers.size(); i < size; i++) { + sink.writeUtf8(headers.name(i)) + .writeUtf8(": ") + .writeUtf8(headers.value(i)) + .writeUtf8("\r\n"); + } + sink.writeUtf8("\r\n"); + state = STATE_OPEN_REQUEST_BODY; + } + + /** Parses bytes of a response header from an HTTP transport. */ + public Response.Builder readResponse() throws IOException { + if (state != STATE_OPEN_REQUEST_BODY && state != STATE_READ_RESPONSE_HEADERS) { + throw new IllegalStateException("state: " + state); + } + + try { + while (true) { + StatusLine statusLine = StatusLine.parse(source.readUtf8LineStrict()); + + Response.Builder responseBuilder = new Response.Builder() + .protocol(statusLine.protocol) + .code(statusLine.code) + .message(statusLine.message) + .headers(readHeaders()); + + if (statusLine.code != HTTP_CONTINUE) { + state = STATE_OPEN_RESPONSE_BODY; + return responseBuilder; + } + } + } catch (EOFException e) { + // Provide more context if the server ends the stream before sending a response. + IOException exception = new IOException("unexpected end of stream on " + streamAllocation); + exception.initCause(e); + throw exception; + } + } + + /** Reads headers or trailers. */ + public Headers readHeaders() throws IOException { + Headers.Builder headers = new Headers.Builder(); + // parse the result headers until the first blank line + for (String line; (line = source.readUtf8LineStrict()).length() != 0; ) { + Internal.instance.addLenient(headers, line); + } + return headers.build(); + } + + public Sink newChunkedSink() { + if (state != STATE_OPEN_REQUEST_BODY) throw new IllegalStateException("state: " + state); + state = STATE_WRITING_REQUEST_BODY; + return new ChunkedSink(); + } + + public Sink newFixedLengthSink(long contentLength) { + if (state != STATE_OPEN_REQUEST_BODY) throw new IllegalStateException("state: " + state); + state = STATE_WRITING_REQUEST_BODY; + return new FixedLengthSink(contentLength); + } + + @Override public void writeRequestBody(RetryableSink requestBody) throws IOException { + if (state != STATE_OPEN_REQUEST_BODY) throw new IllegalStateException("state: " + state); + state = STATE_READ_RESPONSE_HEADERS; + requestBody.writeToSocket(sink); + } + + public Source newFixedLengthSource(long length) throws IOException { + if (state != STATE_OPEN_RESPONSE_BODY) throw new IllegalStateException("state: " + state); + state = STATE_READING_RESPONSE_BODY; + return new FixedLengthSource(length); + } + + public Source newChunkedSource(HttpEngine httpEngine) throws IOException { + if (state != STATE_OPEN_RESPONSE_BODY) throw new IllegalStateException("state: " + state); + state = STATE_READING_RESPONSE_BODY; + return new ChunkedSource(httpEngine); + } + + public Source newUnknownLengthSource() throws IOException { + if (state != STATE_OPEN_RESPONSE_BODY) throw new IllegalStateException("state: " + state); + if (streamAllocation == null) throw new IllegalStateException("streamAllocation == null"); + state = STATE_READING_RESPONSE_BODY; + streamAllocation.noNewStreams(); + return new UnknownLengthSource(); + } + + /** + * Sets the delegate of {@code timeout} to {@link Timeout#NONE} and resets its underlying timeout + * to the default configuration. Use this to avoid unexpected sharing of timeouts between pooled + * connections. + */ + private void detachTimeout(ForwardingTimeout timeout) { + Timeout oldDelegate = timeout.delegate(); + timeout.setDelegate(Timeout.NONE); + oldDelegate.clearDeadline(); + oldDelegate.clearTimeout(); + } + + /** An HTTP body with a fixed length known in advance. */ + private final class FixedLengthSink implements Sink { + private final ForwardingTimeout timeout = new ForwardingTimeout(sink.timeout()); + private boolean closed; + private long bytesRemaining; + + private FixedLengthSink(long bytesRemaining) { + this.bytesRemaining = bytesRemaining; + } + + @Override public Timeout timeout() { + return timeout; + } + + @Override public void write(Buffer source, long byteCount) throws IOException { + if (closed) throw new IllegalStateException("closed"); + checkOffsetAndCount(source.size(), 0, byteCount); + if (byteCount > bytesRemaining) { + throw new ProtocolException("expected " + bytesRemaining + + " bytes but received " + byteCount); + } + sink.write(source, byteCount); + bytesRemaining -= byteCount; + } + + @Override public void flush() throws IOException { + if (closed) return; // Don't throw; this stream might have been closed on the caller's behalf. + sink.flush(); + } + + @Override public void close() throws IOException { + if (closed) return; + closed = true; + if (bytesRemaining > 0) throw new ProtocolException("unexpected end of stream"); + detachTimeout(timeout); + state = STATE_READ_RESPONSE_HEADERS; + } + } + + /** + * An HTTP body with alternating chunk sizes and chunk bodies. It is the caller's responsibility + * to buffer chunks; typically by using a buffered sink with this sink. + */ + private final class ChunkedSink implements Sink { + private final ForwardingTimeout timeout = new ForwardingTimeout(sink.timeout()); + private boolean closed; + + @Override public Timeout timeout() { + return timeout; + } + + @Override public void write(Buffer source, long byteCount) throws IOException { + if (closed) throw new IllegalStateException("closed"); + if (byteCount == 0) return; + + sink.writeHexadecimalUnsignedLong(byteCount); + sink.writeUtf8("\r\n"); + sink.write(source, byteCount); + sink.writeUtf8("\r\n"); + } + + @Override public synchronized void flush() throws IOException { + if (closed) return; // Don't throw; this stream might have been closed on the caller's behalf. + sink.flush(); + } + + @Override public synchronized void close() throws IOException { + if (closed) return; + closed = true; + sink.writeUtf8("0\r\n\r\n"); + detachTimeout(timeout); + state = STATE_READ_RESPONSE_HEADERS; + } + } + + private abstract class AbstractSource implements Source { + protected final ForwardingTimeout timeout = new ForwardingTimeout(source.timeout()); + protected boolean closed; + + @Override public Timeout timeout() { + return timeout; + } + + /** + * Closes the cache entry and makes the socket available for reuse. This should be invoked when + * the end of the body has been reached. + */ + protected final void endOfInput(boolean reuseConnection) throws IOException { + if (state == STATE_CLOSED) return; + if (state != STATE_READING_RESPONSE_BODY) throw new IllegalStateException("state: " + state); + + detachTimeout(timeout); + + state = STATE_CLOSED; + if (streamAllocation != null) { + streamAllocation.streamFinished(!reuseConnection, Http1xStream.this); + } + } + } + + /** An HTTP body with a fixed length specified in advance. */ + private class FixedLengthSource extends AbstractSource { + private long bytesRemaining; + + public FixedLengthSource(long length) throws IOException { + bytesRemaining = length; + if (bytesRemaining == 0) { + endOfInput(true); + } + } + + @Override public long read(Buffer sink, long byteCount) throws IOException { + if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount); + if (closed) throw new IllegalStateException("closed"); + if (bytesRemaining == 0) return -1; + + long read = source.read(sink, Math.min(bytesRemaining, byteCount)); + if (read == -1) { + endOfInput(false); // The server didn't supply the promised content length. + throw new ProtocolException("unexpected end of stream"); + } + + bytesRemaining -= read; + if (bytesRemaining == 0) { + endOfInput(true); + } + return read; + } + + @Override public void close() throws IOException { + if (closed) return; + + if (bytesRemaining != 0 && !Util.discard(this, DISCARD_STREAM_TIMEOUT_MILLIS, MILLISECONDS)) { + endOfInput(false); + } + + closed = true; + } + } + + /** An HTTP body with alternating chunk sizes and chunk bodies. */ + private class ChunkedSource extends AbstractSource { + private static final long NO_CHUNK_YET = -1L; + private long bytesRemainingInChunk = NO_CHUNK_YET; + private boolean hasMoreChunks = true; + private final HttpEngine httpEngine; + + ChunkedSource(HttpEngine httpEngine) throws IOException { + this.httpEngine = httpEngine; + } + + @Override public long read(Buffer sink, long byteCount) throws IOException { + if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount); + if (closed) throw new IllegalStateException("closed"); + if (!hasMoreChunks) return -1; + + if (bytesRemainingInChunk == 0 || bytesRemainingInChunk == NO_CHUNK_YET) { + readChunkSize(); + if (!hasMoreChunks) return -1; + } + + long read = source.read(sink, Math.min(byteCount, bytesRemainingInChunk)); + if (read == -1) { + endOfInput(false); // The server didn't supply the promised chunk length. + throw new ProtocolException("unexpected end of stream"); + } + bytesRemainingInChunk -= read; + return read; + } + + private void readChunkSize() throws IOException { + // Read the suffix of the previous chunk. + if (bytesRemainingInChunk != NO_CHUNK_YET) { + source.readUtf8LineStrict(); + } + try { + bytesRemainingInChunk = source.readHexadecimalUnsignedLong(); + String extensions = source.readUtf8LineStrict().trim(); + if (bytesRemainingInChunk < 0 || (!extensions.isEmpty() && !extensions.startsWith(";"))) { + throw new ProtocolException("expected chunk size and optional extensions but was \"" + + bytesRemainingInChunk + extensions + "\""); + } + } catch (NumberFormatException e) { + throw new ProtocolException(e.getMessage()); + } + if (bytesRemainingInChunk == 0L) { + hasMoreChunks = false; + httpEngine.receiveHeaders(readHeaders()); + endOfInput(true); + } + } + + @Override public void close() throws IOException { + if (closed) return; + if (hasMoreChunks && !Util.discard(this, DISCARD_STREAM_TIMEOUT_MILLIS, MILLISECONDS)) { + endOfInput(false); + } + closed = true; + } + } + + /** An HTTP message body terminated by the end of the underlying stream. */ + private class UnknownLengthSource extends AbstractSource { + private boolean inputExhausted; + + @Override public long read(Buffer sink, long byteCount) + throws IOException { + if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount); + if (closed) throw new IllegalStateException("closed"); + if (inputExhausted) return -1; + + long read = source.read(sink, byteCount); + if (read == -1) { + inputExhausted = true; + endOfInput(true); + return -1; + } + return read; + } + + @Override public void close() throws IOException { + if (closed) return; + if (!inputExhausted) { + endOfInput(false); + } + closed = true; + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/Http2xStream.java b/okhttp3/src/main/java/okhttp3/internal/http/Http2xStream.java new file mode 100644 index 000000000..2f91dab9b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/Http2xStream.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +import java.io.IOException; +import java.net.ProtocolException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import okhttp3.Headers; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.internal.Util; +import okhttp3.internal.framed.ErrorCode; +import okhttp3.internal.framed.FramedConnection; +import okhttp3.internal.framed.FramedStream; +import okhttp3.internal.framed.Header; +import okio.ByteString; +import okio.ForwardingSource; +import okio.Okio; +import okio.Sink; +import okio.Source; + +import static okhttp3.internal.framed.Header.RESPONSE_STATUS; +import static okhttp3.internal.framed.Header.TARGET_AUTHORITY; +import static okhttp3.internal.framed.Header.TARGET_HOST; +import static okhttp3.internal.framed.Header.TARGET_METHOD; +import static okhttp3.internal.framed.Header.TARGET_PATH; +import static okhttp3.internal.framed.Header.TARGET_SCHEME; +import static okhttp3.internal.framed.Header.VERSION; + +/** An HTTP stream for HTTP/2 and SPDY. */ +public final class Http2xStream implements HttpStream { + private static final ByteString CONNECTION = ByteString.encodeUtf8("connection"); + private static final ByteString HOST = ByteString.encodeUtf8("host"); + private static final ByteString KEEP_ALIVE = ByteString.encodeUtf8("keep-alive"); + private static final ByteString PROXY_CONNECTION = ByteString.encodeUtf8("proxy-connection"); + private static final ByteString TRANSFER_ENCODING = ByteString.encodeUtf8("transfer-encoding"); + private static final ByteString TE = ByteString.encodeUtf8("te"); + private static final ByteString ENCODING = ByteString.encodeUtf8("encoding"); + private static final ByteString UPGRADE = ByteString.encodeUtf8("upgrade"); + + /** See http://www.chromium.org/spdy/spdy-protocol/spdy-protocol-draft3-1#TOC-3.2.1-Request. */ + private static final List SPDY_3_SKIPPED_REQUEST_HEADERS = Util.immutableList( + CONNECTION, + HOST, + KEEP_ALIVE, + PROXY_CONNECTION, + TRANSFER_ENCODING, + TARGET_METHOD, + TARGET_PATH, + TARGET_SCHEME, + TARGET_AUTHORITY, + TARGET_HOST, + VERSION); + private static final List SPDY_3_SKIPPED_RESPONSE_HEADERS = Util.immutableList( + CONNECTION, + HOST, + KEEP_ALIVE, + PROXY_CONNECTION, + TRANSFER_ENCODING); + + /** See http://tools.ietf.org/html/draft-ietf-httpbis-http2-09#section-8.1.3. */ + private static final List HTTP_2_SKIPPED_REQUEST_HEADERS = Util.immutableList( + CONNECTION, + HOST, + KEEP_ALIVE, + PROXY_CONNECTION, + TE, + TRANSFER_ENCODING, + ENCODING, + UPGRADE, + TARGET_METHOD, + TARGET_PATH, + TARGET_SCHEME, + TARGET_AUTHORITY, + TARGET_HOST, + VERSION); + private static final List HTTP_2_SKIPPED_RESPONSE_HEADERS = Util.immutableList( + CONNECTION, + HOST, + KEEP_ALIVE, + PROXY_CONNECTION, + TE, + TRANSFER_ENCODING, + ENCODING, + UPGRADE); + + private final StreamAllocation streamAllocation; + private final FramedConnection framedConnection; + private HttpEngine httpEngine; + private FramedStream stream; + + public Http2xStream(StreamAllocation streamAllocation, FramedConnection framedConnection) { + this.streamAllocation = streamAllocation; + this.framedConnection = framedConnection; + } + + @Override public void setHttpEngine(HttpEngine httpEngine) { + this.httpEngine = httpEngine; + } + + @Override public Sink createRequestBody(Request request, long contentLength) throws IOException { + return stream.getSink(); + } + + @Override public void writeRequestHeaders(Request request) throws IOException { + if (stream != null) return; + + httpEngine.writingRequestHeaders(); + boolean permitsRequestBody = httpEngine.permitsRequestBody(request); + List

requestHeaders = framedConnection.getProtocol() == Protocol.HTTP_2 + ? http2HeadersList(request) + : spdy3HeadersList(request); + boolean hasResponseBody = true; + stream = framedConnection.newStream(requestHeaders, permitsRequestBody, hasResponseBody); + stream.readTimeout().timeout(httpEngine.client.readTimeoutMillis(), TimeUnit.MILLISECONDS); + stream.writeTimeout().timeout(httpEngine.client.writeTimeoutMillis(), TimeUnit.MILLISECONDS); + } + + @Override public void writeRequestBody(RetryableSink requestBody) throws IOException { + requestBody.writeToSocket(stream.getSink()); + } + + @Override public void finishRequest() throws IOException { + stream.getSink().close(); + } + + @Override public Response.Builder readResponseHeaders() throws IOException { + return framedConnection.getProtocol() == Protocol.HTTP_2 + ? readHttp2HeadersList(stream.getResponseHeaders()) + : readSpdy3HeadersList(stream.getResponseHeaders()); + } + + /** + * Returns a list of alternating names and values containing a SPDY request. Names are all + * lowercase. No names are repeated. If any name has multiple values, they are concatenated using + * "\0" as a delimiter. + */ + public static List
spdy3HeadersList(Request request) { + Headers headers = request.headers(); + List
result = new ArrayList<>(headers.size() + 5); + result.add(new Header(TARGET_METHOD, request.method())); + result.add(new Header(TARGET_PATH, RequestLine.requestPath(request.url()))); + result.add(new Header(VERSION, "HTTP/1.1")); + result.add(new Header(TARGET_HOST, Util.hostHeader(request.url()))); + result.add(new Header(TARGET_SCHEME, request.url().scheme())); + + Set names = new LinkedHashSet<>(); + for (int i = 0, size = headers.size(); i < size; i++) { + // header names must be lowercase. + ByteString name = ByteString.encodeUtf8(headers.name(i).toLowerCase(Locale.US)); + + // Drop headers that are forbidden when layering HTTP over SPDY. + if (SPDY_3_SKIPPED_REQUEST_HEADERS.contains(name)) continue; + + // If we haven't seen this name before, add the pair to the end of the list... + String value = headers.value(i); + if (names.add(name)) { + result.add(new Header(name, value)); + continue; + } + + // ...otherwise concatenate the existing values and this value. + for (int j = 0; j < result.size(); j++) { + if (result.get(j).name.equals(name)) { + String concatenated = joinOnNull(result.get(j).value.utf8(), value); + result.set(j, new Header(name, concatenated)); + break; + } + } + } + return result; + } + + private static String joinOnNull(String first, String second) { + return new StringBuilder(first).append('\0').append(second).toString(); + } + + public static List
http2HeadersList(Request request) { + Headers headers = request.headers(); + List
result = new ArrayList<>(headers.size() + 4); + result.add(new Header(TARGET_METHOD, request.method())); + result.add(new Header(TARGET_PATH, RequestLine.requestPath(request.url()))); + result.add(new Header(TARGET_AUTHORITY, Util.hostHeader(request.url()))); // Optional. + result.add(new Header(TARGET_SCHEME, request.url().scheme())); + + for (int i = 0, size = headers.size(); i < size; i++) { + // header names must be lowercase. + ByteString name = ByteString.encodeUtf8(headers.name(i).toLowerCase(Locale.US)); + if (!HTTP_2_SKIPPED_REQUEST_HEADERS.contains(name)) { + result.add(new Header(name, headers.value(i))); + } + } + return result; + } + + /** Returns headers for a name value block containing a SPDY response. */ + public static Response.Builder readSpdy3HeadersList(List
headerBlock) throws IOException { + String status = null; + String version = "HTTP/1.1"; + Headers.Builder headersBuilder = new Headers.Builder(); + for (int i = 0, size = headerBlock.size(); i < size; i++) { + ByteString name = headerBlock.get(i).name; + + String values = headerBlock.get(i).value.utf8(); + for (int start = 0; start < values.length(); ) { + int end = values.indexOf('\0', start); + if (end == -1) { + end = values.length(); + } + String value = values.substring(start, end); + if (name.equals(RESPONSE_STATUS)) { + status = value; + } else if (name.equals(VERSION)) { + version = value; + } else if (!SPDY_3_SKIPPED_RESPONSE_HEADERS.contains(name)) { + headersBuilder.add(name.utf8(), value); + } + start = end + 1; + } + } + if (status == null) throw new ProtocolException("Expected ':status' header not present"); + + StatusLine statusLine = StatusLine.parse(version + " " + status); + return new Response.Builder() + .protocol(Protocol.SPDY_3) + .code(statusLine.code) + .message(statusLine.message) + .headers(headersBuilder.build()); + } + + /** Returns headers for a name value block containing an HTTP/2 response. */ + public static Response.Builder readHttp2HeadersList(List
headerBlock) throws IOException { + String status = null; + + Headers.Builder headersBuilder = new Headers.Builder(); + for (int i = 0, size = headerBlock.size(); i < size; i++) { + ByteString name = headerBlock.get(i).name; + + String value = headerBlock.get(i).value.utf8(); + if (name.equals(RESPONSE_STATUS)) { + status = value; + } else if (!HTTP_2_SKIPPED_RESPONSE_HEADERS.contains(name)) { + headersBuilder.add(name.utf8(), value); + } + } + if (status == null) throw new ProtocolException("Expected ':status' header not present"); + + StatusLine statusLine = StatusLine.parse("HTTP/1.1 " + status); + return new Response.Builder() + .protocol(Protocol.HTTP_2) + .code(statusLine.code) + .message(statusLine.message) + .headers(headersBuilder.build()); + } + + @Override public ResponseBody openResponseBody(Response response) throws IOException { + Source source = new StreamFinishingSource(stream.getSource()); + return new RealResponseBody(response.headers(), Okio.buffer(source)); + } + + @Override public void cancel() { + if (stream != null) stream.closeLater(ErrorCode.CANCEL); + } + + class StreamFinishingSource extends ForwardingSource { + public StreamFinishingSource(Source delegate) { + super(delegate); + } + + @Override public void close() throws IOException { + streamAllocation.streamFinished(false, Http2xStream.this); + super.close(); + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/HttpDate.java b/okhttp3/src/main/java/okhttp3/internal/http/HttpDate.java new file mode 100644 index 000000000..b411a17f5 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/HttpDate.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +import java.text.DateFormat; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import static okhttp3.internal.Util.UTC; + +/** + * Best-effort parser for HTTP dates. + */ +public final class HttpDate { + /** The last four-digit year: "Fri, 31 Dec 9999 23:59:59 GMT". */ + public static final long MAX_DATE = 253402300799999L; + + /** + * Most websites serve cookies in the blessed format. Eagerly create the parser to ensure such + * cookies are on the fast path. + */ + private static final ThreadLocal STANDARD_DATE_FORMAT = + new ThreadLocal() { + @Override protected DateFormat initialValue() { + // RFC 2616 specified: RFC 822, updated by RFC 1123 format with fixed GMT. + DateFormat rfc1123 = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US); + rfc1123.setLenient(false); + rfc1123.setTimeZone(UTC); + return rfc1123; + } + }; + + /** If we fail to parse a date in a non-standard format, try each of these formats in sequence. */ + private static final String[] BROWSER_COMPATIBLE_DATE_FORMAT_STRINGS = new String[] { + // HTTP formats required by RFC2616 but with any timezone. + "EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 822, updated by RFC 1123 with any TZ + "EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850, obsoleted by RFC 1036 with any TZ. + "EEE MMM d HH:mm:ss yyyy", // ANSI C's asctime() format + // Alternative formats. + "EEE, dd-MMM-yyyy HH:mm:ss z", + "EEE, dd-MMM-yyyy HH-mm-ss z", + "EEE, dd MMM yy HH:mm:ss z", + "EEE dd-MMM-yyyy HH:mm:ss z", + "EEE dd MMM yyyy HH:mm:ss z", + "EEE dd-MMM-yyyy HH-mm-ss z", + "EEE dd-MMM-yy HH:mm:ss z", + "EEE dd MMM yy HH:mm:ss z", + "EEE,dd-MMM-yy HH:mm:ss z", + "EEE,dd-MMM-yyyy HH:mm:ss z", + "EEE, dd-MM-yyyy HH:mm:ss z", + + /* RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com */ + "EEE MMM d yyyy HH:mm:ss z", + }; + + private static final DateFormat[] BROWSER_COMPATIBLE_DATE_FORMATS = + new DateFormat[BROWSER_COMPATIBLE_DATE_FORMAT_STRINGS.length]; + + /** Returns the date for {@code value}. Returns null if the value couldn't be parsed. */ + public static Date parse(String value) { + if (value.length() == 0) { + return null; + } + + ParsePosition position = new ParsePosition(0); + Date result = STANDARD_DATE_FORMAT.get().parse(value, position); + if (position.getIndex() == value.length()) { + // STANDARD_DATE_FORMAT must match exactly; all text must be consumed, e.g. no ignored + // non-standard trailing "+01:00". Those cases are covered below. + return result; + } + synchronized (BROWSER_COMPATIBLE_DATE_FORMAT_STRINGS) { + for (int i = 0, count = BROWSER_COMPATIBLE_DATE_FORMAT_STRINGS.length; i < count; i++) { + DateFormat format = BROWSER_COMPATIBLE_DATE_FORMATS[i]; + if (format == null) { + format = new SimpleDateFormat(BROWSER_COMPATIBLE_DATE_FORMAT_STRINGS[i], Locale.US); + // Set the timezone to use when interpreting formats that don't have a timezone. GMT is + // specified by RFC 2616. + format.setTimeZone(UTC); + BROWSER_COMPATIBLE_DATE_FORMATS[i] = format; + } + position.setIndex(0); + result = format.parse(value, position); + if (position.getIndex() != 0) { + // Something was parsed. It's possible the entire string was not consumed but we ignore + // that. If any of the BROWSER_COMPATIBLE_DATE_FORMAT_STRINGS ended in "'GMT'" we'd have + // to also check that position.getIndex() == value.length() otherwise parsing might have + // terminated early, ignoring things like "+01:00". Leaving this as != 0 means that any + // trailing junk is ignored. + return result; + } + } + } + return null; + } + + /** Returns the string for {@code value}. */ + public static String format(Date value) { + return STANDARD_DATE_FORMAT.get().format(value); + } + + private HttpDate() { + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/HttpEngine.java b/okhttp3/src/main/java/okhttp3/internal/http/HttpEngine.java new file mode 100644 index 000000000..7c463b2de --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/HttpEngine.java @@ -0,0 +1,974 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +import java.io.IOException; +import java.net.ProtocolException; +import java.net.Proxy; +import java.util.Date; +import java.util.List; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSocketFactory; +import okhttp3.Address; +import okhttp3.CertificatePinner; +import okhttp3.Connection; +import okhttp3.Cookie; +import okhttp3.CookieJar; +import okhttp3.Headers; +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okhttp3.Route; +import okhttp3.internal.Internal; +import okhttp3.internal.InternalCache; +import okhttp3.internal.Version; +import okio.Buffer; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.GzipSource; +import okio.Okio; +import okio.Sink; +import okio.Source; +import okio.Timeout; + +import static java.net.HttpURLConnection.HTTP_MOVED_PERM; +import static java.net.HttpURLConnection.HTTP_MOVED_TEMP; +import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_PROXY_AUTH; +import static java.net.HttpURLConnection.HTTP_SEE_OTHER; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static okhttp3.internal.Util.closeQuietly; +import static okhttp3.internal.Util.discard; +import static okhttp3.internal.Util.hostHeader; +import static okhttp3.internal.http.StatusLine.HTTP_CONTINUE; +import static okhttp3.internal.http.StatusLine.HTTP_PERM_REDIRECT; +import static okhttp3.internal.http.StatusLine.HTTP_TEMP_REDIRECT; + +/** + * Handles a single HTTP request/response pair. Each HTTP engine follows this lifecycle:
  1. It + * is created.
  2. The HTTP request message is sent with sendRequest(). Once the request is sent it + * is an error to modify the request headers. After sendRequest() has been called the request body + * can be written to if it exists.
  3. The HTTP response message is read with readResponse(). After + * the response has been read the response headers and body can be read. All responses have a + * response body input stream, though in some instances this stream is empty.
+ * + *

The request and response may be served by the HTTP response cache, by the network, or by both + * in the event of a conditional GET. + */ +public final class HttpEngine { + /** + * How many redirects and auth challenges should we attempt? Chrome follows 21 redirects; Firefox, + * curl, and wget follow 20; Safari follows 16; and HTTP/1.0 recommends 5. + */ + public static final int MAX_FOLLOW_UPS = 20; + + private static final ResponseBody EMPTY_BODY = new ResponseBody() { + @Override public MediaType contentType() { + return null; + } + + @Override public long contentLength() { + return 0; + } + + @Override public BufferedSource source() { + return new Buffer(); + } + }; + + final OkHttpClient client; + + public final StreamAllocation streamAllocation; + private final Response priorResponse; + private HttpStream httpStream; + + /** The time when the request headers were written, or -1 if they haven't been written yet. */ + long sentRequestMillis = -1; + + /** + * True if this client added an "Accept-Encoding: gzip" header field and is therefore responsible + * for also decompressing the transfer stream. + */ + private boolean transparentGzip; + + /** + * True if the request body must be completely buffered before transmission; false if it can be + * streamed. Buffering has two advantages: we don't need the content-length in advance and we can + * retransmit if necessary. The upside of streaming is that we can save memory. + */ + public final boolean bufferRequestBody; + + /** + * The original application-provided request. Never modified by OkHttp. When follow-up requests + * are necessary, they are derived from this request. + */ + private final Request userRequest; + + /** + * The request to send on the network, or null for no network request. This is derived from the + * user request, and customized to support OkHttp features like compression and caching. + */ + private Request networkRequest; + + /** + * The cached response, or null if the cache doesn't exist or cannot be used for this request. + * Conditional caching means this may be non-null even when the network request is non-null. Never + * modified by OkHttp. + */ + private Response cacheResponse; + + /** + * The user-visible response. This is derived from either the network response, cache response, or + * both. It is customized to support OkHttp features like compression and caching. + */ + private Response userResponse; + + private Sink requestBodyOut; + private BufferedSink bufferedRequestBody; + private final boolean callerWritesRequestBody; + private final boolean forWebSocket; + + /** The cache request currently being populated from a network response. */ + private CacheRequest storeRequest; + private CacheStrategy cacheStrategy; + + /** + * @param request the HTTP request without a body. The body must be written via the engine's + * request body stream. + * @param callerWritesRequestBody true for the {@code HttpURLConnection}-style interaction model + * where control flow is returned to the calling application to write the request body before the + * response body is readable. + */ + public HttpEngine(OkHttpClient client, Request request, boolean bufferRequestBody, + boolean callerWritesRequestBody, boolean forWebSocket, StreamAllocation streamAllocation, + RetryableSink requestBodyOut, Response priorResponse) { + this.client = client; + this.userRequest = request; + this.bufferRequestBody = bufferRequestBody; + this.callerWritesRequestBody = callerWritesRequestBody; + this.forWebSocket = forWebSocket; + this.streamAllocation = streamAllocation != null + ? streamAllocation + : new StreamAllocation(client.connectionPool(), createAddress(client, request)); + this.requestBodyOut = requestBodyOut; + this.priorResponse = priorResponse; + } + + /** + * Figures out what the response source will be, and opens a socket to that source if necessary. + * Prepares the request headers and gets ready to start writing the request body if it exists. + * + * @throws RequestException if there was a problem with request setup. Unrecoverable. + * @throws RouteException if the was a problem during connection via a specific route. Sometimes + * recoverable. See {@link #recover}. + * @throws IOException if there was a problem while making a request. Sometimes recoverable. See + * {@link #recover(IOException)}. + */ + public void sendRequest() throws RequestException, RouteException, IOException { + if (cacheStrategy != null) return; // Already sent. + if (httpStream != null) throw new IllegalStateException(); + + Request request = networkRequest(userRequest); + + InternalCache responseCache = Internal.instance.internalCache(client); + Response cacheCandidate = responseCache != null + ? responseCache.get(request) + : null; + + long now = System.currentTimeMillis(); + cacheStrategy = new CacheStrategy.Factory(now, request, cacheCandidate).get(); + networkRequest = cacheStrategy.networkRequest; + cacheResponse = cacheStrategy.cacheResponse; + + if (responseCache != null) { + responseCache.trackResponse(cacheStrategy); + } + + if (cacheCandidate != null && cacheResponse == null) { + closeQuietly(cacheCandidate.body()); // The cache candidate wasn't applicable. Close it. + } + + // If we're forbidden from using the network and the cache is insufficient, fail. + if (networkRequest == null && cacheResponse == null) { + userResponse = new Response.Builder() + .request(userRequest) + .priorResponse(stripBody(priorResponse)) + .protocol(Protocol.HTTP_1_1) + .code(504) + .message("Unsatisfiable Request (only-if-cached)") + .body(EMPTY_BODY) + .build(); + return; + } + + // If we don't need the network, we're done. + if (networkRequest == null) { + userResponse = cacheResponse.newBuilder() + .request(userRequest) + .priorResponse(stripBody(priorResponse)) + .cacheResponse(stripBody(cacheResponse)) + .build(); + userResponse = unzip(userResponse); + return; + } + + // We need the network to satisfy this request. Possibly for validating a conditional GET. + boolean success = false; + try { + httpStream = connect(); + httpStream.setHttpEngine(this); + + if (writeRequestHeadersEagerly()) { + long contentLength = OkHeaders.contentLength(request); + if (bufferRequestBody) { + if (contentLength > Integer.MAX_VALUE) { + throw new IllegalStateException("Use setFixedLengthStreamingMode() or " + + "setChunkedStreamingMode() for requests larger than 2 GiB."); + } + + if (contentLength != -1) { + // Buffer a request body of a known length. + httpStream.writeRequestHeaders(networkRequest); + requestBodyOut = new RetryableSink((int) contentLength); + } else { + // Buffer a request body of an unknown length. Don't write request headers until the + // entire body is ready; otherwise we can't set the Content-Length header correctly. + requestBodyOut = new RetryableSink(); + } + } else { + httpStream.writeRequestHeaders(networkRequest); + requestBodyOut = httpStream.createRequestBody(networkRequest, contentLength); + } + } + success = true; + } finally { + // If we're crashing on I/O or otherwise, don't leak the cache body. + if (!success && cacheCandidate != null) { + closeQuietly(cacheCandidate.body()); + } + } + } + + /** + * If the caller's control flow writes the request body, we need to create that stream + * immediately. And that means we need to immediately write the request headers, so we can + * start streaming the request body. (We may already have a request body if we're retrying a + * failed POST.) + */ + private boolean writeRequestHeadersEagerly() { + return callerWritesRequestBody + && permitsRequestBody(networkRequest) + && requestBodyOut == null; + } + + private HttpStream connect() throws RouteException, RequestException, IOException { + boolean doExtensiveHealthChecks = !networkRequest.method().equals("GET"); + return streamAllocation.newStream(client.connectTimeoutMillis(), + client.readTimeoutMillis(), client.writeTimeoutMillis(), + client.retryOnConnectionFailure(), doExtensiveHealthChecks); + } + + private static Response stripBody(Response response) { + return response != null && response.body() != null + ? response.newBuilder().body(null).build() + : response; + } + + /** + * Called immediately before the transport transmits HTTP request headers. This is used to observe + * the sent time should the request be cached. + */ + public void writingRequestHeaders() { + if (sentRequestMillis != -1) throw new IllegalStateException(); + sentRequestMillis = System.currentTimeMillis(); + } + + boolean permitsRequestBody(Request request) { + return HttpMethod.permitsRequestBody(request.method()); + } + + /** Returns the request body or null if this request doesn't have a body. */ + public Sink getRequestBody() { + if (cacheStrategy == null) throw new IllegalStateException(); + return requestBodyOut; + } + + public BufferedSink getBufferedRequestBody() { + BufferedSink result = bufferedRequestBody; + if (result != null) return result; + Sink requestBody = getRequestBody(); + return requestBody != null + ? (bufferedRequestBody = Okio.buffer(requestBody)) + : null; + } + + public boolean hasResponse() { + return userResponse != null; + } + + public Request getRequest() { + return userRequest; + } + + /** Returns the engine's response. */ + // TODO: the returned body will always be null. + public Response getResponse() { + if (userResponse == null) throw new IllegalStateException(); + return userResponse; + } + + public Connection getConnection() { + return streamAllocation.connection(); + } + + /** + * Report and attempt to recover from a failure to communicate with a server. Returns a new HTTP + * engine that should be used for the retry if {@code e} is recoverable, or null if the failure is + * permanent. Requests with a body can only be recovered if the body is buffered. + */ + public HttpEngine recover(IOException e, Sink requestBodyOut) { + if (!streamAllocation.recover(e, requestBodyOut)) { + return null; + } + + if (!client.retryOnConnectionFailure()) { + return null; + } + + StreamAllocation streamAllocation = close(); + + // For failure recovery, use the same route selector with a new connection. + return new HttpEngine(client, userRequest, bufferRequestBody, callerWritesRequestBody, + forWebSocket, streamAllocation, (RetryableSink) requestBodyOut, priorResponse); + } + + public HttpEngine recover(IOException e) { + return recover(e, requestBodyOut); + } + + private void maybeCache() throws IOException { + InternalCache responseCache = Internal.instance.internalCache(client); + if (responseCache == null) return; + + // Should we cache this response for this request? + if (!CacheStrategy.isCacheable(userResponse, networkRequest)) { + if (HttpMethod.invalidatesCache(networkRequest.method())) { + try { + responseCache.remove(networkRequest); + } catch (IOException ignored) { + // The cache cannot be written. + } + } + return; + } + + // Offer this request to the cache. + storeRequest = responseCache.put(stripBody(userResponse)); + } + + /** + * Configure the socket connection to be either pooled or closed when it is either exhausted or + * closed. If it is unneeded when this is called, it will be released immediately. + */ + public void releaseStreamAllocation() throws IOException { + streamAllocation.release(); + } + + /** + * Immediately closes the socket connection if it's currently held by this engine. Use this to + * interrupt an in-flight request from any thread. It's the caller's responsibility to close the + * request body and response body streams; otherwise resources may be leaked. + * + *

This method is safe to be called concurrently, but provides limited guarantees. If a + * transport layer connection has been established (such as a HTTP/2 stream) that is terminated. + * Otherwise if a socket connection is being established, that is terminated. + */ + public void cancel() { + streamAllocation.cancel(); + } + + /** + * Release any resources held by this engine. Returns the stream allocation held by this engine, + * which itself must be used or released. + */ + public StreamAllocation close() { + if (bufferedRequestBody != null) { + // This also closes the wrapped requestBodyOut. + closeQuietly(bufferedRequestBody); + } else if (requestBodyOut != null) { + closeQuietly(requestBodyOut); + } + + if (userResponse != null) { + closeQuietly(userResponse.body()); + } else { + // If this engine never achieved a response body, its stream allocation is dead. + streamAllocation.connectionFailed(null); + } + + return streamAllocation; + } + + /** + * Returns a new response that does gzip decompression on {@code response}, if transparent gzip + * was both offered by OkHttp and used by the origin server. + * + *

In addition to decompression, this will also strip the corresponding headers. We strip the + * Content-Encoding header to prevent the application from attempting to double decompress. We + * strip the Content-Length header because it is the length of the compressed content, but the + * application is only interested in the length of the uncompressed content. + * + *

This method should only be used for non-empty response bodies. Response codes like "304 Not + * Modified" can include "Content-Encoding: gzip" without a response body and we will crash if we + * attempt to decompress the zero-byte source. + */ + private Response unzip(final Response response) throws IOException { + if (!transparentGzip || !"gzip".equalsIgnoreCase(userResponse.header("Content-Encoding"))) { + return response; + } + + if (response.body() == null) { + return response; + } + + GzipSource responseBody = new GzipSource(response.body().source()); + Headers strippedHeaders = response.headers().newBuilder() + .removeAll("Content-Encoding") + .removeAll("Content-Length") + .build(); + return response.newBuilder() + .headers(strippedHeaders) + .body(new RealResponseBody(strippedHeaders, Okio.buffer(responseBody))) + .build(); + } + + /** + * Returns true if the response must have a (possibly 0-length) body. See RFC 2616 section 4.3. + */ + public static boolean hasBody(Response response) { + // HEAD requests never yield a body regardless of the response headers. + if (response.request().method().equals("HEAD")) { + return false; + } + + int responseCode = response.code(); + if ((responseCode < HTTP_CONTINUE || responseCode >= 200) + && responseCode != HTTP_NO_CONTENT + && responseCode != HTTP_NOT_MODIFIED) { + return true; + } + + // If the Content-Length or Transfer-Encoding headers disagree with the + // response code, the response is malformed. For best compatibility, we + // honor the headers. + if (OkHeaders.contentLength(response) != -1 + || "chunked".equalsIgnoreCase(response.header("Transfer-Encoding"))) { + return true; + } + + return false; + } + + /** + * Populates request with defaults and cookies. + * + *

This client doesn't specify a default {@code Accept} header because it doesn't know what + * content types the application is interested in. + */ + private Request networkRequest(Request request) throws IOException { + Request.Builder result = request.newBuilder(); + + if (request.header("Host") == null) { + result.header("Host", hostHeader(request.url())); + } + + if (request.header("Connection") == null) { + result.header("Connection", "Keep-Alive"); + } + + if (request.header("Accept-Encoding") == null) { + transparentGzip = true; + result.header("Accept-Encoding", "gzip"); + } + + List cookies = client.cookieJar().loadForRequest(request.url(), request); + if (!cookies.isEmpty()) { + result.header("Cookie", cookieHeader(cookies)); + } + + if (request.header("User-Agent") == null) { + result.header("User-Agent", Version.userAgent()); + } + + return result.build(); + } + + /** Returns a 'Cookie' HTTP request header with all cookies, like {@code a=b; c=d}. */ + private String cookieHeader(List cookies) { + StringBuilder cookieHeader = new StringBuilder(); + for (int i = 0, size = cookies.size(); i < size; i++) { + if (i > 0) { + cookieHeader.append("; "); + } + Cookie cookie = cookies.get(i); + cookieHeader.append(cookie.name()).append('=').append(cookie.value()); + } + return cookieHeader.toString(); + } + + /** + * Flushes the remaining request header and body, parses the HTTP response headers and starts + * reading the HTTP response body if it exists. + */ + public void readResponse() throws IOException { + if (userResponse != null) { + return; // Already ready. + } + if (networkRequest == null && cacheResponse == null) { + throw new IllegalStateException("call sendRequest() first!"); + } + if (networkRequest == null) { + return; // No network response to read. + } + + Response networkResponse; + + if (forWebSocket) { + httpStream.writeRequestHeaders(networkRequest); + networkResponse = readNetworkResponse(); + } else if (!callerWritesRequestBody) { + networkResponse = new NetworkInterceptorChain(0, networkRequest).proceed(networkRequest); + } else { + // Emit the request body's buffer so that everything is in requestBodyOut. + if (bufferedRequestBody != null && bufferedRequestBody.buffer().size() > 0) { + bufferedRequestBody.emit(); + } + + // Emit the request headers if we haven't yet. We might have just learned the Content-Length. + if (sentRequestMillis == -1) { + if (OkHeaders.contentLength(networkRequest) == -1 + && requestBodyOut instanceof RetryableSink) { + long contentLength = ((RetryableSink) requestBodyOut).contentLength(); + networkRequest = networkRequest.newBuilder() + .header("Content-Length", Long.toString(contentLength)) + .build(); + } + httpStream.writeRequestHeaders(networkRequest); + } + + // Write the request body to the socket. + if (requestBodyOut != null) { + if (bufferedRequestBody != null) { + // This also closes the wrapped requestBodyOut. + bufferedRequestBody.close(); + } else { + requestBodyOut.close(); + } + if (requestBodyOut instanceof RetryableSink) { + httpStream.writeRequestBody((RetryableSink) requestBodyOut); + } + } + + networkResponse = readNetworkResponse(); + } + + receiveHeaders(networkResponse.headers()); + + // If we have a cache response too, then we're doing a conditional get. + if (cacheResponse != null) { + if (validate(cacheResponse, networkResponse)) { + userResponse = cacheResponse.newBuilder() + .request(userRequest) + .priorResponse(stripBody(priorResponse)) + .headers(combine(cacheResponse.headers(), networkResponse.headers())) + .cacheResponse(stripBody(cacheResponse)) + .networkResponse(stripBody(networkResponse)) + .build(); + networkResponse.body().close(); + releaseStreamAllocation(); + + // Update the cache after combining headers but before stripping the + // Content-Encoding header (as performed by initContentStream()). + InternalCache responseCache = Internal.instance.internalCache(client); + responseCache.trackConditionalCacheHit(); + responseCache.update(cacheResponse, stripBody(userResponse)); + userResponse = unzip(userResponse); + return; + } else { + closeQuietly(cacheResponse.body()); + } + } + + userResponse = networkResponse.newBuilder() + .request(userRequest) + .priorResponse(stripBody(priorResponse)) + .cacheResponse(stripBody(cacheResponse)) + .networkResponse(stripBody(networkResponse)) + .build(); + + if (hasBody(userResponse)) { + maybeCache(); + userResponse = unzip(cacheWritingResponse(storeRequest, userResponse)); + } + } + + class NetworkInterceptorChain implements Interceptor.Chain { + private final int index; + private final Request request; + private int calls; + + NetworkInterceptorChain(int index, Request request) { + this.index = index; + this.request = request; + } + + @Override public Connection connection() { + return streamAllocation.connection(); + } + + @Override public Request request() { + return request; + } + + @Override public Response proceed(Request request) throws IOException { + calls++; + + if (index > 0) { + Interceptor caller = client.networkInterceptors().get(index - 1); + Address address = connection().route().address(); + + // Confirm that the interceptor uses the connection we've already prepared. + if (!request.url().host().equals(address.url().host()) + || request.url().port() != address.url().port()) { + throw new IllegalStateException("network interceptor " + caller + + " must retain the same host and port"); + } + + // Confirm that this is the interceptor's first call to chain.proceed(). + if (calls > 1) { + throw new IllegalStateException("network interceptor " + caller + + " must call proceed() exactly once"); + } + } + + if (index < client.networkInterceptors().size()) { + // There's another interceptor in the chain. Call that. + NetworkInterceptorChain chain = new NetworkInterceptorChain(index + 1, request); + Interceptor interceptor = client.networkInterceptors().get(index); + Response interceptedResponse = interceptor.intercept(chain); + + // Confirm that the interceptor made the required call to chain.proceed(). + if (chain.calls != 1) { + throw new IllegalStateException("network interceptor " + interceptor + + " must call proceed() exactly once"); + } + if (interceptedResponse == null) { + throw new NullPointerException("network interceptor " + interceptor + + " returned null"); + } + + return interceptedResponse; + } + + httpStream.writeRequestHeaders(request); + + //Update the networkRequest with the possibly updated interceptor request. + networkRequest = request; + + if (permitsRequestBody(request) && request.body() != null) { + Sink requestBodyOut = httpStream.createRequestBody(request, request.body().contentLength()); + BufferedSink bufferedRequestBody = Okio.buffer(requestBodyOut); + request.body().writeTo(bufferedRequestBody); + bufferedRequestBody.close(); + } + + Response response = readNetworkResponse(); + + int code = response.code(); + if ((code == 204 || code == 205) && response.body().contentLength() > 0) { + throw new ProtocolException( + "HTTP " + code + " had non-zero Content-Length: " + response.body().contentLength()); + } + + return response; + } + } + + private Response readNetworkResponse() throws IOException { + httpStream.finishRequest(); + + Response networkResponse = httpStream.readResponseHeaders() + .request(networkRequest) + .handshake(streamAllocation.connection().handshake()) + .header(OkHeaders.SENT_MILLIS, Long.toString(sentRequestMillis)) + .header(OkHeaders.RECEIVED_MILLIS, Long.toString(System.currentTimeMillis())) + .build(); + + if (!forWebSocket) { + networkResponse = networkResponse.newBuilder() + .body(httpStream.openResponseBody(networkResponse)) + .build(); + } + + if ("close".equalsIgnoreCase(networkResponse.request().header("Connection")) + || "close".equalsIgnoreCase(networkResponse.header("Connection"))) { + streamAllocation.noNewStreams(); + } + + return networkResponse; + } + + /** + * Returns a new source that writes bytes to {@code cacheRequest} as they are read by the source + * consumer. This is careful to discard bytes left over when the stream is closed; otherwise we + * may never exhaust the source stream and therefore not complete the cached response. + */ + private Response cacheWritingResponse(final CacheRequest cacheRequest, Response response) + throws IOException { + // Some apps return a null body; for compatibility we treat that like a null cache request. + if (cacheRequest == null) return response; + Sink cacheBodyUnbuffered = cacheRequest.body(); + if (cacheBodyUnbuffered == null) return response; + + final BufferedSource source = response.body().source(); + final BufferedSink cacheBody = Okio.buffer(cacheBodyUnbuffered); + + Source cacheWritingSource = new Source() { + boolean cacheRequestClosed; + + @Override public long read(Buffer sink, long byteCount) throws IOException { + long bytesRead; + try { + bytesRead = source.read(sink, byteCount); + } catch (IOException e) { + if (!cacheRequestClosed) { + cacheRequestClosed = true; + cacheRequest.abort(); // Failed to write a complete cache response. + } + throw e; + } + + if (bytesRead == -1) { + if (!cacheRequestClosed) { + cacheRequestClosed = true; + cacheBody.close(); // The cache response is complete! + } + return -1; + } + + sink.copyTo(cacheBody.buffer(), sink.size() - bytesRead, bytesRead); + cacheBody.emitCompleteSegments(); + return bytesRead; + } + + @Override public Timeout timeout() { + return source.timeout(); + } + + @Override public void close() throws IOException { + if (!cacheRequestClosed + && !discard(this, HttpStream.DISCARD_STREAM_TIMEOUT_MILLIS, MILLISECONDS)) { + cacheRequestClosed = true; + cacheRequest.abort(); + } + source.close(); + } + }; + + return response.newBuilder() + .body(new RealResponseBody(response.headers(), Okio.buffer(cacheWritingSource))) + .build(); + } + + /** + * Returns true if {@code cached} should be used; false if {@code network} response should be + * used. + */ + private static boolean validate(Response cached, Response network) { + if (network.code() == HTTP_NOT_MODIFIED) { + return true; + } + + // The HTTP spec says that if the network's response is older than our + // cached response, we may return the cache's response. Like Chrome (but + // unlike Firefox), this client prefers to return the newer response. + Date lastModified = cached.headers().getDate("Last-Modified"); + if (lastModified != null) { + Date networkLastModified = network.headers().getDate("Last-Modified"); + if (networkLastModified != null + && networkLastModified.getTime() < lastModified.getTime()) { + return true; + } + } + + return false; + } + + /** + * Combines cached headers with a network headers as defined by RFC 2616, 13.5.3. + */ + private static Headers combine(Headers cachedHeaders, Headers networkHeaders) throws IOException { + Headers.Builder result = new Headers.Builder(); + + for (int i = 0, size = cachedHeaders.size(); i < size; i++) { + String fieldName = cachedHeaders.name(i); + String value = cachedHeaders.value(i); + if ("Warning".equalsIgnoreCase(fieldName) && value.startsWith("1")) { + continue; // Drop 100-level freshness warnings. + } + if (!OkHeaders.isEndToEnd(fieldName) || networkHeaders.get(fieldName) == null) { + result.add(fieldName, value); + } + } + + for (int i = 0, size = networkHeaders.size(); i < size; i++) { + String fieldName = networkHeaders.name(i); + if ("Content-Length".equalsIgnoreCase(fieldName)) { + continue; // Ignore content-length headers of validating responses. + } + if (OkHeaders.isEndToEnd(fieldName)) { + result.add(fieldName, networkHeaders.value(i)); + } + } + + return result.build(); + } + + public void receiveHeaders(Headers headers) throws IOException { + if (client.cookieJar() == CookieJar.NO_COOKIES) return; + + List cookies = Cookie.parseAll(userRequest.url(), headers); + if (cookies.isEmpty()) return; + + client.cookieJar().saveFromResponse(userRequest.url(), cookies); + } + + /** + * Figures out the HTTP request to make in response to receiving this engine's response. This will + * either add authentication headers or follow redirects. If a follow-up is either unnecessary or + * not applicable, this returns null. + */ + public Request followUpRequest() throws IOException { + if (userResponse == null) throw new IllegalStateException(); + Connection connection = streamAllocation.connection(); + Route route = connection != null + ? connection.route() + : null; + int responseCode = userResponse.code(); + + final String method = userRequest.method(); + switch (responseCode) { + case HTTP_PROXY_AUTH: + Proxy selectedProxy = route != null + ? route.proxy() + : client.proxy(); + if (selectedProxy.type() != Proxy.Type.HTTP) { + throw new ProtocolException("Received HTTP_PROXY_AUTH (407) code while not using proxy"); + } + // fall-through + case HTTP_UNAUTHORIZED: + return client.authenticator().authenticate(route, userResponse); + + case HTTP_PERM_REDIRECT: + case HTTP_TEMP_REDIRECT: + // "If the 307 or 308 status code is received in response to a request other than GET + // or HEAD, the user agent MUST NOT automatically redirect the request" + if (!method.equals("GET") && !method.equals("HEAD")) { + return null; + } + // fall-through + case HTTP_MULT_CHOICE: + case HTTP_MOVED_PERM: + case HTTP_MOVED_TEMP: + case HTTP_SEE_OTHER: + // Does the client allow redirects? + if (!client.followRedirects()) return null; + + String location = userResponse.header("Location"); + if (location == null) return null; + HttpUrl url = userRequest.url().resolve(location); + + // Don't follow redirects to unsupported protocols. + if (url == null) return null; + + // If configured, don't follow redirects between SSL and non-SSL. + boolean sameScheme = url.scheme().equals(userRequest.url().scheme()); + if (!sameScheme && !client.followSslRedirects()) return null; + + // Redirects don't include a request body. + Request.Builder requestBuilder = userRequest.newBuilder(); + if (HttpMethod.permitsRequestBody(method)) { + if (HttpMethod.redirectsToGet(method)) { + requestBuilder.method("GET", null); + } else { + requestBuilder.method(method, null); + } + requestBuilder.removeHeader("Transfer-Encoding"); + requestBuilder.removeHeader("Content-Length"); + requestBuilder.removeHeader("Content-Type"); + } + + // When redirecting across hosts, drop all authentication headers. This + // is potentially annoying to the application layer since they have no + // way to retain them. + if (!sameConnection(url)) { + requestBuilder.removeHeader("Authorization"); + } + + return requestBuilder.url(url).build(); + + default: + return null; + } + } + + /** + * Returns true if an HTTP request for {@code followUp} can reuse the connection used by this + * engine. + */ + public boolean sameConnection(HttpUrl followUp) { + HttpUrl url = userRequest.url(); + return url.host().equals(followUp.host()) + && url.port() == followUp.port() + && url.scheme().equals(followUp.scheme()); + } + + private static Address createAddress(OkHttpClient client, Request request) { + SSLSocketFactory sslSocketFactory = null; + HostnameVerifier hostnameVerifier = null; + CertificatePinner certificatePinner = null; + if (request.isHttps()) { + sslSocketFactory = client.sslSocketFactory(); + hostnameVerifier = client.hostnameVerifier(); + certificatePinner = client.certificatePinner(); + } + + return new Address(request.url().host(), request.url().port(), client.dns(), + client.socketFactory(), sslSocketFactory, hostnameVerifier, certificatePinner, + client.proxyAuthenticator(), client.proxy(), client.protocols(), + client.connectionSpecs(), client.proxySelector()); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/HttpMethod.java b/okhttp3/src/main/java/okhttp3/internal/http/HttpMethod.java new file mode 100644 index 000000000..3629810c0 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/HttpMethod.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +public final class HttpMethod { + public static boolean invalidatesCache(String method) { + return method.equals("POST") + || method.equals("PATCH") + || method.equals("PUT") + || method.equals("DELETE") + || method.equals("MOVE"); // WebDAV + } + + public static boolean requiresRequestBody(String method) { + return method.equals("POST") + || method.equals("PUT") + || method.equals("PATCH") + || method.equals("PROPPATCH") // WebDAV + || method.equals("REPORT"); // CalDAV/CardDAV (defined in WebDAV Versioning) + } + + public static boolean permitsRequestBody(String method) { + return requiresRequestBody(method) + || method.equals("OPTIONS") + || method.equals("DELETE") // Permitted as spec is ambiguous. + || method.equals("PROPFIND") // (WebDAV) without body: request + || method.equals("MKCOL") // (WebDAV) may contain a body, but behaviour is unspecified + || method.equals("LOCK"); // (WebDAV) body: create lock, without body: refresh lock + } + + public static boolean redirectsToGet(String method) { + // All requests but PROPFIND should redirect to a GET request. + return !method.equals("PROPFIND"); + } + + private HttpMethod() { + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/HttpStream.java b/okhttp3/src/main/java/okhttp3/internal/http/HttpStream.java new file mode 100644 index 000000000..d5fc94c7b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/HttpStream.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +import java.io.IOException; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Sink; + +public interface HttpStream { + /** + * The timeout to use while discarding a stream of input data. Since this is used for connection + * reuse, this timeout should be significantly less than the time it takes to establish a new + * connection. + */ + int DISCARD_STREAM_TIMEOUT_MILLIS = 100; + + /** Returns an output stream where the request body can be streamed. */ + Sink createRequestBody(Request request, long contentLength) throws IOException; + + /** This should update the HTTP engine's sentRequestMillis field. */ + void writeRequestHeaders(Request request) throws IOException; + + /** + * Sends the request body returned by {@link #createRequestBody} to the remote peer. + */ + void writeRequestBody(RetryableSink requestBody) throws IOException; + + /** Flush the request to the underlying socket. */ + void finishRequest() throws IOException; + + /** Read and return response headers. */ + Response.Builder readResponseHeaders() throws IOException; + + /** Returns a stream that reads the response body. */ + ResponseBody openResponseBody(Response response) throws IOException; + + void setHttpEngine(HttpEngine httpEngine); + + /** + * Cancel this stream. Resources held by this stream will be cleaned up, though not synchronously. + * That may happen later by the connection pool thread. + */ + void cancel(); +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/OkHeaders.java b/okhttp3/src/main/java/okhttp3/internal/http/OkHeaders.java new file mode 100644 index 000000000..db8011c54 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/OkHeaders.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import okhttp3.Challenge; +import okhttp3.Headers; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.internal.Platform; + +import static okhttp3.internal.Util.equal; + +/** Headers and utilities for internal use by OkHttp. */ +public final class OkHeaders { + + static final String PREFIX = Platform.get().getPrefix(); + + /** + * Synthetic response header: the local time when the request was sent. + */ + public static final String SENT_MILLIS = PREFIX + "-Sent-Millis"; + + /** + * Synthetic response header: the local time when the response was received. + */ + public static final String RECEIVED_MILLIS = PREFIX + "-Received-Millis"; + + /** + * Synthetic response header: the selected {@link Protocol protocol} ("spdy/3.1", "http/1.1", + * etc). + */ + public static final String SELECTED_PROTOCOL = PREFIX + "-Selected-Protocol"; + + /** Synthetic response header: the location from which the response was loaded. */ + public static final String RESPONSE_SOURCE = PREFIX + "-Response-Source"; + + private OkHeaders() { + } + + public static long contentLength(Request request) { + return contentLength(request.headers()); + } + + public static long contentLength(Response response) { + return contentLength(response.headers()); + } + + public static long contentLength(Headers headers) { + return stringToLong(headers.get("Content-Length")); + } + + private static long stringToLong(String s) { + if (s == null) return -1; + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * Returns true if none of the Vary headers have changed between {@code cachedRequest} and {@code + * newRequest}. + */ + public static boolean varyMatches( + Response cachedResponse, Headers cachedRequest, Request newRequest) { + for (String field : varyFields(cachedResponse)) { + if (!equal(cachedRequest.values(field), newRequest.headers(field))) return false; + } + return true; + } + + /** + * Returns true if a Vary header contains an asterisk. Such responses cannot be cached. + */ + public static boolean hasVaryAll(Response response) { + return hasVaryAll(response.headers()); + } + + /** + * Returns true if a Vary header contains an asterisk. Such responses cannot be cached. + */ + public static boolean hasVaryAll(Headers responseHeaders) { + return varyFields(responseHeaders).contains("*"); + } + + private static Set varyFields(Response response) { + return varyFields(response.headers()); + } + + /** + * Returns the names of the request headers that need to be checked for equality when caching. + */ + public static Set varyFields(Headers responseHeaders) { + Set result = Collections.emptySet(); + for (int i = 0, size = responseHeaders.size(); i < size; i++) { + if (!"Vary".equalsIgnoreCase(responseHeaders.name(i))) continue; + + String value = responseHeaders.value(i); + if (result.isEmpty()) { + result = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + } + for (String varyField : value.split(",")) { + result.add(varyField.trim()); + } + } + return result; + } + + /** + * Returns the subset of the headers in {@code response}'s request that impact the content of + * response's body. + */ + public static Headers varyHeaders(Response response) { + // Use the request headers sent over the network, since that's what the + // response varies on. Otherwise OkHttp-supplied headers like + // "Accept-Encoding: gzip" may be lost. + Headers requestHeaders = response.networkResponse().request().headers(); + Headers responseHeaders = response.headers(); + return varyHeaders(requestHeaders, responseHeaders); + } + + /** + * Returns the subset of the headers in {@code requestHeaders} that impact the content of + * response's body. + */ + public static Headers varyHeaders(Headers requestHeaders, Headers responseHeaders) { + Set varyFields = varyFields(responseHeaders); + if (varyFields.isEmpty()) return new Headers.Builder().build(); + + Headers.Builder result = new Headers.Builder(); + for (int i = 0, size = requestHeaders.size(); i < size; i++) { + String fieldName = requestHeaders.name(i); + if (varyFields.contains(fieldName)) { + result.add(fieldName, requestHeaders.value(i)); + } + } + return result.build(); + } + + /** + * Returns true if {@code fieldName} is an end-to-end HTTP header, as defined by RFC 2616, + * 13.5.1. + */ + static boolean isEndToEnd(String fieldName) { + return !"Connection".equalsIgnoreCase(fieldName) + && !"Keep-Alive".equalsIgnoreCase(fieldName) + && !"Proxy-Authenticate".equalsIgnoreCase(fieldName) + && !"Proxy-Authorization".equalsIgnoreCase(fieldName) + && !"TE".equalsIgnoreCase(fieldName) + && !"Trailers".equalsIgnoreCase(fieldName) + && !"Transfer-Encoding".equalsIgnoreCase(fieldName) + && !"Upgrade".equalsIgnoreCase(fieldName); + } + + /** + * Parse RFC 2617 challenges. This API is only interested in the scheme name and realm. + */ + public static List parseChallenges(Headers responseHeaders, String challengeHeader) { + // auth-scheme = token + // auth-param = token "=" ( token | quoted-string ) + // challenge = auth-scheme 1*SP 1#auth-param + // realm = "realm" "=" realm-value + // realm-value = quoted-string + List result = new ArrayList<>(); + for (int i = 0, size = responseHeaders.size(); i < size; i++) { + if (!challengeHeader.equalsIgnoreCase(responseHeaders.name(i))) { + continue; + } + String value = responseHeaders.value(i); + int pos = 0; + while (pos < value.length()) { + int tokenStart = pos; + pos = HeaderParser.skipUntil(value, pos, " "); + + String scheme = value.substring(tokenStart, pos).trim(); + pos = HeaderParser.skipWhitespace(value, pos); + + // TODO: This currently only handles schemes with a 'realm' parameter; + // It needs to be fixed to handle any scheme and any parameters + // http://code.google.com/p/android/issues/detail?id=11140 + + if (!value.regionMatches(true, pos, "realm=\"", 0, "realm=\"".length())) { + break; // Unexpected challenge parameter; give up! + } + + pos += "realm=\"".length(); + int realmStart = pos; + pos = HeaderParser.skipUntil(value, pos, "\""); + String realm = value.substring(realmStart, pos); + pos++; // Consume '"' close quote. + pos = HeaderParser.skipUntil(value, pos, ","); + pos++; // Consume ',' comma. + pos = HeaderParser.skipWhitespace(value, pos); + result.add(new Challenge(scheme, realm)); + } + } + return result; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/RealResponseBody.java b/okhttp3/src/main/java/okhttp3/internal/http/RealResponseBody.java new file mode 100644 index 000000000..d66fed988 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/RealResponseBody.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.ResponseBody; +import okio.BufferedSource; + +public final class RealResponseBody extends ResponseBody { + private final Headers headers; + private final BufferedSource source; + + public RealResponseBody(Headers headers, BufferedSource source) { + this.headers = headers; + this.source = source; + } + + @Override public MediaType contentType() { + String contentType = headers.get("Content-Type"); + return contentType != null ? MediaType.parse(contentType) : null; + } + + @Override public long contentLength() { + return OkHeaders.contentLength(headers); + } + + @Override public BufferedSource source() { + return source; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/RequestException.java b/okhttp3/src/main/java/okhttp3/internal/http/RequestException.java new file mode 100644 index 000000000..56884c811 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/RequestException.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import java.io.IOException; + +/** + * Indicates a problem with interpreting a request. It may indicate there was a problem with the + * request itself, or the environment being used to interpret the request (network failure, etc.). + */ +public final class RequestException extends Exception { + + public RequestException(IOException cause) { + super(cause); + } + + @Override + public IOException getCause() { + return (IOException) super.getCause(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/RequestLine.java b/okhttp3/src/main/java/okhttp3/internal/http/RequestLine.java new file mode 100644 index 000000000..ff2bc5598 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/RequestLine.java @@ -0,0 +1,49 @@ +package okhttp3.internal.http; + +import java.net.HttpURLConnection; +import java.net.Proxy; +import okhttp3.HttpUrl; +import okhttp3.Request; + +public final class RequestLine { + private RequestLine() { + } + + /** + * Returns the request status line, like "GET / HTTP/1.1". This is exposed to the application by + * {@link HttpURLConnection#getHeaderFields}, so it needs to be set even if the transport is + * SPDY. + */ + static String get(Request request, Proxy.Type proxyType) { + StringBuilder result = new StringBuilder(); + result.append(request.method()); + result.append(' '); + + if (includeAuthorityInRequestLine(request, proxyType)) { + result.append(request.url()); + } else { + result.append(requestPath(request.url())); + } + + result.append(" HTTP/1.1"); + return result.toString(); + } + + /** + * Returns true if the request line should contain the full URL with host and port (like "GET + * http://android.com/foo HTTP/1.1") or only the path (like "GET /foo HTTP/1.1"). + */ + private static boolean includeAuthorityInRequestLine(Request request, Proxy.Type proxyType) { + return !request.isHttps() && proxyType == Proxy.Type.HTTP; + } + + /** + * Returns the path to request, like the '/' in 'GET / HTTP/1.1'. Never empty, even if the request + * URL is. Includes the query component if it exists. + */ + public static String requestPath(HttpUrl url) { + String path = url.encodedPath(); + String query = url.encodedQuery(); + return query != null ? (path + '?' + query) : path; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/RetryableSink.java b/okhttp3/src/main/java/okhttp3/internal/http/RetryableSink.java new file mode 100644 index 000000000..8fb13a8a7 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/RetryableSink.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.http; + +import java.io.IOException; +import java.net.ProtocolException; +import okio.Buffer; +import okio.Sink; +import okio.Timeout; + +import static okhttp3.internal.Util.checkOffsetAndCount; + +/** + * An HTTP request body that's completely buffered in memory. This allows the post body to be + * transparently re-sent if the HTTP request must be sent multiple times. + */ +public final class RetryableSink implements Sink { + private boolean closed; + private final int limit; + private final Buffer content = new Buffer(); + + public RetryableSink(int limit) { + this.limit = limit; + } + + public RetryableSink() { + this(-1); + } + + @Override public void close() throws IOException { + if (closed) return; + closed = true; + if (content.size() < limit) { + throw new ProtocolException( + "content-length promised " + limit + " bytes, but received " + content.size()); + } + } + + @Override public void write(Buffer source, long byteCount) throws IOException { + if (closed) throw new IllegalStateException("closed"); + checkOffsetAndCount(source.size(), 0, byteCount); + if (limit != -1 && content.size() > limit - byteCount) { + throw new ProtocolException("exceeded content-length limit of " + limit + " bytes"); + } + content.write(source, byteCount); + } + + @Override public void flush() throws IOException { + } + + @Override public Timeout timeout() { + return Timeout.NONE; + } + + public long contentLength() throws IOException { + return content.size(); + } + + public void writeToSocket(Sink socketOut) throws IOException { + // Copy the content; otherwise we won't have data to retry. + Buffer buffer = new Buffer(); + content.copyTo(buffer, 0, content.size()); + socketOut.write(buffer, buffer.size()); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/RouteException.java b/okhttp3/src/main/java/okhttp3/internal/http/RouteException.java new file mode 100644 index 000000000..030b346a5 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/RouteException.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * An exception thrown to indicate a problem connecting via a single Route. Multiple attempts may + * have been made with alternative protocols, none of which were successful. + */ +public final class RouteException extends Exception { + private static final Method addSuppressedExceptionMethod; + + static { + Method m; + try { + m = Throwable.class.getDeclaredMethod("addSuppressed", Throwable.class); + } catch (Exception e) { + m = null; + } + addSuppressedExceptionMethod = m; + } + + private IOException lastException; + + public RouteException(IOException cause) { + super(cause); + lastException = cause; + } + + public IOException getLastConnectException() { + return lastException; + } + + public void addConnectException(IOException e) { + addSuppressedIfPossible(e, lastException); + lastException = e; + } + + private void addSuppressedIfPossible(IOException e, IOException suppressed) { + if (addSuppressedExceptionMethod != null) { + try { + addSuppressedExceptionMethod.invoke(e, suppressed); + } catch (InvocationTargetException | IllegalAccessException ignored) { + } + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/RouteSelector.java b/okhttp3/src/main/java/okhttp3/internal/http/RouteSelector.java new file mode 100644 index 000000000..3b5725cbe --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/RouteSelector.java @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2012 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.SocketAddress; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import okhttp3.Address; +import okhttp3.HttpUrl; +import okhttp3.Route; +import okhttp3.internal.RouteDatabase; + +/** + * Selects routes to connect to an origin server. Each connection requires a choice of proxy server, + * IP address, and TLS mode. Connections may also be recycled. + */ +public final class RouteSelector { + private final Address address; + private final RouteDatabase routeDatabase; + + /* The most recently attempted route. */ + private Proxy lastProxy; + private InetSocketAddress lastInetSocketAddress; + + /* State for negotiating the next proxy to use. */ + private List proxies = Collections.emptyList(); + private int nextProxyIndex; + + /* State for negotiating the next socket address to use. */ + private List inetSocketAddresses = Collections.emptyList(); + private int nextInetSocketAddressIndex; + + /* State for negotiating failed routes */ + private final List postponedRoutes = new ArrayList<>(); + + public RouteSelector(Address address, RouteDatabase routeDatabase) { + this.address = address; + this.routeDatabase = routeDatabase; + + resetNextProxy(address.url(), address.proxy()); + } + + /** + * Returns true if there's another route to attempt. Every address has at least one route. + */ + public boolean hasNext() { + return hasNextInetSocketAddress() + || hasNextProxy() + || hasNextPostponed(); + } + + public Route next() throws IOException { + // Compute the next route to attempt. + if (!hasNextInetSocketAddress()) { + if (!hasNextProxy()) { + if (!hasNextPostponed()) { + throw new NoSuchElementException(); + } + return nextPostponed(); + } + lastProxy = nextProxy(); + } + lastInetSocketAddress = nextInetSocketAddress(); + + Route route = new Route(address, lastProxy, lastInetSocketAddress); + if (routeDatabase.shouldPostpone(route)) { + postponedRoutes.add(route); + // We will only recurse in order to skip previously failed routes. They will be tried last. + return next(); + } + + return route; + } + + /** + * Clients should invoke this method when they encounter a connectivity failure on a connection + * returned by this route selector. + */ + public void connectFailed(Route failedRoute, IOException failure) { + if (failedRoute.proxy().type() != Proxy.Type.DIRECT && address.proxySelector() != null) { + // Tell the proxy selector when we fail to connect on a fresh connection. + address.proxySelector().connectFailed( + address.url().uri(), failedRoute.proxy().address(), failure); + } + + routeDatabase.failed(failedRoute); + } + + /** Prepares the proxy servers to try. */ + private void resetNextProxy(HttpUrl url, Proxy proxy) { + if (proxy != null) { + // If the user specifies a proxy, try that and only that. + proxies = Collections.singletonList(proxy); + } else { + // Try each of the ProxySelector choices until one connection succeeds. If none succeed + // then we'll try a direct connection below. + proxies = new ArrayList<>(); + List selectedProxies = address.proxySelector().select(url.uri()); + if (selectedProxies != null) proxies.addAll(selectedProxies); + // Finally try a direct connection. We only try it once! + proxies.removeAll(Collections.singleton(Proxy.NO_PROXY)); + proxies.add(Proxy.NO_PROXY); + } + nextProxyIndex = 0; + } + + /** Returns true if there's another proxy to try. */ + private boolean hasNextProxy() { + return nextProxyIndex < proxies.size(); + } + + /** Returns the next proxy to try. May be PROXY.NO_PROXY but never null. */ + private Proxy nextProxy() throws IOException { + if (!hasNextProxy()) { + throw new SocketException("No route to " + address.url().host() + + "; exhausted proxy configurations: " + proxies); + } + Proxy result = proxies.get(nextProxyIndex++); + resetNextInetSocketAddress(result); + return result; + } + + /** Prepares the socket addresses to attempt for the current proxy or host. */ + private void resetNextInetSocketAddress(Proxy proxy) throws IOException { + // Clear the addresses. Necessary if getAllByName() below throws! + inetSocketAddresses = new ArrayList<>(); + + String socketHost; + int socketPort; + if (proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.SOCKS) { + socketHost = address.url().host(); + socketPort = address.url().port(); + } else { + SocketAddress proxyAddress = proxy.address(); + if (!(proxyAddress instanceof InetSocketAddress)) { + throw new IllegalArgumentException( + "Proxy.address() is not an " + "InetSocketAddress: " + proxyAddress.getClass()); + } + InetSocketAddress proxySocketAddress = (InetSocketAddress) proxyAddress; + socketHost = getHostString(proxySocketAddress); + socketPort = proxySocketAddress.getPort(); + } + + if (socketPort < 1 || socketPort > 65535) { + throw new SocketException("No route to " + socketHost + ":" + socketPort + + "; port is out of range"); + } + + if (proxy.type() == Proxy.Type.SOCKS) { + inetSocketAddresses.add(InetSocketAddress.createUnresolved(socketHost, socketPort)); + } else { + // Try each address for best behavior in mixed IPv4/IPv6 environments. + List addresses = address.dns().lookup(socketHost); + for (int i = 0, size = addresses.size(); i < size; i++) { + InetAddress inetAddress = addresses.get(i); + inetSocketAddresses.add(new InetSocketAddress(inetAddress, socketPort)); + } + } + + nextInetSocketAddressIndex = 0; + } + + /** + * Obtain a "host" from an {@link InetSocketAddress}. This returns a string containing either an + * actual host name or a numeric IP address. + */ + // Visible for testing + static String getHostString(InetSocketAddress socketAddress) { + InetAddress address = socketAddress.getAddress(); + if (address == null) { + // The InetSocketAddress was specified with a string (either a numeric IP or a host name). If + // it is a name, all IPs for that name should be tried. If it is an IP address, only that IP + // address should be tried. + return socketAddress.getHostName(); + } + // The InetSocketAddress has a specific address: we should only try that address. Therefore we + // return the address and ignore any host name that may be available. + return address.getHostAddress(); + } + + /** Returns true if there's another socket address to try. */ + private boolean hasNextInetSocketAddress() { + return nextInetSocketAddressIndex < inetSocketAddresses.size(); + } + + /** Returns the next socket address to try. */ + private InetSocketAddress nextInetSocketAddress() throws IOException { + if (!hasNextInetSocketAddress()) { + throw new SocketException("No route to " + address.url().host() + + "; exhausted inet socket addresses: " + inetSocketAddresses); + } + return inetSocketAddresses.get(nextInetSocketAddressIndex++); + } + + /** Returns true if there is another postponed route to try. */ + private boolean hasNextPostponed() { + return !postponedRoutes.isEmpty(); + } + + /** Returns the next postponed route to try. */ + private Route nextPostponed() { + return postponedRoutes.remove(0); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/StatusLine.java b/okhttp3/src/main/java/okhttp3/internal/http/StatusLine.java new file mode 100644 index 000000000..ccc642b4b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/StatusLine.java @@ -0,0 +1,90 @@ +package okhttp3.internal.http; + +import java.io.IOException; +import java.net.ProtocolException; +import okhttp3.Protocol; +import okhttp3.Response; + +/** An HTTP response status line like "HTTP/1.1 200 OK". */ +public final class StatusLine { + /** Numeric status code, 307: Temporary Redirect. */ + public static final int HTTP_TEMP_REDIRECT = 307; + public static final int HTTP_PERM_REDIRECT = 308; + public static final int HTTP_CONTINUE = 100; + + public final Protocol protocol; + public final int code; + public final String message; + + public StatusLine(Protocol protocol, int code, String message) { + this.protocol = protocol; + this.code = code; + this.message = message; + } + + public static StatusLine get(Response response) { + return new StatusLine(response.protocol(), response.code(), response.message()); + } + + public static StatusLine parse(String statusLine) throws IOException { + // H T T P / 1 . 1 2 0 0 T e m p o r a r y R e d i r e c t + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 + + // Parse protocol like "HTTP/1.1" followed by a space. + int codeStart; + Protocol protocol; + if (statusLine.startsWith("HTTP/1.")) { + if (statusLine.length() < 9 || statusLine.charAt(8) != ' ') { + throw new ProtocolException("Unexpected status line: " + statusLine); + } + int httpMinorVersion = statusLine.charAt(7) - '0'; + codeStart = 9; + if (httpMinorVersion == 0) { + protocol = Protocol.HTTP_1_0; + } else if (httpMinorVersion == 1) { + protocol = Protocol.HTTP_1_1; + } else { + throw new ProtocolException("Unexpected status line: " + statusLine); + } + } else if (statusLine.startsWith("ICY ")) { + // Shoutcast uses ICY instead of "HTTP/1.0". + protocol = Protocol.HTTP_1_0; + codeStart = 4; + } else { + throw new ProtocolException("Unexpected status line: " + statusLine); + } + + // Parse response code like "200". Always 3 digits. + if (statusLine.length() < codeStart + 3) { + throw new ProtocolException("Unexpected status line: " + statusLine); + } + int code; + try { + code = Integer.parseInt(statusLine.substring(codeStart, codeStart + 3)); + } catch (NumberFormatException e) { + throw new ProtocolException("Unexpected status line: " + statusLine); + } + + // Parse an optional response message like "OK" or "Not Modified". If it + // exists, it is separated from the response code by a space. + String message = ""; + if (statusLine.length() > codeStart + 3) { + if (statusLine.charAt(codeStart + 3) != ' ') { + throw new ProtocolException("Unexpected status line: " + statusLine); + } + message = statusLine.substring(codeStart + 4); + } + + return new StatusLine(protocol, code, message); + } + + @Override public String toString() { + StringBuilder result = new StringBuilder(); + result.append(protocol == Protocol.HTTP_1_0 ? "HTTP/1.0" : "HTTP/1.1"); + result.append(' ').append(code); + if (message != null) { + result.append(' ').append(message); + } + return result.toString(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/http/StreamAllocation.java b/okhttp3/src/main/java/okhttp3/internal/http/StreamAllocation.java new file mode 100644 index 000000000..bd9abecaa --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/http/StreamAllocation.java @@ -0,0 +1,362 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.net.ProtocolException; +import java.net.SocketTimeoutException; +import java.security.cert.CertificateException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; +import okhttp3.Address; +import okhttp3.ConnectionPool; +import okhttp3.Route; +import okhttp3.internal.Internal; +import okhttp3.internal.RouteDatabase; +import okhttp3.internal.Util; +import okhttp3.internal.io.RealConnection; +import okio.Sink; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * This class coordinates the relationship between three entities: + * + *

    + *
  • Connections: physical socket connections to remote servers. These are + * potentially slow to establish so it is necessary to be able to cancel a connection + * currently being connected. + *
  • Streams: logical HTTP request/response pairs that are layered on + * connections. Each connection has its own allocation limit, which defines how many + * concurrent streams that connection can carry. HTTP/1.x connections can carry 1 stream + * at a time, SPDY and HTTP/2 typically carry multiple. + *
  • Calls: a logical sequence of streams, typically an initial request and + * its follow up requests. We prefer to keep all streams of a single call on the same + * connection for better behavior and locality. + *
+ * + *

Instances of this class act on behalf of the call, using one or more streams over one or more + * connections. This class has APIs to release each of the above resources: + * + *

    + *
  • {@link #noNewStreams()} prevents the connection from being used for new streams in the + * future. Use this after a {@code Connection: close} header, or when the connection may be + * inconsistent. + *
  • {@link #streamFinished streamFinished()} releases the active stream from this allocation. + * Note that only one stream may be active at a given time, so it is necessary to call + * {@link #streamFinished streamFinished()} before creating a subsequent stream with {@link + * #newStream newStream()}. + *
  • {@link #release()} removes the call's hold on the connection. Note that this won't + * immediately free the connection if there is a stream still lingering. That happens when a + * call is complete but its response body has yet to be fully consumed. + *
+ * + *

This class supports {@linkplain #cancel asynchronous canceling}. This is intended to have the + * smallest blast radius possible. If an HTTP/2 stream is active, canceling will cancel that stream + * but not the other streams sharing its connection. But if the TLS handshake is still in progress + * then canceling may break the entire connection. + */ +public final class StreamAllocation { + public final Address address; + private Route route; + private final ConnectionPool connectionPool; + + // State guarded by connectionPool. + private RouteSelector routeSelector; + private RealConnection connection; + private boolean released; + private boolean canceled; + private HttpStream stream; + + public StreamAllocation(ConnectionPool connectionPool, Address address) { + this.connectionPool = connectionPool; + this.address = address; + this.routeSelector = new RouteSelector(address, routeDatabase()); + } + + public HttpStream newStream(int connectTimeout, int readTimeout, int writeTimeout, + boolean connectionRetryEnabled, boolean doExtensiveHealthChecks) + throws RouteException, IOException { + try { + RealConnection resultConnection = findHealthyConnection(connectTimeout, readTimeout, + writeTimeout, connectionRetryEnabled, doExtensiveHealthChecks); + + HttpStream resultStream; + if (resultConnection.framedConnection != null) { + resultStream = new Http2xStream(this, resultConnection.framedConnection); + } else { + resultConnection.socket().setSoTimeout(readTimeout); + resultConnection.source.timeout().timeout(readTimeout, MILLISECONDS); + resultConnection.sink.timeout().timeout(writeTimeout, MILLISECONDS); + resultStream = new Http1xStream(this, resultConnection.source, resultConnection.sink); + } + + synchronized (connectionPool) { + stream = resultStream; + return resultStream; + } + } catch (IOException e) { + throw new RouteException(e); + } + } + + /** + * Finds a connection and returns it if it is healthy. If it is unhealthy the process is repeated + * until a healthy connection is found. + */ + private RealConnection findHealthyConnection(int connectTimeout, int readTimeout, + int writeTimeout, boolean connectionRetryEnabled, boolean doExtensiveHealthChecks) + throws IOException, RouteException { + while (true) { + RealConnection candidate = findConnection(connectTimeout, readTimeout, writeTimeout, + connectionRetryEnabled); + + // If this is a brand new connection, we can skip the extensive health checks. + synchronized (connectionPool) { + if (candidate.successCount == 0) { + return candidate; + } + } + + // Otherwise do a potentially-slow check to confirm that the pooled connection is still good. + if (candidate.isHealthy(doExtensiveHealthChecks)) { + return candidate; + } + + connectionFailed(new IOException()); + } + } + + /** + * Returns a connection to host a new stream. This prefers the existing connection if it exists, + * then the pool, finally building a new connection. + */ + private RealConnection findConnection(int connectTimeout, int readTimeout, int writeTimeout, + boolean connectionRetryEnabled) throws IOException, RouteException { + Route selectedRoute; + synchronized (connectionPool) { + if (released) throw new IllegalStateException("released"); + if (stream != null) throw new IllegalStateException("stream != null"); + if (canceled) throw new IOException("Canceled"); + + RealConnection allocatedConnection = this.connection; + if (allocatedConnection != null && !allocatedConnection.noNewStreams) { + return allocatedConnection; + } + + // Attempt to get a connection from the pool. + RealConnection pooledConnection = Internal.instance.get(connectionPool, address, this); + if (pooledConnection != null) { + this.connection = pooledConnection; + return pooledConnection; + } + + selectedRoute = route; + } + + if (selectedRoute == null) { + selectedRoute = routeSelector.next(); + synchronized (connectionPool) { + route = selectedRoute; + } + } + RealConnection newConnection = new RealConnection(selectedRoute); + acquire(newConnection); + + synchronized (connectionPool) { + Internal.instance.put(connectionPool, newConnection); + this.connection = newConnection; + if (canceled) throw new IOException("Canceled"); + } + + newConnection.connect(connectTimeout, readTimeout, writeTimeout, address.connectionSpecs(), + connectionRetryEnabled); + routeDatabase().connected(newConnection.route()); + + return newConnection; + } + + public void streamFinished(boolean noNewStreams, HttpStream stream) { + synchronized (connectionPool) { + if (stream == null || stream != this.stream) { + throw new IllegalStateException("expected " + this.stream + " but was " + stream); + } + if (!noNewStreams) { + connection.successCount++; + } + } + deallocate(noNewStreams, false, true); + } + + public HttpStream stream() { + synchronized (connectionPool) { + return stream; + } + } + + private RouteDatabase routeDatabase() { + return Internal.instance.routeDatabase(connectionPool); + } + + public synchronized RealConnection connection() { + return connection; + } + + public void release() { + deallocate(false, true, false); + } + + /** Forbid new streams from being created on the connection that hosts this allocation. */ + public void noNewStreams() { + deallocate(true, false, false); + } + + /** + * Releases resources held by this allocation. If sufficient resources are allocated, the + * connection will be detached or closed. + */ + private void deallocate(boolean noNewStreams, boolean released, boolean streamFinished) { + RealConnection connectionToClose = null; + synchronized (connectionPool) { + if (streamFinished) { + this.stream = null; + } + if (released) { + this.released = true; + } + if (connection != null) { + if (noNewStreams) { + connection.noNewStreams = true; + } + if (this.stream == null && (this.released || connection.noNewStreams)) { + release(connection); + if (connection.allocations.isEmpty()) { + connection.idleAtNanos = System.nanoTime(); + if (Internal.instance.connectionBecameIdle(connectionPool, connection)) { + connectionToClose = connection; + } + } + connection = null; + } + } + } + if (connectionToClose != null) { + Util.closeQuietly(connectionToClose.socket()); + } + } + + public void cancel() { + HttpStream streamToCancel; + RealConnection connectionToCancel; + synchronized (connectionPool) { + canceled = true; + streamToCancel = stream; + connectionToCancel = connection; + } + if (streamToCancel != null) { + streamToCancel.cancel(); + } else if (connectionToCancel != null) { + connectionToCancel.cancel(); + } + } + + public void connectionFailed(IOException e) { + synchronized (connectionPool) { + // Avoid this route if it's never seen a successful call. + if (connection != null && connection.successCount == 0) { + if (route != null && e != null) { + routeSelector.connectFailed(route, e); + } + route = null; + } + } + deallocate(true, false, true); + } + + /** + * Use this allocation to hold {@code connection}. Each call to this must be paired with a call to + * {@link #release} on the same connection. + */ + public void acquire(RealConnection connection) { + connection.allocations.add(new WeakReference<>(this)); + } + + /** Remove this allocation from the connection's list of allocations. */ + private void release(RealConnection connection) { + for (int i = 0, size = connection.allocations.size(); i < size; i++) { + Reference reference = connection.allocations.get(i); + if (reference.get() == this) { + connection.allocations.remove(i); + return; + } + } + throw new IllegalStateException(); + } + + public boolean recover(IOException e, Sink requestBodyOut) { + if (connection != null) { + connectionFailed(e); + } + + boolean canRetryRequestBody = requestBodyOut == null || requestBodyOut instanceof RetryableSink; + if ((routeSelector != null && !routeSelector.hasNext()) // No more routes to attempt. + || !isRecoverable(e) + || !canRetryRequestBody) { + return false; + } + + return true; + } + + private boolean isRecoverable(IOException e) { + // If there was a protocol problem, don't recover. + if (e instanceof ProtocolException) { + return false; + } + + // If there was an interruption don't recover, but if there was a timeout + // we should try the next route (if there is one). + if (e instanceof InterruptedIOException) { + return e instanceof SocketTimeoutException; + } + + // Look for known client-side or negotiation errors that are unlikely to be fixed by trying + // again with a different route. + if (e instanceof SSLHandshakeException) { + // If the problem was a CertificateException from the X509TrustManager, + // do not retry. + if (e.getCause() instanceof CertificateException) { + return false; + } + } + if (e instanceof SSLPeerUnverifiedException) { + // e.g. a certificate pinning error. + return false; + } + + // An example of one we might want to retry with a different route is a problem connecting to a + // proxy and would manifest as a standard IOException. Unless it is one we know we should not + // retry, we return true and try a new route. + return true; + } + + @Override public String toString() { + return address.toString(); + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/io/FileSystem.java b/okhttp3/src/main/java/okhttp3/internal/io/FileSystem.java new file mode 100644 index 000000000..6a786954b --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/io/FileSystem.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.io; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import okio.Okio; +import okio.Sink; +import okio.Source; + +/** + * Access to read and write files on a hierarchical data store. Most callers should use the {@link + * #SYSTEM} implementation, which uses the host machine's local file system. Alternate + * implementations may be used to inject faults (for testing) or to transform stored data (to add + * encryption, for example). + * + *

All operations on a file system are racy. For example, guarding a call to {@link #source} with + * {@link #exists} does not guarantee that {@link FileNotFoundException} will not be thrown. The + * file may be moved between the two calls! + * + *

This interface is less ambitious than {@link java.nio.file.FileSystem} introduced in Java 7. + * It lacks important features like file watching, metadata, permissions, and disk space + * information. In exchange for these limitations, this interface is easier to implement and works + * on all versions of Java and Android. + */ +public interface FileSystem { + /** The host machine's local file system. */ + FileSystem SYSTEM = new FileSystem() { + @Override public Source source(File file) throws FileNotFoundException { + return Okio.source(file); + } + + @Override public Sink sink(File file) throws FileNotFoundException { + try { + return Okio.sink(file); + } catch (FileNotFoundException e) { + // Maybe the parent directory doesn't exist? Try creating it first. + file.getParentFile().mkdirs(); + return Okio.sink(file); + } + } + + @Override public Sink appendingSink(File file) throws FileNotFoundException { + try { + return Okio.appendingSink(file); + } catch (FileNotFoundException e) { + // Maybe the parent directory doesn't exist? Try creating it first. + file.getParentFile().mkdirs(); + return Okio.appendingSink(file); + } + } + + @Override public void delete(File file) throws IOException { + // If delete() fails, make sure it's because the file didn't exist! + if (!file.delete() && file.exists()) { + throw new IOException("failed to delete " + file); + } + } + + @Override public boolean exists(File file) throws IOException { + return file.exists(); + } + + @Override public long size(File file) { + return file.length(); + } + + @Override public void rename(File from, File to) throws IOException { + delete(to); + if (!from.renameTo(to)) { + throw new IOException("failed to rename " + from + " to " + to); + } + } + + @Override public void deleteContents(File directory) throws IOException { + File[] files = directory.listFiles(); + if (files == null) { + throw new IOException("not a readable directory: " + directory); + } + for (File file : files) { + if (file.isDirectory()) { + deleteContents(file); + } + if (!file.delete()) { + throw new IOException("failed to delete " + file); + } + } + } + }; + + /** Reads from {@code file}. */ + Source source(File file) throws FileNotFoundException; + + /** + * Writes to {@code file}, discarding any data already present. Creates parent directories if + * necessary. + */ + Sink sink(File file) throws FileNotFoundException; + + /** + * Writes to {@code file}, appending if data is already present. Creates parent directories if + * necessary. + */ + Sink appendingSink(File file) throws FileNotFoundException; + + /** Deletes {@code file} if it exists. Throws if the file exists and cannot be deleted. */ + void delete(File file) throws IOException; + + /** Returns true if {@code file} exists on the file system. */ + boolean exists(File file) throws IOException; + + /** Returns the number of bytes stored in {@code file}, or 0 if it does not exist. */ + long size(File file); + + /** Renames {@code from} to {@code to}. Throws if the file cannot be renamed. */ + void rename(File from, File to) throws IOException; + + /** + * Recursively delete the contents of {@code directory}. Throws an IOException if any file could + * not be deleted, or if {@code dir} is not a readable directory. + */ + void deleteContents(File directory) throws IOException; +} diff --git a/okhttp3/src/main/java/okhttp3/internal/io/RealConnection.java b/okhttp3/src/main/java/okhttp3/internal/io/RealConnection.java new file mode 100644 index 000000000..3077a7717 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/io/RealConnection.java @@ -0,0 +1,379 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.io; + +import java.io.IOException; +import java.lang.ref.Reference; +import java.net.ConnectException; +import java.net.Proxy; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.UnknownServiceException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import okhttp3.Address; +import okhttp3.CertificatePinner; +import okhttp3.Connection; +import okhttp3.ConnectionSpec; +import okhttp3.Handshake; +import okhttp3.HttpUrl; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; +import okhttp3.internal.ConnectionSpecSelector; +import okhttp3.internal.Platform; +import okhttp3.internal.Util; +import okhttp3.internal.Version; +import okhttp3.internal.framed.FramedConnection; +import okhttp3.internal.http.Http1xStream; +import okhttp3.internal.http.OkHeaders; +import okhttp3.internal.http.RouteException; +import okhttp3.internal.http.StreamAllocation; +import okhttp3.internal.tls.OkHostnameVerifier; +import okio.BufferedSink; +import okio.BufferedSource; +import okio.Okio; +import okio.Source; + +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_PROXY_AUTH; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static okhttp3.internal.Util.closeQuietly; + +public final class RealConnection implements Connection { + private final Route route; + + /** The low-level TCP socket. */ + private Socket rawSocket; + + /** + * The application layer socket. Either an {@link SSLSocket} layered over {@link #rawSocket}, or + * {@link #rawSocket} itself if this connection does not use SSL. + */ + public Socket socket; + private Handshake handshake; + private Protocol protocol; + public volatile FramedConnection framedConnection; + public int successCount; + public BufferedSource source; + public BufferedSink sink; + public final List> allocations = new ArrayList<>(); + public boolean noNewStreams; + public long idleAtNanos = Long.MAX_VALUE; + + public RealConnection(Route route) { + this.route = route; + } + + public void connect(int connectTimeout, int readTimeout, int writeTimeout, + List connectionSpecs, boolean connectionRetryEnabled) throws RouteException { + if (protocol != null) throw new IllegalStateException("already connected"); + + RouteException routeException = null; + ConnectionSpecSelector connectionSpecSelector = new ConnectionSpecSelector(connectionSpecs); + Proxy proxy = route.proxy(); + Address address = route.address(); + + if (route.address().sslSocketFactory() == null + && !connectionSpecs.contains(ConnectionSpec.CLEARTEXT)) { + throw new RouteException(new UnknownServiceException( + "CLEARTEXT communication not supported: " + connectionSpecs)); + } + + while (protocol == null) { + try { + rawSocket = proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP + ? address.socketFactory().createSocket() + : new Socket(proxy); + connectSocket(connectTimeout, readTimeout, writeTimeout, connectionSpecSelector); + } catch (IOException e) { + closeQuietly(socket); + closeQuietly(rawSocket); + socket = null; + rawSocket = null; + source = null; + sink = null; + handshake = null; + protocol = null; + + if (routeException == null) { + routeException = new RouteException(e); + } else { + routeException.addConnectException(e); + } + + if (!connectionRetryEnabled || !connectionSpecSelector.connectionFailed(e)) { + throw routeException; + } + } + } + } + + /** Does all the work necessary to build a full HTTP or HTTPS connection on a raw socket. */ + private void connectSocket(int connectTimeout, int readTimeout, int writeTimeout, + ConnectionSpecSelector connectionSpecSelector) throws IOException { + rawSocket.setSoTimeout(readTimeout); + try { + Platform.get().connectSocket(rawSocket, route.socketAddress(), connectTimeout); + } catch (ConnectException e) { + throw new ConnectException("Failed to connect to " + route.socketAddress()); + } + source = Okio.buffer(Okio.source(rawSocket)); + sink = Okio.buffer(Okio.sink(rawSocket)); + + if (route.address().sslSocketFactory() != null) { + connectTls(readTimeout, writeTimeout, connectionSpecSelector); + } else { + protocol = Protocol.HTTP_1_1; + socket = rawSocket; + } + + if (protocol == Protocol.SPDY_3 || protocol == Protocol.HTTP_2) { + socket.setSoTimeout(0); // Framed connection timeouts are set per-stream. + + FramedConnection framedConnection = new FramedConnection.Builder(true) + .socket(socket, route.address().url().host(), source, sink) + .protocol(protocol) + .build(); + framedConnection.sendConnectionPreface(); + + // Only assign the framed connection once the preface has been sent successfully. + this.framedConnection = framedConnection; + } + } + + private void connectTls(int readTimeout, int writeTimeout, + ConnectionSpecSelector connectionSpecSelector) throws IOException { + if (route.requiresTunnel()) { + createTunnel(readTimeout, writeTimeout); + } + + Address address = route.address(); + SSLSocketFactory sslSocketFactory = address.sslSocketFactory(); + boolean success = false; + SSLSocket sslSocket = null; + try { + // Create the wrapper over the connected socket. + sslSocket = (SSLSocket) sslSocketFactory.createSocket( + rawSocket, address.url().host(), address.url().port(), true /* autoClose */); + + // Configure the socket's ciphers, TLS versions, and extensions. + ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket); + if (connectionSpec.supportsTlsExtensions()) { + Platform.get().configureTlsExtensions( + sslSocket, address.url().host(), address.protocols()); + } + + // Force handshake. This can throw! + sslSocket.startHandshake(); + Handshake unverifiedHandshake = Handshake.get(sslSocket.getSession()); + + // Verify that the socket's certificates are acceptable for the target host. + if (!address.hostnameVerifier().verify(address.url().host(), sslSocket.getSession())) { + X509Certificate cert = (X509Certificate) unverifiedHandshake.peerCertificates().get(0); + throw new SSLPeerUnverifiedException("Hostname " + address.url().host() + " not verified:" + + "\n certificate: " + CertificatePinner.pin(cert) + + "\n DN: " + cert.getSubjectDN().getName() + + "\n subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert)); + } + + // Check that the certificate pinner is satisfied by the certificates presented. + address.certificatePinner().check(address.url().host(), + unverifiedHandshake.peerCertificates()); + + // Success! Save the handshake and the ALPN protocol. + String maybeProtocol = connectionSpec.supportsTlsExtensions() + ? Platform.get().getSelectedProtocol(sslSocket) + : null; + socket = sslSocket; + source = Okio.buffer(Okio.source(socket)); + sink = Okio.buffer(Okio.sink(socket)); + handshake = unverifiedHandshake; + protocol = maybeProtocol != null + ? Protocol.get(maybeProtocol) + : Protocol.HTTP_1_1; + success = true; + } catch (AssertionError e) { + if (Util.isAndroidGetsocknameError(e)) throw new IOException(e); + throw e; + } finally { + if (sslSocket != null) { + Platform.get().afterHandshake(sslSocket); + } + if (!success) { + closeQuietly(sslSocket); + } + } + } + + /** + * To make an HTTPS connection over an HTTP proxy, send an unencrypted CONNECT request to create + * the proxy connection. This may need to be retried if the proxy requires authorization. + */ + private void createTunnel(int readTimeout, int writeTimeout) throws IOException { + // Make an SSL Tunnel on the first message pair of each SSL + proxy connection. + Request tunnelRequest = createTunnelRequest(); + HttpUrl url = tunnelRequest.url(); + String requestLine = "CONNECT " + url.host() + ":" + url.port() + " HTTP/1.1"; + while (true) { + Http1xStream tunnelConnection = new Http1xStream(null, source, sink); + source.timeout().timeout(readTimeout, MILLISECONDS); + sink.timeout().timeout(writeTimeout, MILLISECONDS); + tunnelConnection.writeRequest(tunnelRequest.headers(), requestLine); + tunnelConnection.finishRequest(); + Response response = tunnelConnection.readResponse().request(tunnelRequest).build(); + // The response body from a CONNECT should be empty, but if it is not then we should consume + // it before proceeding. + long contentLength = OkHeaders.contentLength(response); + if (contentLength == -1L) { + contentLength = 0L; + } + Source body = tunnelConnection.newFixedLengthSource(contentLength); + Util.skipAll(body, Integer.MAX_VALUE, TimeUnit.MILLISECONDS); + body.close(); + + switch (response.code()) { + case HTTP_OK: + // Assume the server won't send a TLS ServerHello until we send a TLS ClientHello. If + // that happens, then we will have buffered bytes that are needed by the SSLSocket! + // This check is imperfect: it doesn't tell us whether a handshake will succeed, just + // that it will almost certainly fail because the proxy has sent unexpected data. + if (!source.buffer().exhausted() || !sink.buffer().exhausted()) { + throw new IOException("TLS tunnel buffered too many bytes!"); + } + return; + + case HTTP_PROXY_AUTH: + tunnelRequest = route.address().proxyAuthenticator().authenticate(route, response); + if (tunnelRequest != null) continue; + throw new IOException("Failed to authenticate with proxy"); + + default: + throw new IOException( + "Unexpected response code for CONNECT: " + response.code()); + } + } + } + + /** + * Returns a request that creates a TLS tunnel via an HTTP proxy, or null if no tunnel is + * necessary. Everything in the tunnel request is sent unencrypted to the proxy server, so tunnels + * include only the minimum set of headers. This avoids sending potentially sensitive data like + * HTTP cookies to the proxy unencrypted. + */ + private Request createTunnelRequest() throws IOException { + return new Request.Builder() + .url(route.address().url()) + .header("Host", Util.hostHeader(route.address().url())) + .header("Proxy-Connection", "Keep-Alive") + .header("User-Agent", Version.userAgent()) // For HTTP/1.0 proxies like Squid. + .build(); + } + + /** Returns true if {@link #connect} has been attempted on this connection. */ + boolean isConnected() { + return protocol != null; + } + + @Override public Route route() { + return route; + } + + public void cancel() { + // Close the raw socket so we don't end up doing synchronous I/O. + closeQuietly(rawSocket); + } + + @Override public Socket socket() { + return socket; + } + + public int allocationLimit() { + FramedConnection framedConnection = this.framedConnection; + return framedConnection != null + ? framedConnection.maxConcurrentStreams() + : 1; + } + + /** Returns true if this connection is ready to host new streams. */ + public boolean isHealthy(boolean doExtensiveChecks) { + if (socket.isClosed() || socket.isInputShutdown() || socket.isOutputShutdown()) { + return false; + } + + if (framedConnection != null) { + return true; // TODO: check framedConnection.shutdown. + } + + if (doExtensiveChecks) { + try { + int readTimeout = socket.getSoTimeout(); + try { + socket.setSoTimeout(1); + if (source.exhausted()) { + return false; // Stream is exhausted; socket is closed. + } + return true; + } finally { + socket.setSoTimeout(readTimeout); + } + } catch (SocketTimeoutException ignored) { + // Read timed out; socket is good. + } catch (IOException e) { + return false; // Couldn't read; socket is closed. + } + } + + return true; + } + + @Override public Handshake handshake() { + return handshake; + } + + /** + * Returns true if this is a SPDY connection. Such connections can be used in multiple HTTP + * requests simultaneously. + */ + public boolean isMultiplexed() { + return framedConnection != null; + } + + @Override public Protocol protocol() { + return protocol != null ? protocol : Protocol.HTTP_1_1; + } + + @Override public String toString() { + return "Connection{" + + route.address().url().host() + ":" + route.address().url().port() + + ", proxy=" + + route.proxy() + + " hostAddress=" + + route.socketAddress() + + " cipherSuite=" + + (handshake != null ? handshake.cipherSuite() : "none") + + " protocol=" + + protocol + + '}'; + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/tls/DistinguishedNameParser.java b/okhttp3/src/main/java/okhttp3/internal/tls/DistinguishedNameParser.java new file mode 100644 index 000000000..ceecd9c31 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/tls/DistinguishedNameParser.java @@ -0,0 +1,407 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.tls; + +import javax.security.auth.x500.X500Principal; + +/** + * A distinguished name (DN) parser. This parser only supports extracting a string value from a DN. + * It doesn't support values in the hex-string style. + */ +final class DistinguishedNameParser { + private final String dn; + private final int length; + private int pos; + private int beg; + private int end; + + /** Temporary variable to store positions of the currently parsed item. */ + private int cur; + + /** Distinguished name characters. */ + private char[] chars; + + public DistinguishedNameParser(X500Principal principal) { + // RFC2253 is used to ensure we get attributes in the reverse + // order of the underlying ASN.1 encoding, so that the most + // significant values of repeated attributes occur first. + this.dn = principal.getName(X500Principal.RFC2253); + this.length = this.dn.length(); + } + + // gets next attribute type: (ALPHA 1*keychar) / oid + private String nextAT() { + // skip preceding space chars, they can present after + // comma or semicolon (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + if (pos == length) { + return null; // reached the end of DN + } + + // mark the beginning of attribute type + beg = pos; + + // attribute type chars + pos++; + for (; pos < length && chars[pos] != '=' && chars[pos] != ' '; pos++) { + // we don't follow exact BNF syntax here: + // accept any char except space and '=' + } + if (pos >= length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + // mark the end of attribute type + end = pos; + + // skip trailing space chars between attribute type and '=' + // (compatibility with RFC 1779) + if (chars[pos] == ' ') { + for (; pos < length && chars[pos] != '=' && chars[pos] == ' '; pos++) { + } + + if (chars[pos] != '=' || pos == length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + } + + pos++; //skip '=' char + + // skip space chars between '=' and attribute value + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + + // in case of oid attribute type skip its prefix: "oid." or "OID." + // (compatibility with RFC 1779) + if ((end - beg > 4) && (chars[beg + 3] == '.') + && (chars[beg] == 'O' || chars[beg] == 'o') + && (chars[beg + 1] == 'I' || chars[beg + 1] == 'i') + && (chars[beg + 2] == 'D' || chars[beg + 2] == 'd')) { + beg += 4; + } + + return new String(chars, beg, end - beg); + } + + // gets quoted attribute value: QUOTATION *( quotechar / pair ) QUOTATION + private String quotedAV() { + pos++; + beg = pos; + end = beg; + while (true) { + + if (pos == length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + if (chars[pos] == '"') { + // enclosing quotation was found + pos++; + break; + } else if (chars[pos] == '\\') { + chars[end] = getEscaped(); + } else { + // shift char: required for string with escaped chars + chars[end] = chars[pos]; + } + pos++; + end++; + } + + // skip trailing space chars before comma or semicolon. + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + + return new String(chars, beg, end - beg); + } + + // gets hex string attribute value: "#" hexstring + private String hexAV() { + if (pos + 4 >= length) { + // encoded byte array must be not less then 4 c + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + beg = pos; // store '#' position + pos++; + while (true) { + + // check for end of attribute value + // looks for space and component separators + if (pos == length || chars[pos] == '+' || chars[pos] == ',' + || chars[pos] == ';') { + end = pos; + break; + } + + if (chars[pos] == ' ') { + end = pos; + pos++; + // skip trailing space chars before comma or semicolon. + // (compatibility with RFC 1779) + for (; pos < length && chars[pos] == ' '; pos++) { + } + break; + } else if (chars[pos] >= 'A' && chars[pos] <= 'F') { + chars[pos] += 32; //to low case + } + + pos++; + } + + // verify length of hex string + // encoded byte array must be not less then 4 and must be even number + int hexLen = end - beg; // skip first '#' char + if (hexLen < 5 || (hexLen & 1) == 0) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + // get byte encoding from string representation + byte[] encoded = new byte[hexLen / 2]; + for (int i = 0, p = beg + 1; i < encoded.length; p += 2, i++) { + encoded[i] = (byte) getByte(p); + } + + return new String(chars, beg, hexLen); + } + + // gets string attribute value: *( stringchar / pair ) + private String escapedAV() { + beg = pos; + end = pos; + while (true) { + if (pos >= length) { + // the end of DN has been found + return new String(chars, beg, end - beg); + } + + switch (chars[pos]) { + case '+': + case ',': + case ';': + // separator char has been found + return new String(chars, beg, end - beg); + case '\\': + // escaped char + chars[end++] = getEscaped(); + pos++; + break; + case ' ': + // need to figure out whether space defines + // the end of attribute value or not + cur = end; + + pos++; + chars[end++] = ' '; + + for (; pos < length && chars[pos] == ' '; pos++) { + chars[end++] = ' '; + } + if (pos == length || chars[pos] == ',' || chars[pos] == '+' + || chars[pos] == ';') { + // separator char or the end of DN has been found + return new String(chars, beg, cur - beg); + } + break; + default: + chars[end++] = chars[pos]; + pos++; + } + } + } + + // returns escaped char + private char getEscaped() { + pos++; + if (pos == length) { + throw new IllegalStateException("Unexpected end of DN: " + dn); + } + + switch (chars[pos]) { + case '"': + case '\\': + case ',': + case '=': + case '+': + case '<': + case '>': + case '#': + case ';': + case ' ': + case '*': + case '%': + case '_': + //FIXME: escaping is allowed only for leading or trailing space char + return chars[pos]; + default: + // RFC doesn't explicitly say that escaped hex pair is + // interpreted as UTF-8 char. It only contains an example of such DN. + return getUTF8(); + } + } + + // decodes UTF-8 char + // see http://www.unicode.org for UTF-8 bit distribution table + private char getUTF8() { + int res = getByte(pos); + pos++; //FIXME tmp + + if (res < 128) { // one byte: 0-7F + return (char) res; + } else if (res >= 192 && res <= 247) { + + int count; + if (res <= 223) { // two bytes: C0-DF + count = 1; + res = res & 0x1F; + } else if (res <= 239) { // three bytes: E0-EF + count = 2; + res = res & 0x0F; + } else { // four bytes: F0-F7 + count = 3; + res = res & 0x07; + } + + int b; + for (int i = 0; i < count; i++) { + pos++; + if (pos == length || chars[pos] != '\\') { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + pos++; + + b = getByte(pos); + pos++; //FIXME tmp + if ((b & 0xC0) != 0x80) { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + + res = (res << 6) + (b & 0x3F); + } + return (char) res; + } else { + return 0x3F; //FIXME failed to decode UTF-8 char - return '?' + } + } + + // Returns byte representation of a char pair + // The char pair is composed of DN char in + // specified 'position' and the next char + // According to BNF syntax: + // hexchar = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + // / "a" / "b" / "c" / "d" / "e" / "f" + private int getByte(int position) { + if (position + 1 >= length) { + throw new IllegalStateException("Malformed DN: " + dn); + } + + int b1, b2; + + b1 = chars[position]; + if (b1 >= '0' && b1 <= '9') { + b1 = b1 - '0'; + } else if (b1 >= 'a' && b1 <= 'f') { + b1 = b1 - 87; // 87 = 'a' - 10 + } else if (b1 >= 'A' && b1 <= 'F') { + b1 = b1 - 55; // 55 = 'A' - 10 + } else { + throw new IllegalStateException("Malformed DN: " + dn); + } + + b2 = chars[position + 1]; + if (b2 >= '0' && b2 <= '9') { + b2 = b2 - '0'; + } else if (b2 >= 'a' && b2 <= 'f') { + b2 = b2 - 87; // 87 = 'a' - 10 + } else if (b2 >= 'A' && b2 <= 'F') { + b2 = b2 - 55; // 55 = 'A' - 10 + } else { + throw new IllegalStateException("Malformed DN: " + dn); + } + + return (b1 << 4) + b2; + } + + /** + * Parses the DN and returns the most significant attribute value for an attribute type, or null + * if none found. + * + * @param attributeType attribute type to look for (e.g. "ca") + */ + public String findMostSpecific(String attributeType) { + // Initialize internal state. + pos = 0; + beg = 0; + end = 0; + cur = 0; + chars = dn.toCharArray(); + + String attType = nextAT(); + if (attType == null) { + return null; + } + while (true) { + String attValue = ""; + + if (pos == length) { + return null; + } + + switch (chars[pos]) { + case '"': + attValue = quotedAV(); + break; + case '#': + attValue = hexAV(); + break; + case '+': + case ',': + case ';': // compatibility with RFC 1779: semicolon can separate RDNs + //empty attribute value + break; + default: + attValue = escapedAV(); + } + + // Values are ordered from most specific to least specific + // due to the RFC2253 formatting. So take the first match + // we see. + if (attributeType.equalsIgnoreCase(attType)) { + return attValue; + } + + if (pos >= length) { + return null; + } + + if (chars[pos] == ',' || chars[pos] == ';') { + } else if (chars[pos] != '+') { + throw new IllegalStateException("Malformed DN: " + dn); + } + + pos++; + attType = nextAT(); + if (attType == null) { + throw new IllegalStateException("Malformed DN: " + dn); + } + } + } +} diff --git a/okhttp3/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java b/okhttp3/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java new file mode 100644 index 000000000..e6ad9b8c6 --- /dev/null +++ b/okhttp3/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java @@ -0,0 +1,230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okhttp3.internal.tls; + +import java.security.cert.Certificate; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSession; +import javax.security.auth.x500.X500Principal; + +import static okhttp3.internal.Util.verifyAsIpAddress; + +/** + * A HostnameVerifier consistent with RFC 2818. + */ +public final class OkHostnameVerifier implements HostnameVerifier { + public static final OkHostnameVerifier INSTANCE = new OkHostnameVerifier(); + + private static final int ALT_DNS_NAME = 2; + private static final int ALT_IPA_NAME = 7; + + private OkHostnameVerifier() { + } + + @Override + public boolean verify(String host, SSLSession session) { + try { + Certificate[] certificates = session.getPeerCertificates(); + return verify(host, (X509Certificate) certificates[0]); + } catch (SSLException e) { + return false; + } + } + + public boolean verify(String host, X509Certificate certificate) { + return verifyAsIpAddress(host) + ? verifyIpAddress(host, certificate) + : verifyHostName(host, certificate); + } + + /** Returns true if {@code certificate} matches {@code ipAddress}. */ + private boolean verifyIpAddress(String ipAddress, X509Certificate certificate) { + List altNames = getSubjectAltNames(certificate, ALT_IPA_NAME); + for (int i = 0, size = altNames.size(); i < size; i++) { + if (ipAddress.equalsIgnoreCase(altNames.get(i))) { + return true; + } + } + return false; + } + + /** Returns true if {@code certificate} matches {@code hostName}. */ + private boolean verifyHostName(String hostName, X509Certificate certificate) { + hostName = hostName.toLowerCase(Locale.US); + boolean hasDns = false; + List altNames = getSubjectAltNames(certificate, ALT_DNS_NAME); + for (int i = 0, size = altNames.size(); i < size; i++) { + hasDns = true; + if (verifyHostName(hostName, altNames.get(i))) { + return true; + } + } + + if (!hasDns) { + X500Principal principal = certificate.getSubjectX500Principal(); + // RFC 2818 advises using the most specific name for matching. + String cn = new DistinguishedNameParser(principal).findMostSpecific("cn"); + if (cn != null) { + return verifyHostName(hostName, cn); + } + } + + return false; + } + + public static List allSubjectAltNames(X509Certificate certificate) { + List altIpaNames = getSubjectAltNames(certificate, ALT_IPA_NAME); + List altDnsNames = getSubjectAltNames(certificate, ALT_DNS_NAME); + List result = new ArrayList<>(altIpaNames.size() + altDnsNames.size()); + result.addAll(altIpaNames); + result.addAll(altDnsNames); + return result; + } + + private static List getSubjectAltNames(X509Certificate certificate, int type) { + List result = new ArrayList<>(); + try { + Collection subjectAltNames = certificate.getSubjectAlternativeNames(); + if (subjectAltNames == null) { + return Collections.emptyList(); + } + for (Object subjectAltName : subjectAltNames) { + List entry = (List) subjectAltName; + if (entry == null || entry.size() < 2) { + continue; + } + Integer altNameType = (Integer) entry.get(0); + if (altNameType == null) { + continue; + } + if (altNameType == type) { + String altName = (String) entry.get(1); + if (altName != null) { + result.add(altName); + } + } + } + return result; + } catch (CertificateParsingException e) { + return Collections.emptyList(); + } + } + + /** + * Returns {@code true} iff {@code hostName} matches the domain name {@code pattern}. + * + * @param hostName lower-case host name. + * @param pattern domain name pattern from certificate. May be a wildcard pattern such as {@code + * *.android.com}. + */ + private boolean verifyHostName(String hostName, String pattern) { + // Basic sanity checks + // Check length == 0 instead of .isEmpty() to support Java 5. + if ((hostName == null) || (hostName.length() == 0) || (hostName.startsWith(".")) + || (hostName.endsWith(".."))) { + // Invalid domain name + return false; + } + if ((pattern == null) || (pattern.length() == 0) || (pattern.startsWith(".")) + || (pattern.endsWith(".."))) { + // Invalid pattern/domain name + return false; + } + + // Normalize hostName and pattern by turning them into absolute domain names if they are not + // yet absolute. This is needed because server certificates do not normally contain absolute + // names or patterns, but they should be treated as absolute. At the same time, any hostName + // presented to this method should also be treated as absolute for the purposes of matching + // to the server certificate. + // www.android.com matches www.android.com + // www.android.com matches www.android.com. + // www.android.com. matches www.android.com. + // www.android.com. matches www.android.com + if (!hostName.endsWith(".")) { + hostName += '.'; + } + if (!pattern.endsWith(".")) { + pattern += '.'; + } + // hostName and pattern are now absolute domain names. + + pattern = pattern.toLowerCase(Locale.US); + // hostName and pattern are now in lower case -- domain names are case-insensitive. + + if (!pattern.contains("*")) { + // Not a wildcard pattern -- hostName and pattern must match exactly. + return hostName.equals(pattern); + } + // Wildcard pattern + + // WILDCARD PATTERN RULES: + // 1. Asterisk (*) is only permitted in the left-most domain name label and must be the + // only character in that label (i.e., must match the whole left-most label). + // For example, *.example.com is permitted, while *a.example.com, a*.example.com, + // a*b.example.com, a.*.example.com are not permitted. + // 2. Asterisk (*) cannot match across domain name labels. + // For example, *.example.com matches test.example.com but does not match + // sub.test.example.com. + // 3. Wildcard patterns for single-label domain names are not permitted. + + if ((!pattern.startsWith("*.")) || (pattern.indexOf('*', 1) != -1)) { + // Asterisk (*) is only permitted in the left-most domain name label and must be the only + // character in that label + return false; + } + + // Optimization: check whether hostName is too short to match the pattern. hostName must be at + // least as long as the pattern because asterisk must match the whole left-most label and + // hostName starts with a non-empty label. Thus, asterisk has to match one or more characters. + if (hostName.length() < pattern.length()) { + // hostName too short to match the pattern. + return false; + } + + if ("*.".equals(pattern)) { + // Wildcard pattern for single-label domain name -- not permitted. + return false; + } + + // hostName must end with the region of pattern following the asterisk. + String suffix = pattern.substring(1); + if (!hostName.endsWith(suffix)) { + // hostName does not end with the suffix + return false; + } + + // Check that asterisk did not match across domain name labels. + int suffixStartIndexInHostName = hostName.length() - suffix.length(); + if ((suffixStartIndexInHostName > 0) + && (hostName.lastIndexOf('.', suffixStartIndexInHostName - 1) != -1)) { + // Asterisk is matching across domain name labels -- not permitted. + return false; + } + + // hostName matches pattern + return true; + } +} diff --git a/okhttp3/src/test/java/com/squareup/okhttp3/ExampleUnitTest.java b/okhttp3/src/test/java/com/squareup/okhttp3/ExampleUnitTest.java new file mode 100644 index 000000000..d6ba9a7ea --- /dev/null +++ b/okhttp3/src/test/java/com/squareup/okhttp3/ExampleUnitTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2016 Hippo Seven + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.squareup.okhttp3; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * To work on unit tests, switch the Test Artifact in the Build Variants view. + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/rippleold b/rippleold new file mode 160000 index 000000000..369594a2a --- /dev/null +++ b/rippleold @@ -0,0 +1 @@ +Subproject commit 369594a2a4b5d58371fdec25170c52a6e17d7881 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..25cf17409 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':anani', ':app', 'beerbelly', 'conaco', ':daogenerator', ':easyrecyclerview', 'hotspot', ':okhttp3', 'rippleold', 'unifile', 'vector', 'yorozuya' diff --git a/unifile b/unifile new file mode 160000 index 000000000..d439d8fc0 --- /dev/null +++ b/unifile @@ -0,0 +1 @@ +Subproject commit d439d8fc058867a7ad9ae442d0cddf3518fd01bc diff --git a/vector b/vector new file mode 160000 index 000000000..da209fdd7 --- /dev/null +++ b/vector @@ -0,0 +1 @@ +Subproject commit da209fdd70e8120ef55280f17936ca9effb7c31c diff --git a/yorozuya b/yorozuya new file mode 160000 index 000000000..5ddba6178 --- /dev/null +++ b/yorozuya @@ -0,0 +1 @@ +Subproject commit 5ddba61782827e742732ea627bca18175179da63