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..0f6532ac 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
@@ -37,6 +37,7 @@ import org.floens.chan.core.site.http.Reply;
import org.floens.chan.core.site.http.ReplyResponse;
import org.floens.chan.ui.captcha.AuthenticationLayoutCallback;
import org.floens.chan.ui.captcha.AuthenticationLayoutInterface;
+import org.floens.chan.ui.captcha.v2.CaptchaNoJsLayoutV2;
import org.floens.chan.ui.helper.ImagePickDelegate;
import org.floens.chan.utils.Logger;
@@ -279,10 +280,22 @@ 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 instanceof CaptchaNoJsLayoutV2)) {
+ // should this be called here?
+ 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 +402,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 +410,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 +427,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 +490,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 +540,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..cca835ad 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,10 @@ public class ChanSettings {
public static final LongSetting updateCheckInterval;
public static final BooleanSetting crashReporting;
+ public static final BooleanSetting useNewCaptchaWindow;
+ public static final BooleanSetting useRealGoogleCookies;
+ public static final StringSetting googleCookie;
+ public static final LongSetting lastGoogleCookieUpdateTime;
static {
SettingProvider p = new SharedPreferencesSettingProvider(AndroidUtils.getPreferences());
@@ -250,6 +254,10 @@ 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);
+ useRealGoogleCookies = new BooleanSetting(p, "use_real_google_cookies", false);
+ googleCookie = new StringSetting(p, "google_cookie", "");
+ lastGoogleCookieUpdateTime = new LongSetting(p, "last_google_cookie_update_time", 0L);
// 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/CaptchaNojsLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v1/CaptchaNojsLayoutV1.java
similarity index 89%
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..819e51e9 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);
@@ -172,9 +174,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..bd063398
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParser.java
@@ -0,0 +1,407 @@
+/*
+ * 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 org.floens.chan.utils.Logger;
+
+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 (Throwable error) {
+ Logger.e(TAG, "Could not parse verification token", error);
+ throw 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 (Throwable error) {
+ Logger.e(TAG, "Error while trying to parse challenge title", error);
+ throw error;
+ }
+
+ if (captchaTitle == null) {
+ throw new CaptchaNoJsV2ParsingError("challengeTitle is null");
+ }
+
+ 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 (Throwable error) {
+ Logger.e(TAG, "Error while trying to parse challenge image url", error);
+ throw 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 (Throwable error) {
+ Logger.e(TAG, "Error while trying to parse c parameter", error);
+ throw 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 (Throwable error) {
+ Logger.e(TAG, "Error while trying to parse checkbox with id (" + index + ")", error);
+ throw error;
+ }
+
+ ++index;
+ }
+
+ if (checkboxesSet.isEmpty()) {
+ throw new CaptchaNoJsV2ParsingError("Could not parse any checkboxes!");
+ }
+
+ CaptchaInfo.CaptchaType captchaType;
+
+ try {
+ captchaType = CaptchaInfo.CaptchaType.fromCheckboxesCount(checkboxesSet.size());
+ } catch (Throwable error) {
+ Logger.e(TAG, "Error while trying to parse captcha type", error);
+ throw 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,
+ column * imageWidth,
+ row * imageHeight,
+ imageWidth,
+ imageHeight);
+
+ resultImages.add(imagePiece);
+ }
+ }
+
+ return resultImages;
+ } catch (Throwable 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);
+ }
+ }
+}
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..68e2af69
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsLayoutV2.java
@@ -0,0 +1,327 @@
+/*
+ * 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.settings.ChanSettings;
+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 AppCompatButton refreshCookiesButton;
+ private ConstraintLayout buttonsHolder;
+ private ScrollView background;
+
+ 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);
+ refreshCookiesButton = view.findViewById(R.id.captcha_layout_v2_refresh_cookies);
+ buttonsHolder = view.findViewById(R.id.captcha_layout_v2_buttons_holder);
+ 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));
+ refreshCookiesButton.setTextColor(AndroidUtils.getAttrColor(getContext(), R.attr.text_color_primary));
+
+ captchaVerifyButton.setOnClickListener(this);
+ useOldCaptchaButton.setOnClickListener(this);
+ reloadCaptchaButton.setOnClickListener(this);
+
+ if (ChanSettings.useRealGoogleCookies.get()) {
+ refreshCookiesButton.setVisibility(View.VISIBLE);
+ refreshCookiesButton.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 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);
+ });
+ }
+
+ @Override
+ public void onGoogleCookiesRefreshed() {
+ // called on a background thread
+
+ AndroidUtils.runOnUiThread(() -> {
+ showToast("Google cookies successfully refreshed");
+
+ // refresh the captcha as well
+ reset();
+ });
+ }
+
+ // Called when we could not get google cookies
+ @Override
+ public void onGetGoogleCookieError(boolean shouldFallback, Throwable error) {
+ // called on a background thread
+
+ handleError(shouldFallback, error);
+ }
+
+ // 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();
+ } else if (v == refreshCookiesButton) {
+ presenter.refreshCookies();
+ }
+ }
+
+ private void renderCaptchaWindow(CaptchaInfo captchaInfo) {
+ try {
+ setCaptchaTitle(captchaInfo);
+
+ captchaImagesGrid.setAdapter(null);
+ captchaImagesGrid.setAdapter(adapter);
+
+ int columnsCount = 0;
+ 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) {
+ if (callback != null) {
+ callback.onFallbackToV1CaptchaView();
+ }
+ }
+ }
+
+ private void setCaptchaTitle(CaptchaInfo captchaInfo) {
+ 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);
+ }
+ }
+
+ public void onDestroy() {
+ adapter.onDestroy();
+ presenter.onDestroy();
+ }
+}
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..256ca2c1
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsPresenterV2.java
@@ -0,0 +1,511 @@
+/*
+ * 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.NonNull;
+import android.support.annotation.Nullable;
+
+import org.floens.chan.core.settings.ChanSettings;
+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.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import okhttp3.Headers;
+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 googleBaseUrl = "https://www.google.com/";
+ 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 String setCookieHeaderName = "set-cookie";
+ private static final int SUCCESS_STATUS_CODE = 200;
+ private static final long CAPTCHA_REQUEST_THROTTLE_MS = 3000L;
+ private static final long THREE_MONTHS = TimeUnit.DAYS.toMillis(90);
+
+ // 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;
+ @NonNull
+ // either the default cookie or a real cookie
+ private volatile String googleCookie;
+
+ private AtomicBoolean verificationInProgress = new AtomicBoolean(false);
+ private AtomicBoolean captchaRequestInProgress = new AtomicBoolean(false);
+ private AtomicBoolean refreshCookiesRequestInProgress = 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);
+
+ this.googleCookie = ChanSettings.googleCookie.get();
+ }
+
+ 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");
+ }
+
+ if (googleCookie.isEmpty()) {
+ throw new IllegalStateException("Google cookies are not supposed to be empty here");
+ }
+
+ executor.submit(() -> {
+ try {
+ String recaptchaUrl = recaptchaUrlBase + siteKey;
+ RequestBody body = createResponseBody(prevCaptchaInfo, selectedIds);
+
+ Logger.d(TAG, "Verify called. Current cookie = " + googleCookie);
+
+ 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", googleCookie)
+ .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;
+ }
+ }
+
+ /**
+ * Manually refreshes the google cookie
+ * */
+ public void refreshCookies() {
+ if (!refreshCookiesRequestInProgress.compareAndSet(false, true)) {
+ Logger.d(TAG, "Google cookie request is already in progress");
+ return;
+ }
+
+ if (executor.isShutdown()) {
+ refreshCookiesRequestInProgress.set(false);
+ Logger.d(TAG, "Cannot request google cookie, executor has been shut down");
+ return;
+ }
+
+ executor.submit(() -> {
+ try {
+ googleCookie = getGoogleCookies(true);
+
+ if (callbacks != null) {
+ callbacks.onGoogleCookiesRefreshed();
+ }
+ } catch (IOException e) {
+ if (callbacks != null) {
+ callbacks.onGetGoogleCookieError(false, e);
+ }
+ } finally {
+ refreshCookiesRequestInProgress.set(false);
+ }
+ });
+ }
+
+ /**
+ * 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 {
+ googleCookie = getGoogleCookies(false);
+ } catch (Throwable error) {
+ if (callbacks != null) {
+ callbacks.onGetGoogleCookieError(true, error);
+ }
+
+ throw error;
+ }
+
+ 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;
+ googleCookie = defaultGoogleCookies;
+ } 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;
+ }
+ }
+
+ @NonNull
+ private String getGoogleCookies(boolean forced) throws IOException {
+ if (BackgroundUtils.isMainThread()) {
+ throw new RuntimeException("Must not be executed on the main thread");
+ }
+
+ if (!ChanSettings.useRealGoogleCookies.get()) {
+ Logger.d(TAG, "Google cookies request is disabled in the settings, using the default ones");
+ return defaultGoogleCookies;
+ }
+
+ boolean isItTimeToUpdateCookies =
+ ((System.currentTimeMillis() - ChanSettings.lastGoogleCookieUpdateTime.get()) > THREE_MONTHS);
+
+ if (!forced && (!googleCookie.isEmpty() && !isItTimeToUpdateCookies)) {
+ Logger.d(TAG, "We already have google cookies");
+ return googleCookie;
+ }
+
+ Logger.d(TAG, "Time to update cookies: forced = " + forced + ", isCookieEmpty = " +
+ googleCookie.isEmpty() + ", last cookie expired = " + isItTimeToUpdateCookies);
+
+ Request request = new Request.Builder()
+ .url(googleBaseUrl)
+ .header("User-Agent", userAgentHeader)
+ .header("Accept", acceptHeader)
+ .header("Accept-Encoding", acceptEncodingHeader)
+ .header("Accept-Language", acceptLanguageHeader)
+ .build();
+
+ try (Response response = okHttpClient.newCall(request).execute()) {
+ String newCookie = handleGetGoogleCookiesResponse(response);
+ if (!newCookie.equalsIgnoreCase(defaultGoogleCookies)) {
+ ChanSettings.googleCookie.set(newCookie);
+ ChanSettings.lastGoogleCookieUpdateTime.set(System.currentTimeMillis());
+
+ Logger.d(TAG, "Successfully refreshed google cookies, new cookie = " + newCookie);
+ } else {
+ Logger.d(TAG, "Could not successfully handle google cookie response, " +
+ "using the default google cookies until the next request");
+ }
+
+ return newCookie;
+ }
+ }
+
+ @Nullable
+ private CaptchaInfo getCaptchaInfo() throws IOException {
+ if (BackgroundUtils.isMainThread()) {
+ throw new RuntimeException("Must not be executed on the main thread");
+ }
+
+ if (googleCookie.isEmpty()) {
+ throw new IllegalStateException("Google cookies are not supposed to be null here");
+ }
+
+ 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", googleCookie)
+ .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);
+ }
+
+ @NonNull
+ private String handleGetGoogleCookiesResponse(Response response) {
+ if (response.code() != SUCCESS_STATUS_CODE) {
+ Logger.w(TAG, "Get google cookies request returned bad status code = " + response.code());
+ return defaultGoogleCookies;
+ }
+
+ Headers headers = response.headers();
+
+ for (String headerName : headers.names()) {
+ if (headerName.equalsIgnoreCase(setCookieHeaderName)) {
+ String setCookieHeader = headers.get(headerName);
+ if (setCookieHeader != null) {
+ String[] split = setCookieHeader.split(";");
+ for (String splitPart : split) {
+ if (splitPart.startsWith("NID")) {
+ return splitPart;
+ }
+ }
+ }
+ }
+ }
+
+ Logger.d(TAG, "Could not find the NID cookie in the headers");
+ return defaultGoogleCookies;
+ }
+
+ @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 (Throwable 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 onGetGoogleCookieError(boolean shouldFallback, Throwable error);
+
+ void onGoogleCookiesRefreshed();
+
+ 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..96efb515
--- /dev/null
+++ b/Clover/app/src/main/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsV2Adapter.java
@@ -0,0 +1,139 @@
+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.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import org.floens.chan.R;
+import org.floens.chan.utils.AndroidUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CaptchaNoJsV2Adapter extends BaseAdapter {
+ private static final int ANIMATION_DURATION = 50;
+
+ 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);
+ ConstraintLayout blueCheckmarkHolder = convertView.findViewById(R.id.captcha_challenge_blue_checkmark_holder);
+
+ ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(imageSize, imageSize);
+ imageView.setLayoutParams(layoutParams);
+
+ imageView.setOnClickListener((view) -> {
+ imageList.get(position).toggleChecked();
+ boolean isChecked = imageList.get(position).isChecked;
+
+ AndroidUtils.animateViewScale(imageView, isChecked, ANIMATION_DURATION);
+ blueCheckmarkHolder.setVisibility(isChecked ? View.VISIBLE : View.GONE);
+ });
+
+ 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..e2f83be1 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
@@ -63,10 +60,33 @@ public class BehaviourSettingsController extends SettingsController {
if (item == forceEnglishSetting) {
Toast.makeText(context, R.string.setting_force_english_locale_toggle_notice,
Toast.LENGTH_LONG).show();
+ } else if (item == useNewCaptchaWindow) {
+ // when user disables the new captcha window also disable the usage of the google cookies
+ if (!ChanSettings.useNewCaptchaWindow.get()) {
+ ChanSettings.useRealGoogleCookies.set(false);
+
+ // Reset the old google cookie
+ ChanSettings.googleCookie.set("");
+
+ // and cookie update time as well
+ ChanSettings.lastGoogleCookieUpdateTime.set(0L);
+ }
+
+ rebuildPreferences();
}
}
+ 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 +172,25 @@ 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));
+
+ if (ChanSettings.useNewCaptchaWindow.get()) {
+ captcha.add(new BooleanSettingView(this,
+ ChanSettings.useRealGoogleCookies,
+ R.string.settings_use_real_google_cookies,
+ R.string.settings_use_real_google_cookies_description));
+ }
+
+ 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..3fd4eb67 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,22 @@ public class ReplyLayout extends LoadView implements
authenticationLayout.reset();
}
+ @Override
+ public void destroyCurrentAuthentication() {
+ if (authenticationLayout == null) {
+ return;
+ }
+
+ if (!(authenticationLayout instanceof CaptchaNoJsLayoutV2)) {
+ return;
+ }
+
+ // cleanup resources when switching from the new to the old captcha view
+ ((CaptchaNoJsLayoutV2) authenticationLayout).onDestroy();
+ captchaContainer.removeView((CaptchaNoJsLayoutV2) authenticationLayout);
+ authenticationLayout = null;
+ }
+
@Override
public void loadDraftIntoViews(Reply draft) {
name.setText(draft.name);
@@ -452,6 +489,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/AndroidUtils.java b/Clover/app/src/main/java/org/floens/chan/utils/AndroidUtils.java
index cc8aa1f3..d4e950a0 100644
--- a/Clover/app/src/main/java/org/floens/chan/utils/AndroidUtils.java
+++ b/Clover/app/src/main/java/org/floens/chan/utils/AndroidUtils.java
@@ -51,6 +51,8 @@ import android.view.ViewTreeObserver;
import android.view.inputmethod.InputMethodManager;
import android.widget.TextView;
import android.widget.Toast;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.ScaleAnimation;
import org.floens.chan.R;
@@ -458,6 +460,40 @@ public class AndroidUtils {
return networkInfo != null && networkInfo.isConnected();
}
+ public static void animateViewScale(View view, boolean zoomOut, int duration) {
+ ScaleAnimation scaleAnimation;
+ final float normalScale = 1.0f;
+ final float zoomOutScale = 0.8f;
+
+ if (zoomOut) {
+ scaleAnimation = new ScaleAnimation(
+ normalScale,
+ zoomOutScale,
+ normalScale,
+ zoomOutScale,
+ ScaleAnimation.RELATIVE_TO_SELF,
+ 0.5f,
+ ScaleAnimation.RELATIVE_TO_SELF,
+ 0.5f);
+ } else {
+ scaleAnimation = new ScaleAnimation(
+ zoomOutScale,
+ normalScale,
+ zoomOutScale,
+ normalScale,
+ ScaleAnimation.RELATIVE_TO_SELF,
+ 0.5f,
+ ScaleAnimation.RELATIVE_TO_SELF,
+ 0.5f);
+ }
+
+ scaleAnimation.setDuration(duration);
+ scaleAnimation.setFillAfter(true);
+ scaleAnimation.setInterpolator(new AccelerateDecelerateInterpolator());
+
+ view.startAnimation(scaleAnimation);
+ }
+
public static boolean enableHighEndAnimations() {
boolean lowRamDevice = ActivityManagerCompat.isLowRamDevice(activityManager);
return !lowRamDevice && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M;
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..22da5cb4
--- /dev/null
+++ b/Clover/app/src/main/res/layout/layout_captcha_challenge_image.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..590da38d 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 .
-->
-
+CancelAddClose
@@ -579,4 +579,19 @@ Don't have a 4chan Pass?
"
Themes
+
+
+ Captcha
+ Use new captcha window for no-js captcha
+ Use real google cookies instead of hardcoded ones
+ When this option is enabled a GET request to the google.com will be executed to get the google cookies (NID) which will be used for captcha authentication. Why would you need them? Because the hardcoded ones sometimes will make you re-enter the captcha dozens of times. Those cookies will be updated automatically (every three months). But it will also be possible to update them manually if necessary (for example, when the old ones give too many challenges in a row).
+
+ 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..37a2f32a
--- /dev/null
+++ b/Clover/app/src/test/java/org/floens/chan/ui/captcha/v2/CaptchaNoJsHtmlParserTest.java
@@ -0,0 +1,104 @@
+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