mirror of https://github.com/kurisufriend/Clover
parent
7cbf5cd292
commit
d8aef6e333
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/ |
||||
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<Integer> checkboxes; |
||||
@Nullable |
||||
String cParameter; |
||||
@NonNull |
||||
List<Bitmap> 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<Integer> checkboxes) { |
||||
this.checkboxes = checkboxes; |
||||
} |
||||
|
||||
public void setcParameter(@Nullable String cParameter) { |
||||
this.cParameter = cParameter; |
||||
} |
||||
|
||||
public void setChallengeImages(@NonNull List<Bitmap> challengeImages) { |
||||
this.challengeImages = challengeImages; |
||||
} |
||||
|
||||
public void setCaptchaTitle(@Nullable CaptchaTitle captchaTitle) { |
||||
this.captchaTitle = captchaTitle; |
||||
} |
||||
|
||||
public CaptchaType getCaptchaType() { |
||||
return captchaType; |
||||
} |
||||
|
||||
@NonNull |
||||
public List<Bitmap> getChallengeImages() { |
||||
return challengeImages; |
||||
} |
||||
|
||||
@NonNull |
||||
public List<Integer> 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; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,398 @@ |
||||
/* |
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/ |
||||
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("<input class=\"fbc-imageselect-checkbox-\\d+\" type=\"checkbox\" name=\"response\" value=\"(\\d+)\">"); |
||||
|
||||
// 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("<input type=\"hidden\" name=\"c\" value=\"(.*?)\"/>"); |
||||
private static final Pattern challengeTitlePattern = |
||||
Pattern.compile("<div class=\"(rc-imageselect-desc-no-canonical|rc-imageselect-desc)\">(.*?)</div>"); |
||||
private static final Pattern challengeImageUrlPattern = |
||||
Pattern.compile("<img class=\"fbc-imageselect-payload\" src=\"(.*?)&"); |
||||
private static final Pattern challengeTitleBoldPartPattern = |
||||
Pattern.compile("<strong>(.*?)</strong>"); |
||||
private static final Pattern verificationTokenPattern = |
||||
Pattern.compile("<div class=\"fbc-verification-token\"><textarea dir=\"ltr\" readonly>(.*?)</textarea></div>"); |
||||
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; |
||||
} |
||||
|
||||
private 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("<strong>"); |
||||
|
||||
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<Integer, Integer> 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<Bitmap> challengeImages = decodeImagesFromFile(challengeImageFile, columns, rows); |
||||
captchaInfo.setChallengeImages(challengeImages); |
||||
} |
||||
|
||||
@NonNull |
||||
private Pair<Integer, Integer> 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"); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private 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); |
||||
} |
||||
|
||||
private void parseCheckboxes( |
||||
String responseHtml, |
||||
CaptchaInfo captchaInfo |
||||
) throws CaptchaNoJsV2ParsingError { |
||||
Matcher matcher = checkboxesPattern.matcher(responseHtml); |
||||
Set<Integer> 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<Bitmap> decodeImagesFromFile(File challengeImageFile, int columns, int rows) { |
||||
Bitmap originalBitmap = BitmapFactory.decodeFile(challengeImageFile.getAbsolutePath()); |
||||
List<Bitmap> 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; |
||||
} finally { |
||||
if (originalBitmap != null) { |
||||
originalBitmap.recycle(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
public static class CaptchaNoJsV2ParsingError extends Exception { |
||||
public CaptchaNoJsV2ParsingError(String message) { |
||||
super(message); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,274 @@ |
||||
/* |
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/ |
||||
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.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 ConstraintLayout buttonsHolder; |
||||
private ConstraintLayout 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); |
||||
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)); |
||||
|
||||
captchaVerifyButton.setOnClickListener(this); |
||||
useOldCaptchaButton.setOnClickListener(this); |
||||
reloadCaptchaButton.setOnClickListener(this); |
||||
} |
||||
|
||||
@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; |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onCaptchaInfoParsed(CaptchaInfo captchaInfo) { |
||||
// called on a background thread
|
||||
|
||||
AndroidUtils.runOnUiThread(() -> { |
||||
renderCaptchaWindow(captchaInfo); |
||||
}); |
||||
} |
||||
|
||||
@Override |
||||
public void onVerificationDone(String verificationToken) { |
||||
// called on a background thread
|
||||
|
||||
AndroidUtils.runOnUiThread(() -> { |
||||
callback.onAuthenticationComplete(this, null, verificationToken); |
||||
}); |
||||
} |
||||
|
||||
@Override |
||||
public void onCaptchaInfoParseError(Throwable error) { |
||||
// called on a background thread
|
||||
|
||||
AndroidUtils.runOnUiThread(() -> { |
||||
Logger.e(TAG, "CaptchaV2 error", error); |
||||
|
||||
String message = error.getMessage(); |
||||
showToast(message); |
||||
|
||||
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 = 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); |
||||
} 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<Integer> selectedIds = adapter.getCheckedImageIds(); |
||||
|
||||
try { |
||||
|
||||
CaptchaNoJsPresenterV2.VerifyError verifyError = presenter.verify(selectedIds); |
||||
switch (verifyError) { |
||||
case Ok: |
||||
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; |
||||
} |
||||
} catch (Throwable error) { |
||||
onCaptchaInfoParseError(error); |
||||
} |
||||
} |
||||
|
||||
public void onDestroy() { |
||||
adapter.onDestroy(); |
||||
presenter.onDestroy(); |
||||
} |
||||
} |
@ -0,0 +1,347 @@ |
||||
/* |
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/ |
||||
package org.floens.chan.ui.captcha.v2; |
||||
|
||||
import android.content.Context; |
||||
import android.support.annotation.Nullable; |
||||
|
||||
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.Call; |
||||
import okhttp3.Callback; |
||||
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"; |
||||
// TODO: change useragent?
|
||||
private static final String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36"; |
||||
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 should be updated once in 3 months IIRC
|
||||
private static final String googleCookies = |
||||
"SID=NwcOCOnxA7PVDPUY_InSzz6saQ93BmWzg7N2276OPwUvtwXkLpfqbs1tYNkxNP0xC_Awmg.; " + |
||||
"HSID=AoZX0-3xQ7lN9EMEm; " + |
||||
"SSID=AWwVsHei8i4VlX3H4; " + |
||||
"NID=182=ikM1ZwJ0tMSqCiJc8gdretZpOHCnUSsZ2oM7I681KmnkL0DKFOBmFU6zlmTMC_mDnPhBB5mQxsaB0ipTICW9WyCa9nLUbo1Bx-H9jFmOLNhr1E5EezqlyFZcBKEyZgjFYmaY79_5jHMa4uE6v_Vb8HR1Uk9CXbNfw_yIPEsZXZ0t0CURkV-CdxdOIiZ4BAy0oE6w60GJ9kLpOWWSeNZ_lBcn0PkWRj6vmRH6kKrl2exOKnk"; |
||||
|
||||
// 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 lastTimeCaptchRequest = 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 |
||||
*/ |
||||
public VerifyError verify( |
||||
List<Integer> selectedIds |
||||
) throws CaptchaNoJsV2Error, UnsupportedEncodingException { |
||||
if (!verificationInProgress.compareAndSet(false, true)) { |
||||
return VerifyError.AlreadyInProgress; |
||||
} |
||||
|
||||
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"); |
||||
} |
||||
|
||||
String recaptchaUrl = recaptchaUrlBase + siteKey; |
||||
RequestBody body = createResponseBody(prevCaptchaInfo, selectedIds); |
||||
|
||||
Request request = new Request.Builder() |
||||
.url(recaptchaUrl) |
||||
.post(body) |
||||
.header("User-Agent", userAgent) |
||||
.header("Referer", recaptchaUrl) |
||||
.header("Accept-Language", "en-US") |
||||
.header("Cookie", googleCookies) |
||||
.build(); |
||||
|
||||
okHttpClient.newCall(request).enqueue(new Callback() { |
||||
@Override |
||||
public void onFailure(Call call, IOException e) { |
||||
if (callbacks != null) { |
||||
try { |
||||
callbacks.onCaptchaInfoParseError(e); |
||||
} finally { |
||||
verificationInProgress.set(false); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onResponse(Call call, Response response) { |
||||
executor.execute(() -> { |
||||
// to avoid okhttp's threads to hang
|
||||
|
||||
try { |
||||
prevCaptchaInfo = handleGetRecaptchaResponse(response); |
||||
} finally { |
||||
verificationInProgress.set(false); |
||||
response.close(); |
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
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)) { |
||||
return RequestCaptchaInfoError.AlreadyInProgress; |
||||
} |
||||
|
||||
try { |
||||
// recaptcha may become very angry at you if your are fetching it too fast
|
||||
if (System.currentTimeMillis() - lastTimeCaptchRequest < CAPTCHA_REQUEST_THROTTLE_MS) { |
||||
captchaRequestInProgress.set(false); |
||||
return RequestCaptchaInfoError.HoldYourHorses; |
||||
} |
||||
|
||||
lastTimeCaptchRequest = System.currentTimeMillis(); |
||||
String recaptchaUrl = recaptchaUrlBase + siteKey; |
||||
|
||||
Request request = new Request.Builder() |
||||
.url(recaptchaUrl) |
||||
.header("User-Agent", userAgent) |
||||
.header("Referer", baseUrl) |
||||
.header("Accept-Language", "en-US") |
||||
.header("Cookie", googleCookies) |
||||
.build(); |
||||
|
||||
okHttpClient.newCall(request).enqueue(new Callback() { |
||||
@Override |
||||
public void onFailure(Call call, IOException e) { |
||||
if (callbacks != null) { |
||||
try { |
||||
callbacks.onCaptchaInfoParseError(e); |
||||
} finally { |
||||
captchaRequestInProgress.set(false); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onResponse(Call call, Response response) { |
||||
executor.execute(() -> { |
||||
// to avoid okhttp's threads to hang
|
||||
|
||||
try { |
||||
prevCaptchaInfo = handleGetRecaptchaResponse(response); |
||||
} finally { |
||||
captchaRequestInProgress.set(false); |
||||
response.close(); |
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
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; |
||||
} |
||||
} |
||||
|
||||
private RequestBody createResponseBody( |
||||
CaptchaInfo prevCaptchaInfo, |
||||
List<Integer> 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); |
||||
|
||||
if (callbacks != null) { |
||||
callbacks.onVerificationDone(verificationToken); |
||||
} |
||||
|
||||
return null; |
||||
} else { |
||||
// got the challenge
|
||||
CaptchaInfo captchaInfo = parser.parseHtml(bodyString, siteKey); |
||||
|
||||
if (callbacks != null) { |
||||
callbacks.onCaptchaInfoParsed(captchaInfo); |
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
public enum RequestCaptchaInfoError { |
||||
Ok, |
||||
AlreadyInProgress, |
||||
HoldYourHorses |
||||
} |
||||
|
||||
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); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,137 @@ |
||||
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 LayoutInflater inflater; |
||||
private int imageSize = 0; |
||||
|
||||
private List<ImageChallengeInfo> imageList = new ArrayList<>(); |
||||
|
||||
public CaptchaNoJsV2Adapter(Context context) { |
||||
this.inflater = LayoutInflater.from(context); |
||||
} |
||||
|
||||
public void setImages(List<Bitmap> 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); |
||||
AppCompatImageView blueCheckmark = convertView.findViewById(R.id.captcha_challenge_blue_checkmark); |
||||
|
||||
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); |
||||
blueCheckmark.setVisibility(isChecked ? View.VISIBLE : View.GONE); |
||||
}); |
||||
|
||||
if (position >= 0 && position <= imageList.size()) { |
||||
imageView.setImageBitmap(imageList.get(position).getBitmap()); |
||||
} |
||||
} |
||||
|
||||
return convertView; |
||||
} |
||||
|
||||
public List<Integer> getCheckedImageIds() { |
||||
List<Integer> 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(); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,4 @@ |
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" > |
||||
<solid android:color="@android:color/white" /> |
||||
<stroke android:width="1dip" android:color="@color/accent"/> |
||||
</shape> |
@ -0,0 +1,5 @@ |
||||
<vector android:height="24dp" android:tint="#20A8C6" |
||||
android:viewportHeight="24.0" android:viewportWidth="24.0" |
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<path android:fillColor="#FF000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/> |
||||
</vector> |
@ -0,0 +1,31 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent"> |
||||
|
||||
<android.support.v7.widget.AppCompatImageView |
||||
android:id="@+id/captcha_challenge_image" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:scaleType="fitXY" |
||||
android:layout_margin="2dp" |
||||
android:background="@drawable/background_rectangle" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
<android.support.v7.widget.AppCompatImageView |
||||
android:id="@+id/captcha_challenge_blue_checkmark" |
||||
android:layout_width="32dp" |
||||
android:layout_height="32dp" |
||||
android:layout_marginStart="8dp" |
||||
android:layout_marginLeft="8dp" |
||||
android:layout_marginTop="8dp" |
||||
android:visibility="gone" |
||||
android:src="@drawable/ic_blue_checkmark_24dp" |
||||
app:layout_constraintStart_toStartOf="@+id/captcha_challenge_image" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
</android.support.constraint.ConstraintLayout> |
@ -0,0 +1,93 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:id="@+id/captcha_layout_v2_background" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent"> |
||||
|
||||
<LinearLayout |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:orientation="vertical"> |
||||
|
||||
<android.support.v7.widget.AppCompatTextView |
||||
android:id="@+id/captcha_layout_v2_title" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="40dp" |
||||
android:background="@color/accent" |
||||
android:ellipsize="middle" |
||||
android:gravity="center" |
||||
android:maxLines="2" |
||||
android:padding="4dp" |
||||
android:text="" |
||||
android:textColor="#ffffff" |
||||
android:textSize="18sp" |
||||
tools:text="Select all images with something" /> |
||||
|
||||
<org.floens.chan.ui.view.WrappingGridView |
||||
android:id="@+id/captcha_layout_v2_images_grid" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:gravity="center" |
||||
android:stretchMode="columnWidth" /> |
||||
|
||||
<android.support.constraint.ConstraintLayout |
||||
android:id="@+id/captcha_layout_v2_buttons_holder" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="64dp" |
||||
android:background="@color/accent"> |
||||
|
||||
<android.support.v7.widget.AppCompatButton |
||||
android:id="@+id/captcha_layout_v2_use_old_captcha_button" |
||||
android:layout_width="128dp" |
||||
android:layout_height="wrap_content" |
||||
android:background="?android:attr/selectableItemBackground" |
||||
android:clickable="true" |
||||
android:focusable="true" |
||||
android:paddingStart="16dp" |
||||
android:paddingLeft="16dp" |
||||
android:paddingEnd="16dp" |
||||
android:paddingRight="16dp" |
||||
android:text="@string/captcha_layout_v2_use_old_captcha" |
||||
android:textColor="#ffffff" |
||||
app:layout_constraintBottom_toBottomOf="@+id/captcha_layout_v2_reload_button" |
||||
app:layout_constraintEnd_toStartOf="@+id/captcha_layout_v2_reload_button" |
||||
app:layout_constraintHorizontal_bias="0.5" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="@+id/captcha_layout_v2_reload_button" /> |
||||
|
||||
<android.support.v7.widget.AppCompatButton |
||||
android:id="@+id/captcha_layout_v2_reload_button" |
||||
android:layout_width="96dp" |
||||
android:layout_height="wrap_content" |
||||
android:background="?android:attr/selectableItemBackground" |
||||
android:clickable="true" |
||||
android:focusable="true" |
||||
android:text="@string/captcha_layout_v2_reload" |
||||
android:textColor="#ffffff" |
||||
app:layout_constraintBottom_toBottomOf="@+id/captcha_layout_v2_verify_button" |
||||
app:layout_constraintEnd_toStartOf="@+id/captcha_layout_v2_verify_button" |
||||
app:layout_constraintHorizontal_bias="0.5" |
||||
app:layout_constraintStart_toEndOf="@+id/captcha_layout_v2_use_old_captcha_button" |
||||
app:layout_constraintTop_toTopOf="@+id/captcha_layout_v2_verify_button" /> |
||||
|
||||
<android.support.v7.widget.AppCompatButton |
||||
android:id="@+id/captcha_layout_v2_verify_button" |
||||
android:layout_width="96dp" |
||||
android:layout_height="wrap_content" |
||||
android:background="?android:attr/selectableItemBackground" |
||||
android:clickable="true" |
||||
android:focusable="true" |
||||
android:text="@string/captcha_layout_v2_verify_button_text" |
||||
android:textColor="#ffffff" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintStart_toEndOf="@+id/captcha_layout_v2_reload_button" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
</android.support.constraint.ConstraintLayout> |
||||
|
||||
</LinearLayout> |
||||
|
||||
</android.support.constraint.ConstraintLayout> |
Loading…
Reference in new issue