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
reCAPTCHA
\"reCAPTCHA
"); + inputDataList.add("reCAPTCHA challenge
reCAPTCHA
\"reCAPTCHA
"); + inputDataList.add("reCAPTCHA challenge
reCAPTCHA
\"reCAPTCHA
"); + inputDataList.add("reCAPTCHA challenge
reCAPTCHA
\"reCAPTCHA
"); + inputDataList.add("reCAPTCHA challenge
reCAPTCHA
\"reCAPTCHA
"); + inputDataList.add("reCAPTCHA challenge
reCAPTCHA
\"reCAPTCHA
"); + inputDataList.add("reCAPTCHA challenge
reCAPTCHA
\"reCAPTCHA
"); + + 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