|
| 1 | +package com.mishiranu.dashchan.chan.dvach; |
| 2 | + |
| 3 | +import android.graphics.Bitmap; |
| 4 | +import android.graphics.BitmapFactory; |
| 5 | +import android.graphics.Canvas; |
| 6 | +import android.net.Uri; |
| 7 | +import android.util.Base64; |
| 8 | + |
| 9 | +import org.json.JSONException; |
| 10 | +import org.json.JSONObject; |
| 11 | + |
| 12 | +import java.io.IOException; |
| 13 | +import java.io.InputStream; |
| 14 | +import java.net.HttpURLConnection; |
| 15 | +import java.util.ArrayList; |
| 16 | +import java.util.List; |
| 17 | + |
| 18 | +import chan.content.ChanPerformer; |
| 19 | +import chan.http.HttpException; |
| 20 | +import chan.http.HttpRequest; |
| 21 | +import chan.http.HttpResponse; |
| 22 | +import chan.http.SimpleEntity; |
| 23 | +import chan.text.JsonSerial; |
| 24 | +import chan.text.ParseException; |
| 25 | + |
| 26 | +/** |
| 27 | + * Separate class which contains logic for solving emoji_captcha type on 2ch.hk. |
| 28 | + * Emoji captcha shows a picture with some emojis, and custom keyboard with emojis. User should |
| 29 | + * use keyboard to select all emojis from picture (order-independent). Keyboard changes after each |
| 30 | + * user input. |
| 31 | + */ |
| 32 | +class DvachEmojiCaptchaProvider { |
| 33 | + |
| 34 | + private final DvachChanLocator locator; |
| 35 | + private final String captchaId; |
| 36 | + |
| 37 | + private final DvachChanPerformer.ReadCaptchaData data; |
| 38 | + |
| 39 | + private final DvachEmojiCaptchaAnswerRetriever answerRetriever; |
| 40 | + |
| 41 | + /** |
| 42 | + * Main constructor. For each captcha task we creating new instance of this class. |
| 43 | + * @param data - input info about captcha |
| 44 | + * @param locator - dvach locator for making requests |
| 45 | + * @param id - captcha id |
| 46 | + * @param answerRetriever - lambda for emoji selection on keyboard by user |
| 47 | + */ |
| 48 | + DvachEmojiCaptchaProvider(DvachChanPerformer.ReadCaptchaData data, |
| 49 | + DvachChanLocator locator, |
| 50 | + String id, |
| 51 | + DvachEmojiCaptchaAnswerRetriever answerRetriever) { |
| 52 | + this.locator = locator; |
| 53 | + this.data = data; |
| 54 | + this.captchaId = id; |
| 55 | + this.answerRetriever = answerRetriever; |
| 56 | + } |
| 57 | + |
| 58 | + /** |
| 59 | + * Entrypoint for solver. No params since we set them all in constructor. Method gets the task |
| 60 | + * and starts the solving cycle. |
| 61 | + * @return final captcha result. |
| 62 | + * @throws HttpException if some request was failed |
| 63 | + */ |
| 64 | + ChanPerformer.ReadCaptchaResult loadEmojiCaptcha() throws HttpException { |
| 65 | + // First, we request initial captcha state |
| 66 | + Uri uri = locator.buildPath("api", "captcha", "emoji", "show").buildUpon() |
| 67 | + .appendQueryParameter("id", captchaId).build(); |
| 68 | + HttpResponse response = doWithRetries(uri, data, 3); |
| 69 | + EmojiCaptchaResponse parsedResponse = parseEmojiCaptcha(response); |
| 70 | + |
| 71 | + //prepare selected emojis state |
| 72 | + SelectedEmojis selected = new SelectedEmojis(); |
| 73 | + |
| 74 | + return solveEmojiCaptchaLoop(parsedResponse, selected); |
| 75 | + } |
| 76 | + |
| 77 | + /** |
| 78 | + * Captcha solving loop method. After each user input, we sent it to the server and getting a |
| 79 | + * new keyboard, until {@link EmojiCaptchaResponse.Success} is received. |
| 80 | + * @param parsedResponse current server response |
| 81 | + * @param selected current user selected emojis |
| 82 | + * @return captcha answer (probably from recursive method call) |
| 83 | + * @throws HttpException |
| 84 | + */ |
| 85 | + private ChanPerformer.ReadCaptchaResult solveEmojiCaptchaLoop( |
| 86 | + EmojiCaptchaResponse parsedResponse, |
| 87 | + SelectedEmojis selected |
| 88 | + ) throws HttpException { |
| 89 | + // If we received new captcha content, then we show it to user, so that he chooses emoji from keyboard |
| 90 | + if (parsedResponse instanceof EmojiCaptchaResponse.Content) { |
| 91 | + EmojiCaptchaResponse.Content content = (EmojiCaptchaResponse.Content) parsedResponse; |
| 92 | + |
| 93 | + // prepare captcha task image with previously selected emojis |
| 94 | + Bitmap captchaImage = base64ToBitmap(content.image); |
| 95 | + Bitmap comboBitmap = createTaskWithSelectedBitmap(captchaImage, selected); |
| 96 | + |
| 97 | + // prepare captcha task keyboard array |
| 98 | + Bitmap[] keyboardImages = new Bitmap[content.keyboard.size()]; |
| 99 | + for (int i = 0; i < content.keyboard.size(); i++) { |
| 100 | + Bitmap origKeyIcon = base64ToBitmap(content.keyboard.get(i)); |
| 101 | + int maxSize = Math.max(origKeyIcon.getHeight(), origKeyIcon.getWidth()); |
| 102 | + Bitmap keyBitmap = Bitmap.createBitmap(maxSize, maxSize, Bitmap.Config.ARGB_8888); |
| 103 | + Canvas keyCanvas = new Canvas(keyBitmap); |
| 104 | + int x = Math.max((origKeyIcon.getHeight() - origKeyIcon.getWidth()) / 2, 0); |
| 105 | + int y = Math.max((origKeyIcon.getWidth() - origKeyIcon.getHeight()) / 2, 0); |
| 106 | + keyCanvas.drawBitmap(origKeyIcon, x, y, null); |
| 107 | + keyboardImages[i] = keyBitmap; |
| 108 | + } |
| 109 | + |
| 110 | + // send task image and keyboard, receive user input |
| 111 | + Integer answer = answerRetriever.getAnswer(comboBitmap, keyboardImages); |
| 112 | + |
| 113 | + // if user skipped answer, or made improper input, then we stopping captcha solving |
| 114 | + if (answer == null || answer == -1 || answer >= keyboardImages.length) { |
| 115 | + return new ChanPerformer.ReadCaptchaResult(ChanPerformer.CaptchaState.NEED_LOAD, null); |
| 116 | + } else { |
| 117 | + // if user made a valid selection, we process it |
| 118 | + // add selected emoji to list of selected |
| 119 | + Bitmap selectedBitmap = keyboardImages[answer]; |
| 120 | + selected.bitmaps.add(Bitmap.createScaledBitmap(selectedBitmap, |
| 121 | + selectedBitmap.getWidth() * SelectedEmojis.SIZE / selectedBitmap.getHeight(), |
| 122 | + SelectedEmojis.SIZE, true)); |
| 123 | + |
| 124 | + // send user selection to server |
| 125 | + try { |
| 126 | + Uri uri = locator.buildPath("api", "captcha", "emoji", "click") |
| 127 | + .buildUpon().build(); |
| 128 | + SimpleEntity entity = new SimpleEntity(); |
| 129 | + entity.setContentType("application/json; charset=utf-8"); |
| 130 | + JSONObject jsonObject = new JSONObject(); |
| 131 | + jsonObject.put("captchaTokenId", captchaId); |
| 132 | + jsonObject.put("emojiNumber", answer); |
| 133 | + entity.setData(jsonObject.toString()); |
| 134 | + HttpResponse response = new HttpRequest(uri, data) |
| 135 | + .setPostMethod(entity).perform(); |
| 136 | + |
| 137 | + // server returns new response (this is either new state or finish signal) |
| 138 | + EmojiCaptchaResponse parsedClickResponse = parseEmojiCaptcha(response); |
| 139 | + |
| 140 | + //continue the loop |
| 141 | + return solveEmojiCaptchaLoop(parsedClickResponse, selected); |
| 142 | + } catch (JSONException ex) { |
| 143 | + // if something goes wrong, just drop the captcha solving process |
| 144 | + return new ChanPerformer.ReadCaptchaResult(ChanPerformer.CaptchaState.NEED_LOAD, null); |
| 145 | + } |
| 146 | + } |
| 147 | + } else { |
| 148 | + // If we got "success" field in server response then we finish the process, and fill |
| 149 | + // the result |
| 150 | + EmojiCaptchaResponse.Success success = (EmojiCaptchaResponse.Success) parsedResponse; |
| 151 | + ChanPerformer.CaptchaData captchaData = new ChanPerformer.CaptchaData(); |
| 152 | + ChanPerformer.ReadCaptchaResult result = new ChanPerformer.ReadCaptchaResult(ChanPerformer.CaptchaState.SKIP, captchaData); |
| 153 | + // Fill the challenge field with result, to use it later when we send post |
| 154 | + captchaData.put(ChanPerformer.CaptchaData.CHALLENGE, success.success); |
| 155 | + return result; |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + private HttpResponse doWithRetries(Uri uri, HttpRequest.Preset data, int attempts) throws HttpException { |
| 160 | + while (true) { |
| 161 | + try { |
| 162 | + return new HttpRequest(uri, data).perform(); |
| 163 | + } catch (HttpException e) { |
| 164 | + attempts--; |
| 165 | + if (attempts == 0 || e.getResponseCode() != HttpURLConnection.HTTP_INTERNAL_ERROR) { |
| 166 | + throw e; |
| 167 | + } |
| 168 | + try { |
| 169 | + int delayBetweenLoadCaptchaImageAttemptsMillis = 500; |
| 170 | + Thread.sleep(delayBetweenLoadCaptchaImageAttemptsMillis); |
| 171 | + } catch (InterruptedException ex) { |
| 172 | + throw e; |
| 173 | + } |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * Class for parsing server captcha response. |
| 180 | + * @param response - raw response |
| 181 | + * @return {@link EmojiCaptchaResponse.Success} or {@link EmojiCaptchaResponse.Content} object |
| 182 | + * @throws HttpException |
| 183 | + * @throws RuntimeException |
| 184 | + */ |
| 185 | + private EmojiCaptchaResponse parseEmojiCaptcha(HttpResponse response) throws HttpException, RuntimeException { |
| 186 | + String image = ""; |
| 187 | + ArrayList<String> keyboard = new ArrayList<>(); |
| 188 | + try (InputStream input = response.open(); |
| 189 | + JsonSerial.Reader reader = JsonSerial.reader(input)) { |
| 190 | + reader.startObject(); |
| 191 | + while (!reader.endStruct()) { |
| 192 | + switch (reader.nextName()) { |
| 193 | + case "image": { |
| 194 | + image = reader.nextString(); |
| 195 | + break; |
| 196 | + } |
| 197 | + case "keyboard": { |
| 198 | + reader.startArray(); |
| 199 | + while (!reader.endStruct()) { |
| 200 | + keyboard.add(reader.nextString()); |
| 201 | + } |
| 202 | + break; |
| 203 | + } |
| 204 | + case "success": { |
| 205 | + return new EmojiCaptchaResponse.Success(reader.nextString()); |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | + } catch (IOException | ParseException ex) { |
| 210 | + throw new RuntimeException(ex.getMessage()); |
| 211 | + } |
| 212 | + return new EmojiCaptchaResponse.Content(image, keyboard); |
| 213 | + } |
| 214 | + |
| 215 | + /** |
| 216 | + * This method merges captcha task image and previously selected emojis |
| 217 | + * @param captchaImage captcha task image |
| 218 | + * @param selected selected emoji container |
| 219 | + * @return resulting bitmap |
| 220 | + */ |
| 221 | + private Bitmap createTaskWithSelectedBitmap(Bitmap captchaImage, SelectedEmojis selected) { |
| 222 | + Bitmap comboBitmap; |
| 223 | + |
| 224 | + int width, height; |
| 225 | + width = captchaImage.getWidth(); |
| 226 | + height = captchaImage.getHeight() + SelectedEmojis.SIZE_WITH_PADDING; |
| 227 | + comboBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); |
| 228 | + Canvas comboImage = new Canvas(comboBitmap); |
| 229 | + |
| 230 | + for (int i = 0; i < selected.bitmaps.size(); i++) { |
| 231 | + comboImage.drawBitmap(selected.bitmaps.get(i), |
| 232 | + i * SelectedEmojis.SIZE_WITH_PADDING, 0f, null); |
| 233 | + } |
| 234 | + comboImage.drawBitmap(captchaImage, 0, SelectedEmojis.SIZE_WITH_PADDING, null); |
| 235 | + return comboBitmap; |
| 236 | + } |
| 237 | + |
| 238 | + private Bitmap base64ToBitmap(String base64) { |
| 239 | + byte[] decodedString = Base64.decode(base64, Base64.DEFAULT); |
| 240 | + return BitmapFactory.decodeByteArray(decodedString, 0, decodedString.length); |
| 241 | + } |
| 242 | + |
| 243 | + /** |
| 244 | + * This interface is used for selecting emoji from keyboard from user |
| 245 | + */ |
| 246 | + interface DvachEmojiCaptchaAnswerRetriever { |
| 247 | + |
| 248 | + /** |
| 249 | + * @param task captcha task bitmap, also showing previously selected emojis |
| 250 | + * @param keyboard captcha keyboard bitmap array |
| 251 | + * @return keyboard selection index |
| 252 | + */ |
| 253 | + Integer getAnswer(Bitmap task, Bitmap[] keyboard); |
| 254 | + } |
| 255 | + |
| 256 | + /** |
| 257 | + * This class is used for storing previously selected user emojis, to show them in emoji |
| 258 | + * selection dialog alongside with captcha task |
| 259 | + */ |
| 260 | + private static class SelectedEmojis { |
| 261 | + |
| 262 | + private static final int SIZE = 40; |
| 263 | + private static final int PADDING = 5; |
| 264 | + |
| 265 | + private static final int SIZE_WITH_PADDING = SIZE + PADDING; |
| 266 | + |
| 267 | + private final List<Bitmap> bitmaps = new ArrayList<>(); |
| 268 | + } |
| 269 | + |
| 270 | + |
| 271 | + /** |
| 272 | + * Base data class for emoji captcha info from server. |
| 273 | + */ |
| 274 | + private abstract static class EmojiCaptchaResponse { |
| 275 | + |
| 276 | + /** |
| 277 | + * This class is received from the server if the captcha solving is finished. |
| 278 | + */ |
| 279 | + private static class Success extends EmojiCaptchaResponse { |
| 280 | + private final String success; |
| 281 | + |
| 282 | + /** |
| 283 | + * @param success this is captcha result, we send it with the post. |
| 284 | + */ |
| 285 | + private Success(String success) { |
| 286 | + this.success = success; |
| 287 | + } |
| 288 | + } |
| 289 | + |
| 290 | + /** |
| 291 | + * This class is received from the server if the captcha solving is in process. |
| 292 | + */ |
| 293 | + private static class Content extends EmojiCaptchaResponse { |
| 294 | + |
| 295 | + private final String image; |
| 296 | + |
| 297 | + private final List<String> keyboard; |
| 298 | + |
| 299 | + /** |
| 300 | + * @param image base64 picture, captcha task |
| 301 | + * @param keyboard base64 picture array, actual task keyboard |
| 302 | + */ |
| 303 | + private Content(String image, List<String> keyboard) { |
| 304 | + this.image = image; |
| 305 | + this.keyboard = keyboard; |
| 306 | + } |
| 307 | + } |
| 308 | + } |
| 309 | + |
| 310 | +} |
0 commit comments