diff --git a/Clover/app/build.gradle b/Clover/app/build.gradle
index 4dbbd0bd..3c795807 100644
--- a/Clover/app/build.gradle
+++ b/Clover/app/build.gradle
@@ -182,4 +182,7 @@ dependencies {
implementation 'me.xdrop:fuzzywuzzy:1.1.10'
implementation 'org.codejargon.feather:feather:1.0'
implementation 'io.sentry:sentry-android:1.7.24'
+
+ testImplementation 'junit:junit:4.12'
+ testImplementation 'org.mockito:mockito-core:2.27.0'
}
diff --git a/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java b/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java
index 28e8d427..f2c2a8b2 100644
--- a/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java
+++ b/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java
@@ -279,10 +279,21 @@ public class ReplyPresenter implements AuthenticationLayoutCallback, ImagePickDe
public void onAuthenticationComplete(AuthenticationLayoutInterface authenticationLayout, String challenge, String response) {
draft.captchaChallenge = challenge;
draft.captchaResponse = response;
- authenticationLayout.reset();
+
+ // we don't need this to be called for new captcha window.
+ // Otherwise "Request captcha request is already in progress" message will be shown
+ if (authenticationLayout.requireResetAfterComplete()) {
+ authenticationLayout.reset();
+ }
+
makeSubmitCall();
}
+ @Override
+ public void onFallbackToV1CaptchaView() {
+ callback.onFallbackToV1CaptchaView();
+ }
+
public void onCommentTextChanged(CharSequence text) {
int length = text.toString().getBytes(UTF_8).length;
callback.updateCommentCount(length, board.maxCommentChars, length > board.maxCommentChars);
@@ -389,6 +400,7 @@ public class ReplyPresenter implements AuthenticationLayoutCallback, ImagePickDe
callback.openSpoiler(false, false);
callback.openPreview(false, null);
callback.openPreviewMessage(false, null);
+ callback.destroyCurrentAuthentication();
}
private void makeSubmitCall() {
@@ -396,8 +408,12 @@ public class ReplyPresenter implements AuthenticationLayoutCallback, ImagePickDe
switchPage(Page.LOADING, true);
}
- private void switchPage(Page page, boolean animate) {
- if (this.page != page) {
+ public void switchPage(Page page, boolean animate) {
+ switchPage(page, animate, ChanSettings.useNewCaptchaWindow.get());
+ }
+
+ public void switchPage(Page page, boolean animate, boolean useV2NoJsCaptcha) {
+ if (!useV2NoJsCaptcha || this.page != page) {
this.page = page;
switch (page) {
case LOADING:
@@ -409,7 +425,9 @@ public class ReplyPresenter implements AuthenticationLayoutCallback, ImagePickDe
case AUTHENTICATION:
SiteAuthentication authentication = loadable.site.actions().postAuthenticate();
- callback.initializeAuthentication(loadable.site, authentication, this);
+ // cleanup resources tied to the new captcha layout/presenter
+ callback.destroyCurrentAuthentication();
+ callback.initializeAuthentication(loadable.site, authentication, this, useV2NoJsCaptcha);
callback.setPage(Page.AUTHENTICATION, true);
break;
@@ -470,8 +488,10 @@ public class ReplyPresenter implements AuthenticationLayoutCallback, ImagePickDe
void setPage(Page page, boolean animate);
- void initializeAuthentication(Site site, SiteAuthentication authentication,
- AuthenticationLayoutCallback callback);
+ void initializeAuthentication(Site site,
+ SiteAuthentication authentication,
+ AuthenticationLayoutCallback callback,
+ boolean useV2NoJsCaptcha);
void resetAuthentication();
@@ -518,5 +538,9 @@ public class ReplyPresenter implements AuthenticationLayoutCallback, ImagePickDe
ChanThread getThread();
void focusComment();
+
+ void onFallbackToV1CaptchaView();
+
+ void destroyCurrentAuthentication();
}
}
diff --git a/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java b/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
index 0fc9128f..2dd373e9 100644
--- a/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
+++ b/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
@@ -160,6 +160,7 @@ public class ChanSettings {
public static final LongSetting updateCheckInterval;
public static final BooleanSetting crashReporting;
+ public static final BooleanSetting useNewCaptchaWindow;
static {
SettingProvider p = new SharedPreferencesSettingProvider(AndroidUtils.getPreferences());
@@ -250,6 +251,7 @@ public class ChanSettings {
updateCheckInterval = new LongSetting(p, "update_check_interval", UpdateManager.DEFAULT_UPDATE_CHECK_INTERVAL_MS);
crashReporting = new BooleanSetting(p, "preference_crash_reporting", true);
+ useNewCaptchaWindow = new BooleanSetting(p, "use_new_captcha_window", true);
// Old (but possibly still in some users phone)
// preference_board_view_mode default "list"
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutCallback.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutCallback.java
index 019f85f8..10e6cc0e 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutCallback.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutCallback.java
@@ -20,4 +20,5 @@ package org.floens.chan.ui.captcha;
public interface AuthenticationLayoutCallback {
void onAuthenticationComplete(AuthenticationLayoutInterface authenticationLayout,
String challenge, String response);
+ void onFallbackToV1CaptchaView();
}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutInterface.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutInterface.java
index a3f2c10a..ce6203dd 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutInterface.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/AuthenticationLayoutInterface.java
@@ -24,5 +24,9 @@ public interface AuthenticationLayoutInterface {
void reset();
+ boolean requireResetAfterComplete();
+
void hardReset();
+
+ void onDestroy();
}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaLayout.java
index a3c0af4b..601595ad 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaLayout.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaLayout.java
@@ -121,6 +121,15 @@ public class CaptchaLayout extends WebView implements AuthenticationLayoutInterf
loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null);
}
+ @Override
+ public boolean requireResetAfterComplete() {
+ return true;
+ }
+
+ @Override
+ public void onDestroy() {
+ }
+
private void onCaptchaLoaded() {
}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/GenericWebViewAuthenticationLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/GenericWebViewAuthenticationLayout.java
index 21473b0a..bad7d1d0 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/captcha/GenericWebViewAuthenticationLayout.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/GenericWebViewAuthenticationLayout.java
@@ -80,6 +80,16 @@ public class GenericWebViewAuthenticationLayout extends WebView implements Authe
public void hardReset() {
}
+ @Override
+ public boolean requireResetAfterComplete() {
+ return true;
+ }
+
+ @Override
+ public void onDestroy() {
+
+ }
+
private void checkText() {
loadUrl("javascript:WebInterface.onAllText(document.documentElement.textContent)");
}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/LegacyCaptchaLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/LegacyCaptchaLayout.java
index e7e6e9b3..34485940 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/captcha/LegacyCaptchaLayout.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/LegacyCaptchaLayout.java
@@ -142,6 +142,16 @@ public class LegacyCaptchaLayout extends LinearLayout implements AuthenticationL
input.requestFocus();
}
+ @Override
+ public boolean requireResetAfterComplete() {
+ return true;
+ }
+
+ @Override
+ public void onDestroy() {
+
+ }
+
private void submitCaptcha() {
AndroidUtils.hideKeyboard(this);
callback.onAuthenticationComplete(this, challenge, input.getText().toString());
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaNojsLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v1/CaptchaNojsLayoutV1.java
similarity index 87%
rename from Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaNojsLayout.java
rename to Clover/app/src/main/java/org/floens/chan/ui/captcha/v1/CaptchaNojsLayoutV1.java
index 254b0daa..8fb5f1f8 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/captcha/CaptchaNojsLayout.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v1/CaptchaNojsLayoutV1.java
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.floens.chan.ui.captcha;
+package org.floens.chan.ui.captcha.v1;
import android.annotation.SuppressLint;
import android.content.Context;
@@ -32,6 +32,8 @@ import android.webkit.WebViewClient;
import org.floens.chan.core.site.SiteAuthentication;
import org.floens.chan.core.site.Site;
+import org.floens.chan.ui.captcha.AuthenticationLayoutCallback;
+import org.floens.chan.ui.captcha.AuthenticationLayoutInterface;
import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.Logger;
@@ -48,7 +50,7 @@ import okhttp3.ResponseBody;
* It directly loads the captcha2 fallback url into a webview, and on each requests it executes
* some javascript that will tell the callback if the token is there.
*/
-public class CaptchaNojsLayout extends WebView implements AuthenticationLayoutInterface {
+public class CaptchaNojsLayoutV1 extends WebView implements AuthenticationLayoutInterface {
private static final String TAG = "CaptchaNojsLayout";
private AuthenticationLayoutCallback callback;
@@ -59,15 +61,15 @@ public class CaptchaNojsLayout extends WebView implements AuthenticationLayoutIn
private String webviewUserAgent;
- public CaptchaNojsLayout(Context context) {
+ public CaptchaNojsLayoutV1(Context context) {
super(context);
}
- public CaptchaNojsLayout(Context context, AttributeSet attrs) {
+ public CaptchaNojsLayoutV1(Context context, AttributeSet attrs) {
super(context, attrs);
}
- public CaptchaNojsLayout(Context context, AttributeSet attrs, int defStyle) {
+ public CaptchaNojsLayoutV1(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@@ -114,7 +116,7 @@ public class CaptchaNojsLayout extends WebView implements AuthenticationLayoutIn
return false;
}
- if (host.equals(Uri.parse(CaptchaNojsLayout.this.baseUrl).getHost())) {
+ if (host.equals(Uri.parse(CaptchaNojsLayoutV1.this.baseUrl).getHost())) {
return false;
} else {
AndroidUtils.openLink(url);
@@ -131,11 +133,20 @@ public class CaptchaNojsLayout extends WebView implements AuthenticationLayoutIn
hardReset();
}
+ @Override
+ public boolean requireResetAfterComplete() {
+ return true;
+ }
+
@Override
public void hardReset() {
loadRecaptchaAndSetWebViewData();
}
+ @Override
+ public void onDestroy() {
+ }
+
private void loadRecaptchaAndSetWebViewData() {
final String recaptchaUrl = "https://www.google.com/recaptcha/api/fallback?k=" + siteKey;
@@ -172,9 +183,9 @@ public class CaptchaNojsLayout extends WebView implements AuthenticationLayoutIn
}
public static class CaptchaInterface {
- private final CaptchaNojsLayout layout;
+ private final CaptchaNojsLayoutV1 layout;
- public CaptchaInterface(CaptchaNojsLayout layout) {
+ public CaptchaInterface(CaptchaNojsLayoutV1 layout) {
this.layout = layout;
}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaInfo.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaInfo.java
new file mode 100644
index 00000000..1a878ab5
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaInfo.java
@@ -0,0 +1,139 @@
+/*
+ * Clover - 4chan browser https://github.com/Floens/Clover/
+ * Copyright (C) 2014 Floens
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.floens.chan.ui.captcha.v2;
+
+import android.graphics.Bitmap;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CaptchaInfo {
+ CaptchaType captchaType;
+ @NonNull
+ List checkboxes;
+ @Nullable
+ String cParameter;
+ @NonNull
+ List challengeImages;
+ @Nullable
+ CaptchaTitle captchaTitle;
+
+ public CaptchaInfo() {
+ captchaType = CaptchaType.Unknown;
+ checkboxes = new ArrayList<>();
+ cParameter = null;
+ challengeImages = null;
+ captchaTitle = null;
+ }
+
+ public void setCaptchaType(CaptchaType captchaType) {
+ this.captchaType = captchaType;
+ }
+
+ public void setCheckboxes(@NonNull List checkboxes) {
+ this.checkboxes = checkboxes;
+ }
+
+ public void setcParameter(@Nullable String cParameter) {
+ this.cParameter = cParameter;
+ }
+
+ public void setChallengeImages(@NonNull List challengeImages) {
+ this.challengeImages = challengeImages;
+ }
+
+ public void setCaptchaTitle(@Nullable CaptchaTitle captchaTitle) {
+ this.captchaTitle = captchaTitle;
+ }
+
+ public CaptchaType getCaptchaType() {
+ return captchaType;
+ }
+
+ @NonNull
+ public List getChallengeImages() {
+ return challengeImages;
+ }
+
+ @NonNull
+ public List getCheckboxes() {
+ return checkboxes;
+ }
+
+ @Nullable
+ public String getcParameter() {
+ return cParameter;
+ }
+
+ @Nullable
+ public CaptchaTitle getCaptchaTitle() {
+ return captchaTitle;
+ }
+
+ public enum CaptchaType {
+ Unknown,
+ // 3x3
+ Canonical,
+ // 2x4
+ NoCanonical;
+
+ public static CaptchaType fromCheckboxesCount(int count) {
+ if (count == 8) {
+ return NoCanonical;
+ } else if (count == 9) {
+ return Canonical;
+ }
+
+ return Unknown;
+ }
+ }
+
+ public static class CaptchaTitle {
+ private String title;
+ private int boldStart = -1;
+ private int boldEnd = -1;
+
+ public CaptchaTitle(String title, int boldStart, int boldEnd) {
+ this.title = title;
+ this.boldStart = boldStart;
+ this.boldEnd = boldEnd;
+ }
+
+ public boolean isEmpty() {
+ return title.isEmpty() && boldStart == -1 && boldEnd == -1;
+ }
+
+ public boolean hasBold() {
+ return boldStart != -1 && boldEnd != -1;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public int getBoldStart() {
+ return boldStart;
+ }
+
+ public int getBoldEnd() {
+ return boldEnd;
+ }
+ }
+}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParser.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParser.java
new file mode 100644
index 00000000..17edaee3
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParser.java
@@ -0,0 +1,400 @@
+/*
+ * Clover - 4chan browser https://github.com/Floens/Clover/
+ * Copyright (C) 2014 Floens
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.floens.chan.ui.captcha.v2;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.support.annotation.NonNull;
+import android.support.v4.util.Pair;
+
+import org.floens.chan.utils.BackgroundUtils;
+import org.floens.chan.utils.IOUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class CaptchaNoJsHtmlParser {
+ private static final String TAG = "CaptchaNoJsHtmlParser";
+ private static final String googleBaseUrl = "https://www.google.com";
+ private static final Pattern checkboxesPattern =
+ Pattern.compile("");
+
+ // FIXME: this pattern captures the C parameter as many times as it is in the HTML.
+ // Should match only the first occurrence instead.
+ private static final Pattern cParameterPattern =
+ Pattern.compile("");
+ private static final Pattern challengeTitlePattern =
+ Pattern.compile("(.*?)
");
+ private static final Pattern challengeImageUrlPattern =
+ Pattern.compile("
(.*?)");
+ private static final Pattern verificationTokenPattern =
+ Pattern.compile("");
+ private static final String CHALLENGE_IMAGE_FILE_NAME = "challenge_image_file";
+ private static final int SUCCESS_STATUS_CODE = 200;
+
+ private OkHttpClient okHttpClient;
+ private Context context;
+
+ public CaptchaNoJsHtmlParser(Context context, OkHttpClient okHttpClient) {
+ this.context = context;
+ this.okHttpClient = okHttpClient;
+ }
+
+ @NonNull
+ public CaptchaInfo parseHtml(String responseHtml, String siteKey)
+ throws CaptchaNoJsV2ParsingError, IOException {
+ if (BackgroundUtils.isMainThread()) {
+ throw new RuntimeException("Must not be executed on the main thread!");
+ }
+
+ CaptchaInfo captchaInfo = new CaptchaInfo();
+
+ // parse challenge checkboxes' ids
+ parseCheckboxes(responseHtml, captchaInfo);
+
+ // parse captcha random key
+ parseCParameter(responseHtml, captchaInfo);
+
+ // parse title
+ parseChallengeTitle(responseHtml, captchaInfo);
+
+ // parse image url, download image and split it into list of separate images
+ parseAndDownloadChallengeImage(responseHtml, captchaInfo, siteKey);
+
+ return captchaInfo;
+ }
+
+ @NonNull
+ String parseVerificationToken(String responseHtml) throws CaptchaNoJsV2ParsingError {
+ if (BackgroundUtils.isMainThread()) {
+ throw new RuntimeException("Must not be executed on the main thread!");
+ }
+
+ Matcher matcher = verificationTokenPattern.matcher(responseHtml);
+ if (!matcher.find()) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse verification token");
+ }
+
+ String token;
+
+ try {
+ token = matcher.group(1);
+ } catch (Exception error) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse verification token", error);
+ }
+
+ if (token == null || token.isEmpty()) {
+ throw new CaptchaNoJsV2ParsingError("Verification token is null or empty");
+ }
+
+ return token;
+ }
+
+ public void parseChallengeTitle(
+ String responseHtml,
+ CaptchaInfo captchaInfo
+ ) throws CaptchaNoJsV2ParsingError {
+ Matcher matcher = challengeTitlePattern.matcher(responseHtml);
+ if (!matcher.find()) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse challenge title " + responseHtml);
+ }
+
+ CaptchaInfo.CaptchaTitle captchaTitle;
+
+ try {
+ String title = matcher.group(2);
+ Matcher titleMatcher = challengeTitleBoldPartPattern.matcher(title);
+
+ if (titleMatcher.find()) {
+ // find the part of the title that should be bold
+ int start = title.indexOf("");
+
+ String firstPart = title.substring(0, start);
+ String boldPart = titleMatcher.group(1);
+ String resultTitle = firstPart + boldPart;
+
+ captchaTitle = new CaptchaInfo.CaptchaTitle(
+ resultTitle,
+ firstPart.length(),
+ firstPart.length() + boldPart.length());
+ } else {
+ // could not find it
+ captchaTitle = new CaptchaInfo.CaptchaTitle(title, -1, -1);
+ }
+ } catch (Exception error) {
+ throw new CaptchaNoJsV2ParsingError("Error while trying to parse challenge title", error);
+ }
+
+ if (captchaTitle.isEmpty()) {
+ throw new CaptchaNoJsV2ParsingError("challengeTitle is empty");
+ }
+
+ captchaInfo.setCaptchaTitle(captchaTitle);
+ }
+
+ private void parseAndDownloadChallengeImage(
+ String responseHtml,
+ CaptchaInfo captchaInfo,
+ String siteKey
+ ) throws CaptchaNoJsV2ParsingError, IOException {
+ Matcher matcher = challengeImageUrlPattern.matcher(responseHtml);
+ if (!matcher.find()) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse challenge image url");
+ }
+
+ String challengeImageUrl;
+
+ try {
+ challengeImageUrl = matcher.group(1);
+ } catch (Exception error) {
+ throw new CaptchaNoJsV2ParsingError("Error while trying to parse challenge image url", error);
+ }
+
+ if (challengeImageUrl == null) {
+ throw new CaptchaNoJsV2ParsingError("challengeImageUrl is null");
+ }
+
+ if (challengeImageUrl.isEmpty()) {
+ throw new CaptchaNoJsV2ParsingError("challengeImageUrl is empty");
+ }
+
+ String fullUrl = googleBaseUrl + challengeImageUrl + "&k=" + siteKey;
+ File challengeImageFile = downloadAndStoreImage(fullUrl);
+
+ Pair columnsAndRows = calculateColumnsAndRows(
+ captchaInfo.captchaType
+ );
+
+ Integer columns = columnsAndRows.first;
+ Integer rows = columnsAndRows.second;
+
+ if (columns == null) {
+ throw new CaptchaNoJsV2ParsingError("Could not calculate columns count");
+ }
+
+ if (rows == null) {
+ throw new CaptchaNoJsV2ParsingError("Could not calculate rows count");
+ }
+
+ List challengeImages = decodeImagesFromFile(challengeImageFile, columns, rows);
+ captchaInfo.setChallengeImages(challengeImages);
+ }
+
+ @NonNull
+ private Pair calculateColumnsAndRows(
+ CaptchaInfo.CaptchaType captchaType
+ ) throws CaptchaNoJsV2ParsingError {
+ switch (captchaType) {
+ case Canonical: {
+ // 3x3 captcha with square images
+ return new Pair<>(3, 3);
+ }
+
+ case NoCanonical: {
+ // 2x4 captcha with rectangle images (store fronts)
+ return new Pair<>(2, 4);
+ }
+
+ default: {
+ throw new CaptchaNoJsV2ParsingError("Unknown captcha type");
+ }
+ }
+ }
+
+ public void parseCParameter(
+ String responseHtml,
+ CaptchaInfo captchaInfo
+ ) throws CaptchaNoJsV2ParsingError {
+ Matcher matcher = cParameterPattern.matcher(responseHtml);
+ if (!matcher.find()) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse c parameter");
+ }
+
+ String cParameter;
+
+ try {
+ cParameter = matcher.group(1);
+ } catch (Exception error) {
+ throw new CaptchaNoJsV2ParsingError("Error while trying to parse c parameter", error);
+ }
+
+ if (cParameter == null) {
+ throw new CaptchaNoJsV2ParsingError("cParameter is null");
+ }
+
+ if (cParameter.isEmpty()) {
+ throw new CaptchaNoJsV2ParsingError("cParameter is empty");
+ }
+
+ captchaInfo.setcParameter(cParameter);
+ }
+
+ public void parseCheckboxes(
+ String responseHtml,
+ CaptchaInfo captchaInfo
+ ) throws CaptchaNoJsV2ParsingError {
+ Matcher matcher = checkboxesPattern.matcher(responseHtml);
+ Set checkboxesSet = new HashSet<>(matcher.groupCount());
+ int index = 0;
+
+ while (matcher.find()) {
+ try {
+ Integer checkboxId = Integer.parseInt(matcher.group(1));
+ checkboxesSet.add(checkboxId);
+ } catch (Exception error) {
+ throw new CaptchaNoJsV2ParsingError("Error while trying to parse checkbox with id (" + index + ")", error);
+ }
+
+ ++index;
+ }
+
+ if (checkboxesSet.isEmpty()) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse any checkboxes!");
+ }
+
+ CaptchaInfo.CaptchaType captchaType;
+
+ try {
+ captchaType = CaptchaInfo.CaptchaType.fromCheckboxesCount(checkboxesSet.size());
+ } catch (Exception error) {
+ throw new CaptchaNoJsV2ParsingError("Error while trying to parse captcha type", error);
+ }
+
+ if (captchaType == CaptchaInfo.CaptchaType.Unknown) {
+ throw new CaptchaNoJsV2ParsingError("Unknown captcha type");
+ }
+
+ captchaInfo.setCaptchaType(captchaType);
+ captchaInfo.setCheckboxes(new ArrayList<>(checkboxesSet));
+ }
+
+ @NonNull
+ private File downloadAndStoreImage(String fullUrl) throws IOException, CaptchaNoJsV2ParsingError {
+ Request request = new Request.Builder()
+ .url(fullUrl)
+ .build();
+
+ try (Response response = okHttpClient.newCall(request).execute()) {
+ if (response.code() != SUCCESS_STATUS_CODE) {
+ throw new CaptchaNoJsV2ParsingError(
+ "Could not download challenge image, status code = " + response.code());
+ }
+
+ if (response.body() == null) {
+ throw new CaptchaNoJsV2ParsingError("Captcha challenge image body is null");
+ }
+
+ try (InputStream is = response.body().byteStream()) {
+ File imageFile = getChallengeImageFile();
+
+ try (OutputStream os = new FileOutputStream(imageFile)) {
+ IOUtils.copy(is, os);
+ }
+
+ return imageFile;
+ }
+ }
+ }
+
+ private File getChallengeImageFile() throws CaptchaNoJsV2ParsingError, IOException {
+ File imageFile = new File(context.getCacheDir(), CHALLENGE_IMAGE_FILE_NAME);
+
+ if (imageFile.exists()) {
+ if (!imageFile.delete()) {
+ throw new CaptchaNoJsV2ParsingError(
+ "Could not delete old challenge image file \"" + imageFile.getAbsolutePath() + "\"");
+ }
+ }
+
+ if (!imageFile.createNewFile()) {
+ throw new CaptchaNoJsV2ParsingError(
+ "Could not create challenge image file \"" + imageFile.getAbsolutePath() + "\"");
+ }
+
+ if (!imageFile.canWrite()) {
+ throw new CaptchaNoJsV2ParsingError(
+ "Cannot write to the challenge image file \"" + imageFile.getAbsolutePath() + "\"");
+ }
+
+ return imageFile;
+ }
+
+ private List decodeImagesFromFile(File challengeImageFile, int columns, int rows) {
+ Bitmap originalBitmap = BitmapFactory.decodeFile(challengeImageFile.getAbsolutePath());
+ List resultImages = new ArrayList<>(columns * rows);
+
+ try {
+ int imageWidth = originalBitmap.getWidth() / columns;
+ int imageHeight = originalBitmap.getHeight() / rows;
+
+ for (int column = 0; column < columns; ++column) {
+ for (int row = 0; row < rows; ++row) {
+ Bitmap imagePiece = Bitmap.createBitmap(
+ originalBitmap,
+ row * imageWidth,
+ column * imageHeight,
+ imageWidth,
+ imageHeight);
+
+ resultImages.add(imagePiece);
+ }
+ }
+
+ return resultImages;
+ } catch (Exception error) {
+ for (Bitmap bitmap : resultImages) {
+ if (!bitmap.isRecycled()) {
+ bitmap.recycle();
+ }
+ }
+
+ resultImages.clear();
+ throw error;
+ } finally {
+ if (originalBitmap != null) {
+ originalBitmap.recycle();
+ }
+ }
+ }
+
+ public static class CaptchaNoJsV2ParsingError extends Exception {
+ public CaptchaNoJsV2ParsingError(String message) {
+ super(message);
+ }
+
+ public CaptchaNoJsV2ParsingError(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsLayoutV2.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsLayoutV2.java
new file mode 100644
index 00000000..cfefbb52
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsLayoutV2.java
@@ -0,0 +1,303 @@
+/*
+ * Clover - 4chan browser https://github.com/Floens/Clover/
+ * Copyright (C) 2014 Floens
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.floens.chan.ui.captcha.v2;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Typeface;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.constraint.ConstraintLayout;
+import android.support.v7.widget.AppCompatButton;
+import android.support.v7.widget.AppCompatTextView;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.StyleSpan;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.GridView;
+import android.widget.ScrollView;
+import android.widget.Toast;
+
+import org.floens.chan.R;
+import org.floens.chan.core.site.Site;
+import org.floens.chan.core.site.SiteAuthentication;
+import org.floens.chan.ui.captcha.AuthenticationLayoutCallback;
+import org.floens.chan.ui.captcha.AuthenticationLayoutInterface;
+import org.floens.chan.utils.AndroidUtils;
+import org.floens.chan.utils.Logger;
+
+import java.util.List;
+
+public class CaptchaNoJsLayoutV2 extends FrameLayout
+ implements AuthenticationLayoutInterface,
+ CaptchaNoJsPresenterV2.AuthenticationCallbacks, View.OnClickListener {
+ private static final String TAG = "CaptchaNoJsLayoutV2";
+
+ private AppCompatTextView captchaChallengeTitle;
+ private GridView captchaImagesGrid;
+ private AppCompatButton captchaVerifyButton;
+ private AppCompatButton useOldCaptchaButton;
+ private AppCompatButton reloadCaptchaButton;
+
+ private CaptchaNoJsV2Adapter adapter;
+ private CaptchaNoJsPresenterV2 presenter;
+ private Context context;
+ private AuthenticationLayoutCallback callback;
+
+ public CaptchaNoJsLayoutV2(@NonNull Context context) {
+ super(context);
+ init(context);
+ }
+
+ public CaptchaNoJsLayoutV2(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init(context);
+ }
+
+ public CaptchaNoJsLayoutV2(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context);
+ }
+
+ private void init(Context context) {
+ this.context = context;
+ this.presenter = new CaptchaNoJsPresenterV2(this, context);
+ this.adapter = new CaptchaNoJsV2Adapter(context);
+
+ View view = inflate(context, R.layout.layout_captcha_nojs_v2, this);
+
+ captchaChallengeTitle = view.findViewById(R.id.captcha_layout_v2_title);
+ captchaImagesGrid = view.findViewById(R.id.captcha_layout_v2_images_grid);
+ captchaVerifyButton = view.findViewById(R.id.captcha_layout_v2_verify_button);
+ useOldCaptchaButton = view.findViewById(R.id.captcha_layout_v2_use_old_captcha_button);
+ reloadCaptchaButton = view.findViewById(R.id.captcha_layout_v2_reload_button);
+ ConstraintLayout buttonsHolder = view.findViewById(R.id.captcha_layout_v2_buttons_holder);
+ ScrollView background = view.findViewById(R.id.captcha_layout_v2_background);
+
+ background.setBackgroundColor(AndroidUtils.getAttrColor(getContext(), R.attr.backcolor));
+ buttonsHolder.setBackgroundColor(AndroidUtils.getAttrColor(getContext(), R.attr.backcolor_secondary));
+ captchaChallengeTitle.setBackgroundColor(AndroidUtils.getAttrColor(getContext(), R.attr.backcolor_secondary));
+ captchaChallengeTitle.setTextColor(AndroidUtils.getAttrColor(getContext(), R.attr.text_color_primary));
+ captchaVerifyButton.setTextColor(AndroidUtils.getAttrColor(getContext(), R.attr.text_color_primary));
+ useOldCaptchaButton.setTextColor(AndroidUtils.getAttrColor(getContext(), R.attr.text_color_primary));
+ reloadCaptchaButton.setTextColor(AndroidUtils.getAttrColor(getContext(), R.attr.text_color_primary));
+
+ captchaVerifyButton.setOnClickListener(this);
+ useOldCaptchaButton.setOnClickListener(this);
+ reloadCaptchaButton.setOnClickListener(this);
+
+ captchaVerifyButton.setEnabled(false);
+ }
+
+ @Override
+ protected void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ reset();
+ }
+
+ @Override
+ public void initialize(Site site, AuthenticationLayoutCallback callback) {
+ this.callback = callback;
+
+ SiteAuthentication authentication = site.actions().postAuthenticate();
+ if (authentication.type != SiteAuthentication.Type.CAPTCHA2_NOJS) {
+ callback.onFallbackToV1CaptchaView();
+ return;
+ }
+
+ presenter.init(authentication.siteKey, authentication.baseUrl);
+ }
+
+ @Override
+ public void reset() {
+ hardReset();
+ }
+
+ @Override
+ public void hardReset() {
+ CaptchaNoJsPresenterV2.RequestCaptchaInfoError captchaInfoError = presenter.requestCaptchaInfo();
+
+ switch (captchaInfoError) {
+ case Ok:
+ break;
+ case HoldYourHorses:
+ showToast(getContext().getString(
+ R.string.captcha_layout_v2_you_are_requesting_captcha_too_fast));
+ break;
+ case AlreadyInProgress:
+ showToast(getContext().getString(
+ R.string.captcha_layout_v2_captcha_request_is_already_in_progress));
+ break;
+ case AlreadyShutdown:
+ // do nothing
+ break;
+ }
+ }
+
+ @Override
+ public boolean requireResetAfterComplete() {
+ return false;
+ }
+
+ @Override
+ public void onDestroy() {
+ adapter.onDestroy();
+ presenter.onDestroy();
+ }
+
+ @Override
+ public void onCaptchaInfoParsed(CaptchaInfo captchaInfo) {
+ // called on a background thread
+
+ AndroidUtils.runOnUiThread(() -> {
+ captchaVerifyButton.setEnabled(true);
+ renderCaptchaWindow(captchaInfo);
+ });
+ }
+
+ @Override
+ public void onVerificationDone(String verificationToken) {
+ // called on a background thread
+
+ AndroidUtils.runOnUiThread(() -> {
+ captchaVerifyButton.setEnabled(true);
+ callback.onAuthenticationComplete(this, null, verificationToken);
+ });
+ }
+ // Called when we got response from re-captcha but could not parse some part of it
+
+ @Override
+ public void onCaptchaInfoParseError(Throwable error) {
+ // called on a background thread
+
+ handleError(true, error);
+ }
+
+ private void handleError(boolean shouldFallback, Throwable error) {
+ AndroidUtils.runOnUiThread(() -> {
+ Logger.e(TAG, "CaptchaV2 error", error);
+
+ String message = error.getMessage();
+ showToast(message);
+
+ captchaVerifyButton.setEnabled(true);
+
+ if (shouldFallback) {
+ callback.onFallbackToV1CaptchaView();
+ }
+ });
+ }
+
+ private void showToast(String message) {
+ AndroidUtils.runOnUiThread(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v == captchaVerifyButton) {
+ sendVerificationResponse();
+ } else if (v == useOldCaptchaButton) {
+ callback.onFallbackToV1CaptchaView();
+ } else if (v == reloadCaptchaButton) {
+ reset();
+ }
+ }
+
+ private void renderCaptchaWindow(CaptchaInfo captchaInfo) {
+ try {
+ setCaptchaTitle(captchaInfo);
+
+ captchaImagesGrid.setAdapter(null);
+ captchaImagesGrid.setAdapter(adapter);
+
+ int columnsCount;
+ int imageSize = captchaImagesGrid.getWidth();
+
+ switch (captchaInfo.getCaptchaType()) {
+ case Canonical:
+ columnsCount = 3;
+ imageSize /= columnsCount;
+ break;
+ case NoCanonical:
+ columnsCount = 2;
+ imageSize /= columnsCount;
+ break;
+ default:
+ throw new IllegalStateException("Unknown captcha type");
+ }
+
+ captchaImagesGrid.setNumColumns(columnsCount);
+
+ adapter.setImageSize(imageSize);
+ adapter.setImages(captchaInfo.challengeImages);
+
+ captchaVerifyButton.setEnabled(true);
+ } catch (Throwable error) {
+ Logger.e(TAG, "renderCaptchaWindow", error);
+ if (callback != null) {
+ callback.onFallbackToV1CaptchaView();
+ }
+ }
+ }
+
+ private void setCaptchaTitle(CaptchaInfo captchaInfo) {
+ if (captchaInfo.getCaptchaTitle() == null) {
+ return;
+ }
+
+ if (captchaInfo.getCaptchaTitle().hasBold()) {
+ SpannableString spannableString = new SpannableString(captchaInfo.getCaptchaTitle().getTitle());
+ spannableString.setSpan(
+ new StyleSpan(Typeface.BOLD),
+ captchaInfo.getCaptchaTitle().getBoldStart(),
+ captchaInfo.getCaptchaTitle().getBoldEnd(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ captchaChallengeTitle.setText(spannableString);
+ } else {
+ captchaChallengeTitle.setText(captchaInfo.getCaptchaTitle().getTitle());
+ }
+ }
+
+ private void sendVerificationResponse() {
+ List selectedIds = adapter.getCheckedImageIds();
+
+ try {
+ CaptchaNoJsPresenterV2.VerifyError verifyError = presenter.verify(selectedIds);
+ switch (verifyError) {
+ case Ok:
+ captchaVerifyButton.setEnabled(false);
+ break;
+ case NoImagesSelected:
+ showToast(getContext().getString(R.string.captcha_layout_v2_you_have_to_select_at_least_one_image));
+ break;
+ case AlreadyInProgress:
+ showToast(getContext().getString(R.string.captcha_layout_v2_verification_already_in_progress));
+ break;
+ case AlreadyShutdown:
+ // do nothing
+ break;
+ }
+ } catch (Throwable error) {
+ onCaptchaInfoParseError(error);
+ }
+ }
+}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsPresenterV2.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsPresenterV2.java
new file mode 100644
index 00000000..a8069a28
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsPresenterV2.java
@@ -0,0 +1,370 @@
+/*
+ * Clover - 4chan browser https://github.com/Floens/Clover/
+ * Copyright (C) 2014 Floens
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.floens.chan.ui.captcha.v2;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+
+import org.floens.chan.utils.BackgroundUtils;
+import org.floens.chan.utils.Logger;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import okhttp3.MediaType;
+import okhttp3.MultipartBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public class CaptchaNoJsPresenterV2 {
+ private static final String TAG = "CaptchaNoJsPresenterV2";
+ private static final String userAgentHeader = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36";
+ private static final String acceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3";
+ private static final String acceptEncodingHeader = "deflate, br";
+ private static final String acceptLanguageHeader = "en-US";
+ private static final String recaptchaUrlBase = "https://www.google.com/recaptcha/api/fallback?k=";
+ private static final String encoding = "UTF-8";
+ private static final String mediaType = "application/x-www-form-urlencoded";
+ private static final String recaptchaChallengeString = "reCAPTCHA challenge";
+ private static final String verificationTokenString = "fbc-verification-token";
+ private static final int SUCCESS_STATUS_CODE = 200;
+ private static final long CAPTCHA_REQUEST_THROTTLE_MS = 3000L;
+
+ // this cookie is taken from dashchan
+ private static final String defaultGoogleCookies = "NID=87=gkOAkg09AKnvJosKq82kgnDnHj8Om2pLskKhdna02msog8HkdHDlasDf";
+
+ // TODO: inject this in the future when https://github.com/Floens/Clover/pull/678 is merged
+ private final OkHttpClient okHttpClient = new OkHttpClient();
+
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private final CaptchaNoJsHtmlParser parser;
+
+ @Nullable
+ private AuthenticationCallbacks callbacks;
+ @Nullable
+ private CaptchaInfo prevCaptchaInfo = null;
+
+ private AtomicBoolean verificationInProgress = new AtomicBoolean(false);
+ private AtomicBoolean captchaRequestInProgress = new AtomicBoolean(false);
+ private String siteKey;
+ private String baseUrl;
+ private long lastTimeCaptchaRequest = 0L;
+
+ public CaptchaNoJsPresenterV2(@Nullable AuthenticationCallbacks callbacks, Context context) {
+ this.callbacks = callbacks;
+ this.parser = new CaptchaNoJsHtmlParser(context, okHttpClient);
+ }
+
+ public void init(String siteKey, String baseUrl) {
+ this.siteKey = siteKey;
+ this.baseUrl = baseUrl;
+ }
+
+ /**
+ * Send challenge solution back to the recaptcha
+ */
+ public VerifyError verify(
+ List selectedIds
+ ) throws CaptchaNoJsV2Error {
+ if (!verificationInProgress.compareAndSet(false, true)) {
+ Logger.d(TAG, "Verify captcha request is already in progress");
+ return VerifyError.AlreadyInProgress;
+ }
+
+ if (executor.isShutdown()) {
+ verificationInProgress.set(false);
+ Logger.d(TAG, "Cannot verify, executor has been shut down");
+ return VerifyError.AlreadyShutdown;
+ }
+
+ try {
+ if (selectedIds.isEmpty()) {
+ verificationInProgress.set(false);
+ return VerifyError.NoImagesSelected;
+ }
+
+ if (prevCaptchaInfo == null) {
+ throw new CaptchaNoJsV2Error("prevCaptchaInfo is null");
+ }
+
+ if (prevCaptchaInfo.getcParameter() == null) {
+ throw new CaptchaNoJsV2Error("C parameter is null");
+ }
+
+ executor.submit(() -> {
+ try {
+ String recaptchaUrl = recaptchaUrlBase + siteKey;
+ RequestBody body = createResponseBody(prevCaptchaInfo, selectedIds);
+
+ Logger.d(TAG, "Verify called");
+
+ Request request = new Request.Builder()
+ .url(recaptchaUrl)
+ .post(body)
+ .header("Referer", recaptchaUrl)
+ .header("User-Agent", userAgentHeader)
+ .header("Accept", acceptHeader)
+ .header("Accept-Encoding", acceptEncodingHeader)
+ .header("Accept-Language", acceptLanguageHeader)
+ .header("Cookie", defaultGoogleCookies)
+ .build();
+
+ try (Response response = okHttpClient.newCall(request).execute()) {
+ prevCaptchaInfo = handleGetRecaptchaResponse(response);
+ } finally {
+ verificationInProgress.set(false);
+ }
+ } catch (Throwable error) {
+ if (callbacks != null) {
+ try {
+ prevCaptchaInfo = null;
+ callbacks.onCaptchaInfoParseError(error);
+ } finally {
+ verificationInProgress.set(false);
+ }
+ }
+ }
+ });
+
+ return VerifyError.Ok;
+ } catch (Throwable error) {
+ verificationInProgress.set(false);
+ throw error;
+ }
+ }
+
+ /**
+ * Requests captcha data, parses it and then passes it to the render function
+ */
+ public RequestCaptchaInfoError requestCaptchaInfo() {
+ if (!captchaRequestInProgress.compareAndSet(false, true)) {
+ Logger.d(TAG, "Request captcha request is already in progress");
+ return RequestCaptchaInfoError.AlreadyInProgress;
+ }
+
+ try {
+ // recaptcha may become very angry at you if your are fetching it too fast
+ if (System.currentTimeMillis() - lastTimeCaptchaRequest < CAPTCHA_REQUEST_THROTTLE_MS) {
+ captchaRequestInProgress.set(false);
+ Logger.d(TAG, "Requesting captcha info too fast");
+ return RequestCaptchaInfoError.HoldYourHorses;
+ }
+
+ if (executor.isShutdown()) {
+ captchaRequestInProgress.set(false);
+ Logger.d(TAG, "Cannot request captcha info, executor has been shut down");
+ return RequestCaptchaInfoError.AlreadyShutdown;
+ }
+
+ lastTimeCaptchaRequest = System.currentTimeMillis();
+
+ executor.submit(() -> {
+ try {
+ try {
+ prevCaptchaInfo = getCaptchaInfo();
+ } catch (Throwable error) {
+ if (callbacks != null) {
+ callbacks.onCaptchaInfoParseError(error);
+ }
+
+ throw error;
+ }
+ } catch (Throwable error) {
+ Logger.e(TAG, "Error while executing captcha requests", error);
+
+ prevCaptchaInfo = null;
+ } finally {
+ captchaRequestInProgress.set(false);
+ }
+ });
+
+ return RequestCaptchaInfoError.Ok;
+ } catch (Throwable error) {
+ captchaRequestInProgress.set(false);
+
+ if (callbacks != null) {
+ callbacks.onCaptchaInfoParseError(error);
+ }
+
+ // return ok here too because we already handled this exception in the callback
+ return RequestCaptchaInfoError.Ok;
+ }
+ }
+
+ @Nullable
+ private CaptchaInfo getCaptchaInfo() throws IOException {
+ if (BackgroundUtils.isMainThread()) {
+ throw new RuntimeException("Must not be executed on the main thread");
+ }
+
+ String recaptchaUrl = recaptchaUrlBase + siteKey;
+
+ Request request = new Request.Builder()
+ .url(recaptchaUrl)
+ .header("Referer", baseUrl)
+ .header("User-Agent", userAgentHeader)
+ .header("Accept", acceptHeader)
+ .header("Accept-Encoding", acceptEncodingHeader)
+ .header("Accept-Language", acceptLanguageHeader)
+ .header("Cookie", defaultGoogleCookies)
+ .build();
+
+ try (Response response = okHttpClient.newCall(request).execute()) {
+ return handleGetRecaptchaResponse(response);
+ }
+ }
+
+ private RequestBody createResponseBody(
+ CaptchaInfo prevCaptchaInfo,
+ List selectedIds
+ ) throws UnsupportedEncodingException {
+ StringBuilder sb = new StringBuilder();
+
+ sb.append(URLEncoder.encode("c", encoding));
+ sb.append("=");
+ sb.append(URLEncoder.encode(prevCaptchaInfo.getcParameter(), encoding));
+ sb.append("&");
+
+ for (Integer selectedImageId : selectedIds) {
+ sb.append(URLEncoder.encode("response", encoding));
+ sb.append("=");
+ sb.append(URLEncoder.encode(String.valueOf(selectedImageId), encoding));
+ sb.append("&");
+ }
+
+ String resultBody;
+
+ if (selectedIds.size() > 0) {
+ resultBody = sb.deleteCharAt(sb.length() - 1).toString();
+ } else {
+ resultBody = sb.toString();
+ }
+
+ return MultipartBody.create(
+ MediaType.parse(mediaType),
+ resultBody);
+ }
+
+ @Nullable
+ private CaptchaInfo handleGetRecaptchaResponse(Response response) {
+ try {
+ if (response.code() != SUCCESS_STATUS_CODE) {
+ if (callbacks != null) {
+ callbacks.onCaptchaInfoParseError(
+ new IOException("Bad status code for captcha request = " + response.code()));
+ }
+
+ return null;
+ }
+
+ ResponseBody body = response.body();
+ if (body == null) {
+ if (callbacks != null) {
+ callbacks.onCaptchaInfoParseError(
+ new IOException("Captcha response body is empty (null)"));
+ }
+
+ return null;
+ }
+
+ String bodyString = body.string();
+ if (!bodyString.contains(recaptchaChallengeString)) {
+ throw new IllegalStateException("Response body does not contain \"reCAPTCHA challenge\" string");
+ }
+
+ if (bodyString.contains(verificationTokenString)) {
+ // got the token
+ String verificationToken = parser.parseVerificationToken(bodyString);
+ Logger.d(TAG, "Got the verification token");
+
+ if (callbacks != null) {
+ callbacks.onVerificationDone(verificationToken);
+ }
+
+ return null;
+ } else {
+ // got the challenge
+ CaptchaInfo captchaInfo = parser.parseHtml(bodyString, siteKey);
+ Logger.d(TAG, "Got new challenge");
+
+ if (callbacks != null) {
+ callbacks.onCaptchaInfoParsed(captchaInfo);
+ } else {
+ // Return null when callbacks are null to reset prevCaptchaInfo so that we won't
+ // get stuck without captchaInfo and disabled buttons forever
+ return null;
+ }
+
+ return captchaInfo;
+ }
+ } catch (Exception e) {
+ Logger.e(TAG, "Error while trying to parse captcha html data", e);
+
+ if (callbacks != null) {
+ callbacks.onCaptchaInfoParseError(e);
+ }
+
+ return null;
+ }
+ }
+
+ public void onDestroy() {
+ this.callbacks = null;
+ this.prevCaptchaInfo = null;
+ this.verificationInProgress.set(false);
+ this.captchaRequestInProgress.set(false);
+
+ executor.shutdown();
+ }
+
+ public enum VerifyError {
+ Ok,
+ NoImagesSelected,
+ AlreadyInProgress,
+ AlreadyShutdown
+ }
+
+ public enum RequestCaptchaInfoError {
+ Ok,
+ AlreadyInProgress,
+ HoldYourHorses,
+ AlreadyShutdown
+ }
+
+ public interface AuthenticationCallbacks {
+ void onCaptchaInfoParsed(CaptchaInfo captchaInfo);
+
+ void onCaptchaInfoParseError(Throwable error);
+
+ void onVerificationDone(String verificationToken);
+ }
+
+ public static class CaptchaNoJsV2Error extends Exception {
+ public CaptchaNoJsV2Error(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsV2Adapter.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsV2Adapter.java
new file mode 100644
index 00000000..74a1f9b4
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsV2Adapter.java
@@ -0,0 +1,152 @@
+package org.floens.chan.ui.captcha.v2;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.support.constraint.ConstraintLayout;
+import android.support.v7.widget.AppCompatImageView;
+import android.view.HapticFeedbackConstants;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.BaseAdapter;
+
+import org.floens.chan.R;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CaptchaNoJsV2Adapter extends BaseAdapter {
+ private LayoutInflater inflater;
+ private int imageSize = 0;
+
+ private List imageList = new ArrayList<>();
+
+ public CaptchaNoJsV2Adapter(Context context) {
+ this.inflater = LayoutInflater.from(context);
+ }
+
+ public void setImages(List imageList) {
+ cleanUpImages();
+
+ for (Bitmap bitmap : imageList) {
+ this.imageList.add(new ImageChallengeInfo(bitmap, false));
+ }
+
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getCount() {
+ return imageList.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return position;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(final int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = inflater.inflate(R.layout.layout_captcha_challenge_image, parent, false);
+ }
+
+ AppCompatImageView imageView = convertView.findViewById(R.id.captcha_challenge_image);
+ imageView.setScaleX(1.0f);
+ imageView.setScaleY(1.0f);
+ ConstraintLayout blueCheckmarkHolder = convertView.findViewById(R.id.captcha_challenge_blue_checkmark_holder);
+ blueCheckmarkHolder.setAlpha(0.0f);
+
+ ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(imageSize, imageSize);
+ imageView.setLayoutParams(layoutParams);
+
+ imageView.setOnClickListener((view) -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+
+ imageList.get(position).toggleChecked();
+ boolean isChecked = imageList.get(position).isChecked;
+
+ imageView.animate()
+ .scaleX(isChecked ? 0.8f : 1.0f)
+ .scaleY(isChecked ? 0.8f : 1.0f)
+ .setInterpolator(new DecelerateInterpolator(2.0f))
+ .setDuration(200)
+ .start();
+ blueCheckmarkHolder.animate()
+ .alpha(isChecked ? 1.0f : 0.0f)
+ .setInterpolator(new DecelerateInterpolator(2.0f))
+ .setDuration(200)
+ .start();
+ });
+
+ if (position >= 0 && position <= imageList.size()) {
+ imageView.setImageBitmap(imageList.get(position).getBitmap());
+ }
+
+ return convertView;
+ }
+
+ public List getCheckedImageIds() {
+ List selectedList = new ArrayList<>();
+
+ for (int i = 0; i < imageList.size(); i++) {
+ ImageChallengeInfo info = imageList.get(i);
+
+ if (info.isChecked()) {
+ selectedList.add(i);
+ }
+ }
+
+ return selectedList;
+ }
+
+ public void setImageSize(int imageSize) {
+ this.imageSize = imageSize;
+ }
+
+ public void onDestroy() {
+ cleanUpImages();
+ }
+
+ private void cleanUpImages() {
+ for (ImageChallengeInfo imageChallengeInfo : imageList) {
+ imageChallengeInfo.recycle();
+ }
+
+ imageList.clear();
+ }
+
+ public static class ImageChallengeInfo {
+ private Bitmap bitmap;
+ private boolean isChecked;
+
+ public ImageChallengeInfo(Bitmap bitmap, boolean isChecked) {
+ this.bitmap = bitmap;
+ this.isChecked = isChecked;
+ }
+
+ public Bitmap getBitmap() {
+ return bitmap;
+ }
+
+ public boolean isChecked() {
+ return isChecked;
+ }
+
+ public void toggleChecked() {
+ this.isChecked = !this.isChecked;
+ }
+
+ public void recycle() {
+ if (bitmap != null && !bitmap.isRecycled()) {
+ bitmap.recycle();
+ }
+ }
+ }
+}
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/BehaviourSettingsController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/BehaviourSettingsController.java
index 5dfb4b3c..dabaf0ec 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/controller/BehaviourSettingsController.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/BehaviourSettingsController.java
@@ -39,6 +39,7 @@ import static org.floens.chan.Chan.injector;
public class BehaviourSettingsController extends SettingsController {
private SettingView forceEnglishSetting;
+ private SettingView useNewCaptchaWindow;
public BehaviourSettingsController(Context context) {
super(context);
@@ -47,14 +48,10 @@ public class BehaviourSettingsController extends SettingsController {
@Override
public void onCreate() {
super.onCreate();
-
navigation.setTitle(R.string.settings_screen_behavior);
setupLayout();
-
- populatePreferences();
-
- buildPreferences();
+ rebuildPreferences();
}
@Override
@@ -66,7 +63,17 @@ public class BehaviourSettingsController extends SettingsController {
}
}
+ private void rebuildPreferences() {
+ populatePreferences();
+ buildPreferences();
+ }
+
private void populatePreferences() {
+ requiresUiRefresh.clear();
+ groups.clear();
+ requiresRestart.clear();
+
+
// General group
{
SettingsGroup general = new SettingsGroup(R.string.settings_group_general);
@@ -152,6 +159,18 @@ public class BehaviourSettingsController extends SettingsController {
groups.add(post);
}
+ // Captcha group
+ {
+ SettingsGroup captcha = new SettingsGroup(R.string.settings_captcha_group);
+
+ useNewCaptchaWindow = captcha.add(new BooleanSettingView(this,
+ ChanSettings.useNewCaptchaWindow,
+ R.string.settings_use_new_captcha_window,
+ 0));
+
+ groups.add(captcha);
+ }
+
// Proxy group
{
SettingsGroup proxy = new SettingsGroup(R.string.settings_group_proxy);
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java
index a203fcd8..9ff48145 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java
@@ -49,9 +49,10 @@ import org.floens.chan.ui.activity.StartActivity;
import org.floens.chan.ui.captcha.AuthenticationLayoutCallback;
import org.floens.chan.ui.captcha.AuthenticationLayoutInterface;
import org.floens.chan.ui.captcha.CaptchaLayout;
-import org.floens.chan.ui.captcha.CaptchaNojsLayout;
import org.floens.chan.ui.captcha.GenericWebViewAuthenticationLayout;
import org.floens.chan.ui.captcha.LegacyCaptchaLayout;
+import org.floens.chan.ui.captcha.v1.CaptchaNojsLayoutV1;
+import org.floens.chan.ui.captcha.v2.CaptchaNoJsLayoutV2;
import org.floens.chan.ui.drawable.DropdownArrowDrawable;
import org.floens.chan.ui.helper.ImagePickDelegate;
import org.floens.chan.ui.view.LoadView;
@@ -258,8 +259,10 @@ public class ReplyLayout extends LoadView implements
}
@Override
- public void initializeAuthentication(Site site, SiteAuthentication authentication,
- AuthenticationLayoutCallback callback) {
+ public void initializeAuthentication(Site site,
+ SiteAuthentication authentication,
+ AuthenticationLayoutCallback callback,
+ boolean useV2NoJsCaptcha) {
if (authenticationLayout == null) {
switch (authentication.type) {
case CAPTCHA1: {
@@ -273,7 +276,25 @@ public class ReplyLayout extends LoadView implements
break;
}
case CAPTCHA2_NOJS:
- authenticationLayout = new CaptchaNojsLayout(getContext());
+ if (useV2NoJsCaptcha) {
+ // new captcha window without webview
+ authenticationLayout = new CaptchaNoJsLayoutV2(getContext());
+ } else {
+ // default webview-based captcha view
+ authenticationLayout = new CaptchaNojsLayoutV1(getContext());
+ }
+
+ ImageView resetButton = captchaContainer.findViewById(R.id.reset);
+ if (resetButton != null) {
+ if (useV2NoJsCaptcha) {
+ // we don't need the default reset button because we have our own
+ resetButton.setVisibility(View.GONE);
+ } else {
+ // restore the button's visibility when using old v1 captcha view
+ resetButton.setVisibility(View.VISIBLE);
+ }
+ }
+
break;
case GENERIC_WEBVIEW: {
GenericWebViewAuthenticationLayout view = new GenericWebViewAuthenticationLayout(getContext());
@@ -338,6 +359,17 @@ public class ReplyLayout extends LoadView implements
authenticationLayout.reset();
}
+ @Override
+ public void destroyCurrentAuthentication() {
+ if (authenticationLayout == null) {
+ return;
+ }
+
+ authenticationLayout.onDestroy();
+ captchaContainer.removeView((CaptchaNoJsLayoutV2) authenticationLayout);
+ authenticationLayout = null;
+ }
+
@Override
public void loadDraftIntoViews(Reply draft) {
name.setText(draft.name);
@@ -452,6 +484,12 @@ public class ReplyLayout extends LoadView implements
comment.post(() -> AndroidUtils.requestViewAndKeyboardFocus(comment));
}
+ @Override
+ public void onFallbackToV1CaptchaView() {
+ // fallback to v1 captcha window
+ presenter.switchPage(ReplyPresenter.Page.AUTHENTICATION, true, false);
+ }
+
@Override
public void openPreview(boolean show, File previewFile) {
if (show) {
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/settings/SettingsController.java b/Clover/app/src/main/java/org/floens/chan/ui/settings/SettingsController.java
index 63f7a3a8..0c80b5ba 100644
--- a/Clover/app/src/main/java/org/floens/chan/ui/settings/SettingsController.java
+++ b/Clover/app/src/main/java/org/floens/chan/ui/settings/SettingsController.java
@@ -140,6 +140,8 @@ public class SettingsController extends Controller implements AndroidUtils.OnMea
protected void buildPreferences() {
LayoutInflater inf = LayoutInflater.from(context);
boolean firstGroup = true;
+ content.removeAllViews();
+
for (SettingsGroup group : groups) {
LinearLayout groupLayout = (LinearLayout) inf.inflate(R.layout.setting_group, content, false);
((TextView) groupLayout.findViewById(R.id.header)).setText(group.name);
diff --git a/Clover/app/src/main/java/org/floens/chan/ui/view/WrappingGridView.java b/Clover/app/src/main/java/org/floens/chan/ui/view/WrappingGridView.java
new file mode 100644
index 00000000..7e681c07
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/view/WrappingGridView.java
@@ -0,0 +1,50 @@
+/*
+ * Clover - 4chan browser https://github.com/Floens/Clover/
+ * Copyright (C) 2014 Floens
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.floens.chan.ui.view;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.GridView;
+
+public class WrappingGridView extends GridView {
+
+ public WrappingGridView(Context context) {
+ super(context);
+ }
+
+ public WrappingGridView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public WrappingGridView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int heightSpec = heightMeasureSpec;
+ if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
+ heightSpec = View.MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, View.MeasureSpec.AT_MOST);
+ }
+
+ super.onMeasure(widthMeasureSpec, heightSpec);
+ }
+
+}
\ No newline at end of file
diff --git a/Clover/app/src/main/java/org/floens/chan/utils/BackgroundUtils.java b/Clover/app/src/main/java/org/floens/chan/utils/BackgroundUtils.java
index 6062d117..db57cc5b 100644
--- a/Clover/app/src/main/java/org/floens/chan/utils/BackgroundUtils.java
+++ b/Clover/app/src/main/java/org/floens/chan/utils/BackgroundUtils.java
@@ -17,11 +17,18 @@
*/
package org.floens.chan.utils;
+import android.os.Looper;
+
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
public class BackgroundUtils {
+
+ public static boolean isMainThread() {
+ return Thread.currentThread() == Looper.getMainLooper().getThread();
+ }
+
public static Cancelable runWithExecutor(Executor executor, final Callable background,
final BackgroundResult result) {
final AtomicBoolean cancelled = new AtomicBoolean(false);
diff --git a/Clover/app/src/main/res/drawable/ic_blue_checkmark_24dp.xml b/Clover/app/src/main/res/drawable/ic_blue_checkmark_24dp.xml
new file mode 100644
index 00000000..0ade221a
--- /dev/null
+++ b/Clover/app/src/main/res/drawable/ic_blue_checkmark_24dp.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/Clover/app/src/main/res/layout/layout_captcha_challenge_image.xml b/Clover/app/src/main/res/layout/layout_captcha_challenge_image.xml
new file mode 100644
index 00000000..760bfb3d
--- /dev/null
+++ b/Clover/app/src/main/res/layout/layout_captcha_challenge_image.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Clover/app/src/main/res/layout/layout_captcha_nojs_v2.xml b/Clover/app/src/main/res/layout/layout_captcha_nojs_v2.xml
new file mode 100644
index 00000000..c42d5fb8
--- /dev/null
+++ b/Clover/app/src/main/res/layout/layout_captcha_nojs_v2.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Clover/app/src/main/res/values/strings.xml b/Clover/app/src/main/res/values/strings.xml
index 00123752..af0ff99c 100644
--- a/Clover/app/src/main/res/values/strings.xml
+++ b/Clover/app/src/main/res/values/strings.xml
@@ -15,7 +15,7 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
-->
-
+
Cancel
Add
Close
@@ -579,4 +579,17 @@ Don't have a 4chan Pass?
"
Themes
+
+
+ Captcha
+ Use new captcha window for no-js captcha
+
+ Verify
+ Reload
+ Use old captcha
+ You have to select at least one image (if you see a captcha that does not have any matching images then it is probably a bug fill an issue and use old captcha for a while)
+ Verification is already in progress
+ Captcha request is already in progress
+ You are requesting captcha too fast
+ Refresh cookies
diff --git a/Clover/app/src/test/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParserTest.java b/Clover/app/src/test/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParserTest.java
new file mode 100644
index 00000000..62faa58b
--- /dev/null
+++ b/Clover/app/src/test/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParserTest.java
@@ -0,0 +1,106 @@
+package org.floens.chan.ui.captcha.v2;
+
+import android.content.Context;
+
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import okhttp3.OkHttpClient;
+
+import static org.junit.Assert.assertEquals;
+
+
+public class CaptchaNoJsHtmlParserTest {
+ private List inputDataList = new ArrayList<>();
+ private List> resultCheckboxList = new ArrayList<>();
+ private List resultTitleList = new ArrayList<>();
+ private List resultCParameterList = new ArrayList<>();
+
+ private Context contextMock = Mockito.mock(Context.class);
+ private OkHttpClient okHttpClient = Mockito.mock(OkHttpClient.class);
+ private CaptchaNoJsHtmlParser parser = new CaptchaNoJsHtmlParser(contextMock, okHttpClient);
+
+ @Before
+ public void setUp() {
+ inputDataList.add("reCAPTCHA challenge");
+ inputDataList.add("reCAPTCHA challenge");
+ inputDataList.add("reCAPTCHA challenge");
+ inputDataList.add("reCAPTCHA challenge");
+ inputDataList.add("reCAPTCHA challenge");
+ inputDataList.add("reCAPTCHA challenge");
+ inputDataList.add("reCAPTCHA challenge");
+
+ for (int i = 0; i < inputDataList.size(); ++i) {
+ resultCheckboxList.add(new ArrayList<>());
+
+ resultCheckboxList.get(i).add(0);
+ resultCheckboxList.get(i).add(1);
+ resultCheckboxList.get(i).add(2);
+ resultCheckboxList.get(i).add(3);
+ resultCheckboxList.get(i).add(4);
+ resultCheckboxList.get(i).add(5);
+ resultCheckboxList.get(i).add(6);
+ resultCheckboxList.get(i).add(7);
+ resultCheckboxList.get(i).add(8);
+ }
+
+ resultTitleList.add("Select all images with cars");
+ resultTitleList.add("Select all images with mountains or hills");
+ resultTitleList.add("Select all images with crosswalks");
+ resultTitleList.add("Select all images with bridges");
+ resultTitleList.add("Select all images with bicycles");
+ resultTitleList.add("Select all images with cars");
+ resultTitleList.add("Select all images with a bus");
+
+ resultCParameterList.add("03AOLTBLSABiot1uRaxXzd4_PRTTGbxW4AV8NOI947nBNVIBwdfOC2DwB0gdrPnfe8YYLI3kkW_tzwTJbQwZk0_nKaIsfXLaGWnnrEhvRMstWDzRin27w_Yalo6OS8Yr5sIqYBUfI496UtsoDGSL8NVQAbxjXocz_E7DTNXVBMfdLPx093-9vUHKH4yt4Mo6HuKSXmBPC2KNL4j_yl78YQ_VG1mprDwUkQeW-MQZxBOQb4MyhWZMOmNp_aRgHKGpJYHpcVMljDUvwZRk_xLw-NoJAWsgCIAt6pfrUE9jvHxjE4fep8OCH2T-6g-_Lla_UovuyEVIfUi1MQa6KWQMypsavA2mVUjTvIe_NkkO8fJ6gteHqUc7a9FTIHHs1OR5uV7fD0SCn9G5ii");
+ resultCParameterList.add("03AOLTBLRv_ZKMPWAc0bBYTnC9wPIQElGB6H6SrifMnrj1yDeFnUPNpOPgGZHdaGPVzfieFHzQOh4GIKWUBPQ_aMs3gp0EJmgdP-LAGFBZIreVB-boqP1DyIblN-3ws1bxCyVvw4kSanc08rPVr7G2nwy7b0kkBV82x8ypU1QP1fQR1HxC8qUddQZSLvgmkFiRatbaOqMdsMPDwl1ENJVrzTkX0sQGmfqnf2aisqRbSsiH0BrW1_wTLPjiGv1SW-FrBYWKJZBWP_FanbTftUuhKLaYTjABDD2mcBg-7-QEpFbaFqi7cwSA4Y-SsCiJg7y0DBL8PjsDSxOqoY_Upc5I-0GI3X21KJLJMLxq9tRkWZbsyhHXNyFzzM_4PfOIiGErbTMzICa596q7");
+ resultCParameterList.add("03AOLTBLQdQO0rHKgYInXnNyPho7cEllKtboAokZsqQPHyE5VkAEXwvkvin540F_CAPIA872WSFqdlShtJvERZPgOP_DmuO4lAF7CxIRDeRzyMd2kKF-ivS3XADiISevh50q1qtolKOW6nhdktgo4JxW3nej539yRZxJ4X6SRde_29QsGgqtTUUB82CYWZ2vOa9yclvuMMLPTFfIIRQHy4DNQXqVOEKV0PfvBatjLhq4ekuYgEoHbni97ePTmjvfsL0gy6CR2SAmOf6m7oJm2snVyBBgxWfMnPue6wypmHco7JRLcEsRHOvUDBLa6rMT5p7fiRP3sxPQNcv_H6prK_EAtSPOB29SELUlf7G72UlcxBNOaKxsOY_xzyFHnyFPRTERh2xc7FEmDF");
+ resultCParameterList.add("03AOLTBLTkdQd0zGmSjFw9OCiKz18VT-h4lleJaBwtNMSDPFsUSR_9qepA1uCUW_FNAju-tgYzTOTjmjf0wWmvfLlwKCxEsyMKfYobnJXXYcOHyAwaOJRtEOAwn2SSmVEzlE1DYZ89lFH7XB5rw6tGpSJRJQkvitoVK-aLg_gzmYxbYJPH_VK9ofkfeGfhnZ6bsXoz1vnnhHYOXm7apmKjDEk6NIxKqB_FHDFclemX8rr7liJBaTcJ98I20a9wXGinBUEEyupoQ5gdnoYqLMu_XIQaRYtdmRsCgtm9RorpkJZxaqEpqfusfpFZUntIXkXF31dmx0hL-B5DpA4uFOBI6UmR2oPLAA2LhStMgKtlnl1IYwyrmaeYUybV0rPl0F5jMAr3GZxEa7Ld");
+ resultCParameterList.add("03AOLTBLTAhnX3O2lESLZYL9y7w8txPisktNhNpI-h3hZpULhz_-yMEId_7-NW8RGCMbZ6_W8CRH6LnL7Icxdw3nISINh3DGcldAmF8jojjG17gPIVvCCk1WdKfgYSkC_oAThQDESoAOz-VGAahHroLUNSnCm1tHmExy5tRX3EGi0xO3ep-1iMSGuMb_aoo3hxIuEZE4_J37RGoE--t2yoU4pKDxfbnHNYYfl48KgIb_qBzUt9O-gOF5P3oMM7G5HpMHjq3lraD0Ep19tiq5c_P-JO21V7QiJBB8XiwqgIHsEwGaLsm3J2NdQjLHot1xy8_A8x04ZTqHw-d_LnpBOTz8Jry5ym2K-zfkqkK1VtbJUnayZ-i-e5_ErxE3HTesMtzJ6I-ZBu2h8T");
+ resultCParameterList.add("03AOLTBLSu6KJ0tMzrENsXJM2H5K8GDF_2PZOEYKew4UxN49JDmoXnrlOFLYbETkJfXyG-kXWdmiTIg3XgR8vVfIf8J4oR1DpEyi9HcPaV9kLNeq-0aK3P1Vw62wBm3OOhU_zeP49kiwmy4icv4ro9NQjfBxw1scZgYsj_R41sBDvlLkofoDMrWNzsAjwFAM2pmbRGKf6qThzQ6ijSAjHBAD_oIK4E4m7kgeUZyEvw9-2MN_8AJoGXtI_74LdvbkngnF1qqK5gIKYX05AyaI39M1-TXfT5Gq2ePu8XMKUR709pZPc22Pw9lni0a1I-UyfGMJJ1u0Pvh0HKO6atPREGFcXwvhOQsSHakAGuzHUE8V5ywUqpW3mNFE0xly-GK07BbGMicQZ-7F-9");
+ resultCParameterList.add("03AOLTBLSe4m_kEnKDI7Zg9oYjJ28Y1XKwS7uZ10NlWRoq-2e5tLG3r47ClAG3F_pTLqQC_krxnHkt8UBIV5EfW5goPvQQQN2W8ll3YYiMjWM_ge1_Z9lk_7M5KCriFBFhmPMB1MtMfeXS5LpcekGDSq9J2O20zySzNHXZ7xkvx5vJ327ioNoDEizwInEaLMG5tMqaWthqZE_-DZlpI7n3v35Glt3hNuT_utgSlBBqt7yipMuy-dunICsXT3Oxojeb-zcvPk3SFsWyMcTI-354eV1PfA-7q0mOGIADYuavzciBKjfZEHrsR9su48sHj_AH8RG4b_8yBzmR7WAj7Va-EhZzxDnlGdfBVqUo5wVUyJxlZe6NWkNkBwiaisxhC8As0VJIY0u_ugms");
+ }
+
+ @Test
+ public void testCheckBoxParsing() throws CaptchaNoJsHtmlParser.CaptchaNoJsV2ParsingError {
+ for (int i = 0; i < inputDataList.size(); ++i) {
+ String inputData = inputDataList.get(i);
+ CaptchaInfo captchaInfo = new CaptchaInfo();
+ parser.parseCheckboxes(inputData, captchaInfo);
+
+ for (int j = 0; j < captchaInfo.checkboxes.size(); j++) {
+ Integer checkbox = captchaInfo.checkboxes.get(j);
+ assertEquals(checkbox, resultCheckboxList.get(i).get(j));
+ }
+ }
+ }
+
+ @Test
+ public void testTitleParsing() throws CaptchaNoJsHtmlParser.CaptchaNoJsV2ParsingError {
+ for (int i = 0; i < inputDataList.size(); ++i) {
+ String inputData = inputDataList.get(i);
+ CaptchaInfo captchaInfo = new CaptchaInfo();
+ parser.parseChallengeTitle(inputData, captchaInfo);
+
+ String title = captchaInfo.captchaTitle.getTitle();
+ assertEquals(resultTitleList.get(i), title);
+ }
+ }
+
+ @Test
+ public void testCValueParsing() throws CaptchaNoJsHtmlParser.CaptchaNoJsV2ParsingError {
+ for (int i = 0; i < inputDataList.size(); ++i) {
+ String inputData = inputDataList.get(i);
+ CaptchaInfo captchaInfo = new CaptchaInfo();
+ parser.parseCParameter(inputData, captchaInfo);
+
+ String cParameter = captchaInfo.cParameter;
+ assertEquals(resultCParameterList.get(i), cParameter);
+ }
+ }
+}
\ No newline at end of file