diff --git a/extensions/dvach/res/values-ru/strings.xml b/extensions/dvach/res/values-ru/strings.xml index d5abb9d5..dc8c2dc2 100644 --- a/extensions/dvach/res/values-ru/strings.xml +++ b/extensions/dvach/res/values-ru/strings.xml @@ -2,4 +2,5 @@ Использовать полную клавиатуру для капчи + Выберите все символы на картинке (в любом порядке) diff --git a/extensions/dvach/res/values/strings.xml b/extensions/dvach/res/values/strings.xml index 6729b407..c6f46e6e 100644 --- a/extensions/dvach/res/values/strings.xml +++ b/extensions/dvach/res/values/strings.xml @@ -2,4 +2,5 @@ Use full keyboard for captcha + Select all icons from the picture (any order) diff --git a/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanConfiguration.java b/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanConfiguration.java index 7bcac76f..6b164634 100644 --- a/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanConfiguration.java +++ b/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanConfiguration.java @@ -17,12 +17,14 @@ public class DvachChanConfiguration extends ChanConfiguration { public static final String CAPTCHA_TYPE_2CH_CAPTCHA = "2ch_captcha"; + public static final String CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA = "emoji_captcha"; public static final Map CAPTCHA_TYPES; static { Map captchaTypes = new LinkedHashMap<>(); captchaTypes.put(CAPTCHA_TYPE_2CH_CAPTCHA, "2chcaptcha"); + captchaTypes.put(CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA, "emoji"); captchaTypes.put(CAPTCHA_TYPE_RECAPTCHA_2, "recaptcha"); captchaTypes.put(CAPTCHA_TYPE_RECAPTCHA_2_INVISIBLE, "invisible_recaptcha"); CAPTCHA_TYPES = Collections.unmodifiableMap(captchaTypes); @@ -69,15 +71,23 @@ public Board obtainBoardConfiguration(String boardName) { @Override public Captcha obtainCustomCaptchaConfiguration(String captchaType) { - if (CAPTCHA_TYPE_2CH_CAPTCHA.equals(captchaType)) { - Captcha captcha = new Captcha(); - captcha.title = "2ch Captcha"; - captcha.input = Captcha.Input.ALL; - captcha.validity = Captcha.Validity.IN_THREAD; - captcha.ttl = CAPTCHA_TTL; - return captcha; + Captcha captcha = new Captcha(); + switch (captchaType) { + case CAPTCHA_TYPE_2CH_CAPTCHA: + captcha.title = "2ch Captcha"; + captcha.input = Captcha.Input.ALL; + captcha.validity = Captcha.Validity.IN_THREAD; + captcha.ttl = CAPTCHA_TTL; + return captcha; + case CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA: + captcha.title = "Emoji Captcha"; + captcha.input = Captcha.Input.ALL; + captcha.validity = Captcha.Validity.IN_THREAD; + captcha.ttl = CAPTCHA_TTL; + return captcha; + default: + return null; } - return null; } @Override diff --git a/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanPerformer.java b/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanPerformer.java index 08d1c937..34eaafaa 100644 --- a/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanPerformer.java +++ b/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanPerformer.java @@ -1016,7 +1016,25 @@ private ReadCaptchaResult onReadCaptcha(ReadCaptchaData data, String captchaPass } } - } else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) || + } else if (DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA.equals(data.captchaType)) { + if (data.mayShowLoadButton) { + return new ReadCaptchaResult(CaptchaState.NEED_LOAD, null); + } + DvachEmojiCaptchaProvider.DvachEmojiCaptchaAnswerRetriever retriever = + (Bitmap task, Bitmap[] keyboardImages) -> { + try { + return requireUserImageSingleChoice(-1, + keyboardImages, + configuration.getResources().getString( + R.string.emoji_captcha_input), + task); + } catch (HttpException e) { + return -1; + } + }; + return new DvachEmojiCaptchaProvider(data, locator, id, retriever) + .loadEmojiCaptcha(); + } else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) || DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2_INVISIBLE.equals(data.captchaType)) { result = new ReadCaptchaResult(CaptchaState.CAPTCHA, captchaData); captchaData.put(CaptchaData.API_KEY, id); @@ -1091,14 +1109,19 @@ public SendPostResult onSendPost(SendPostData data) throws HttpException, ApiExc String challenge = data.captchaData.get(CaptchaData.CHALLENGE); String input = StringUtils.emptyIfNull(data.captchaData.get(CaptchaData.INPUT)); - String remoteCaptchaType = DvachChanConfiguration.CAPTCHA_TYPES.get(data.captchaType); - if (remoteCaptchaType != null) { - entity.add("captcha_type", remoteCaptchaType); + if (!DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA.equals(data.captchaType)) { + String remoteCaptchaType = DvachChanConfiguration.CAPTCHA_TYPES.get(data.captchaType); + if (remoteCaptchaType != null) { + entity.add("captcha_type", remoteCaptchaType); + } } if (DvachChanConfiguration.CAPTCHA_TYPE_2CH_CAPTCHA.equals(data.captchaType)) { entity.add("2chcaptcha_id", challenge); entity.add("2chcaptcha_value", input); - } else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) || + } else if (DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA.equals(data.captchaType)) { + entity.add("captcha_type", DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA); + entity.add("emoji_captcha_id", challenge); + } else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) || DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2_INVISIBLE.equals(data.captchaType)) { entity.add("g-recaptcha-response", input); } diff --git a/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachEmojiCaptchaProvider.java b/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachEmojiCaptchaProvider.java new file mode 100644 index 00000000..454e9fd5 --- /dev/null +++ b/extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachEmojiCaptchaProvider.java @@ -0,0 +1,310 @@ +package com.mishiranu.dashchan.chan.dvach; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.net.Uri; +import android.util.Base64; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.List; + +import chan.content.ChanPerformer; +import chan.http.HttpException; +import chan.http.HttpRequest; +import chan.http.HttpResponse; +import chan.http.SimpleEntity; +import chan.text.JsonSerial; +import chan.text.ParseException; + +/** + * Separate class which contains logic for solving emoji_captcha type on 2ch.hk. + * Emoji captcha shows a picture with some emojis, and custom keyboard with emojis. User should + * use keyboard to select all emojis from picture (order-independent). Keyboard changes after each + * user input. + */ +class DvachEmojiCaptchaProvider { + + private final DvachChanLocator locator; + private final String captchaId; + + private final DvachChanPerformer.ReadCaptchaData data; + + private final DvachEmojiCaptchaAnswerRetriever answerRetriever; + + /** + * Main constructor. For each captcha task we creating new instance of this class. + * @param data - input info about captcha + * @param locator - dvach locator for making requests + * @param id - captcha id + * @param answerRetriever - lambda for emoji selection on keyboard by user + */ + DvachEmojiCaptchaProvider(DvachChanPerformer.ReadCaptchaData data, + DvachChanLocator locator, + String id, + DvachEmojiCaptchaAnswerRetriever answerRetriever) { + this.locator = locator; + this.data = data; + this.captchaId = id; + this.answerRetriever = answerRetriever; + } + + /** + * Entrypoint for solver. No params since we set them all in constructor. Method gets the task + * and starts the solving cycle. + * @return final captcha result. + * @throws HttpException if some request was failed + */ + ChanPerformer.ReadCaptchaResult loadEmojiCaptcha() throws HttpException { + // First, we request initial captcha state + Uri uri = locator.buildPath("api", "captcha", "emoji", "show").buildUpon() + .appendQueryParameter("id", captchaId).build(); + HttpResponse response = doWithRetries(uri, data, 3); + EmojiCaptchaResponse parsedResponse = parseEmojiCaptcha(response); + + //prepare selected emojis state + SelectedEmojis selected = new SelectedEmojis(); + + return solveEmojiCaptchaLoop(parsedResponse, selected); + } + + /** + * Captcha solving loop method. After each user input, we sent it to the server and getting a + * new keyboard, until {@link EmojiCaptchaResponse.Success} is received. + * @param parsedResponse current server response + * @param selected current user selected emojis + * @return captcha answer (probably from recursive method call) + * @throws HttpException + */ + private ChanPerformer.ReadCaptchaResult solveEmojiCaptchaLoop( + EmojiCaptchaResponse parsedResponse, + SelectedEmojis selected + ) throws HttpException { + // If we received new captcha content, then we show it to user, so that he chooses emoji from keyboard + if (parsedResponse instanceof EmojiCaptchaResponse.Content) { + EmojiCaptchaResponse.Content content = (EmojiCaptchaResponse.Content) parsedResponse; + + // prepare captcha task image with previously selected emojis + Bitmap captchaImage = base64ToBitmap(content.image); + Bitmap comboBitmap = createTaskWithSelectedBitmap(captchaImage, selected); + + // prepare captcha task keyboard array + Bitmap[] keyboardImages = new Bitmap[content.keyboard.size()]; + for (int i = 0; i < content.keyboard.size(); i++) { + Bitmap origKeyIcon = base64ToBitmap(content.keyboard.get(i)); + int maxSize = Math.max(origKeyIcon.getHeight(), origKeyIcon.getWidth()); + Bitmap keyBitmap = Bitmap.createBitmap(maxSize, maxSize, Bitmap.Config.ARGB_8888); + Canvas keyCanvas = new Canvas(keyBitmap); + int x = Math.max((origKeyIcon.getHeight() - origKeyIcon.getWidth()) / 2, 0); + int y = Math.max((origKeyIcon.getWidth() - origKeyIcon.getHeight()) / 2, 0); + keyCanvas.drawBitmap(origKeyIcon, x, y, null); + keyboardImages[i] = keyBitmap; + } + + // send task image and keyboard, receive user input + Integer answer = answerRetriever.getAnswer(comboBitmap, keyboardImages); + + // if user skipped answer, or made improper input, then we stopping captcha solving + if (answer == null || answer == -1 || answer >= keyboardImages.length) { + return new ChanPerformer.ReadCaptchaResult(ChanPerformer.CaptchaState.NEED_LOAD, null); + } else { + // if user made a valid selection, we process it + // add selected emoji to list of selected + Bitmap selectedBitmap = keyboardImages[answer]; + selected.bitmaps.add(Bitmap.createScaledBitmap(selectedBitmap, + selectedBitmap.getWidth() * SelectedEmojis.SIZE / selectedBitmap.getHeight(), + SelectedEmojis.SIZE, true)); + + // send user selection to server + try { + Uri uri = locator.buildPath("api", "captcha", "emoji", "click") + .buildUpon().build(); + SimpleEntity entity = new SimpleEntity(); + entity.setContentType("application/json; charset=utf-8"); + JSONObject jsonObject = new JSONObject(); + jsonObject.put("captchaTokenId", captchaId); + jsonObject.put("emojiNumber", answer); + entity.setData(jsonObject.toString()); + HttpResponse response = new HttpRequest(uri, data) + .setPostMethod(entity).perform(); + + // server returns new response (this is either new state or finish signal) + EmojiCaptchaResponse parsedClickResponse = parseEmojiCaptcha(response); + + //continue the loop + return solveEmojiCaptchaLoop(parsedClickResponse, selected); + } catch (JSONException ex) { + // if something goes wrong, just drop the captcha solving process + return new ChanPerformer.ReadCaptchaResult(ChanPerformer.CaptchaState.NEED_LOAD, null); + } + } + } else { + // If we got "success" field in server response then we finish the process, and fill + // the result + EmojiCaptchaResponse.Success success = (EmojiCaptchaResponse.Success) parsedResponse; + ChanPerformer.CaptchaData captchaData = new ChanPerformer.CaptchaData(); + ChanPerformer.ReadCaptchaResult result = new ChanPerformer.ReadCaptchaResult(ChanPerformer.CaptchaState.SKIP, captchaData); + // Fill the challenge field with result, to use it later when we send post + captchaData.put(ChanPerformer.CaptchaData.CHALLENGE, success.success); + return result; + } + } + + private HttpResponse doWithRetries(Uri uri, HttpRequest.Preset data, int attempts) throws HttpException { + while (true) { + try { + return new HttpRequest(uri, data).perform(); + } catch (HttpException e) { + attempts--; + if (attempts == 0 || e.getResponseCode() != HttpURLConnection.HTTP_INTERNAL_ERROR) { + throw e; + } + try { + int delayBetweenLoadCaptchaImageAttemptsMillis = 500; + Thread.sleep(delayBetweenLoadCaptchaImageAttemptsMillis); + } catch (InterruptedException ex) { + throw e; + } + } + } + } + + /** + * Class for parsing server captcha response. + * @param response - raw response + * @return {@link EmojiCaptchaResponse.Success} or {@link EmojiCaptchaResponse.Content} object + * @throws HttpException + * @throws RuntimeException + */ + private EmojiCaptchaResponse parseEmojiCaptcha(HttpResponse response) throws HttpException, RuntimeException { + String image = ""; + ArrayList keyboard = new ArrayList<>(); + try (InputStream input = response.open(); + JsonSerial.Reader reader = JsonSerial.reader(input)) { + reader.startObject(); + while (!reader.endStruct()) { + switch (reader.nextName()) { + case "image": { + image = reader.nextString(); + break; + } + case "keyboard": { + reader.startArray(); + while (!reader.endStruct()) { + keyboard.add(reader.nextString()); + } + break; + } + case "success": { + return new EmojiCaptchaResponse.Success(reader.nextString()); + } + } + } + } catch (IOException | ParseException ex) { + throw new RuntimeException(ex.getMessage()); + } + return new EmojiCaptchaResponse.Content(image, keyboard); + } + + /** + * This method merges captcha task image and previously selected emojis + * @param captchaImage captcha task image + * @param selected selected emoji container + * @return resulting bitmap + */ + private Bitmap createTaskWithSelectedBitmap(Bitmap captchaImage, SelectedEmojis selected) { + Bitmap comboBitmap; + + int width, height; + width = captchaImage.getWidth(); + height = captchaImage.getHeight() + SelectedEmojis.SIZE_WITH_PADDING; + comboBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas comboImage = new Canvas(comboBitmap); + + for (int i = 0; i < selected.bitmaps.size(); i++) { + comboImage.drawBitmap(selected.bitmaps.get(i), + i * SelectedEmojis.SIZE_WITH_PADDING, 0f, null); + } + comboImage.drawBitmap(captchaImage, 0, SelectedEmojis.SIZE_WITH_PADDING, null); + return comboBitmap; + } + + private Bitmap base64ToBitmap(String base64) { + byte[] decodedString = Base64.decode(base64, Base64.DEFAULT); + return BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length); + } + + /** + * This interface is used for selecting emoji from keyboard from user + */ + interface DvachEmojiCaptchaAnswerRetriever { + + /** + * @param task captcha task bitmap, also showing previously selected emojis + * @param keyboard captcha keyboard bitmap array + * @return keyboard selection index + */ + Integer getAnswer(Bitmap task, Bitmap[] keyboard); + } + + /** + * This class is used for storing previously selected user emojis, to show them in emoji + * selection dialog alongside with captcha task + */ + private static class SelectedEmojis { + + private static final int SIZE = 40; + private static final int PADDING = 5; + + private static final int SIZE_WITH_PADDING = SIZE + PADDING; + + private final List bitmaps = new ArrayList<>(); + } + + + /** + * Base data class for emoji captcha info from server. + */ + private abstract static class EmojiCaptchaResponse { + + /** + * This class is received from the server if the captcha solving is finished. + */ + private static class Success extends EmojiCaptchaResponse { + private final String success; + + /** + * @param success this is captcha result, we send it with the post. + */ + private Success(String success) { + this.success = success; + } + } + + /** + * This class is received from the server if the captcha solving is in process. + */ + private static class Content extends EmojiCaptchaResponse { + + private final String image; + + private final List keyboard; + + /** + * @param image base64 picture, captcha task + * @param keyboard base64 picture array, actual task keyboard + */ + private Content(String image, List keyboard) { + this.image = image; + this.keyboard = keyboard; + } + } + } + +}