Skip to content

Commit bbceaed

Browse files
authoredSep 9, 2024··
Merge pull request #83 from Sweetsound/2ch-emoji-captcha
Support emoji_captcha for 2ch.hk
2 parents 5190073 + 0292c37 commit bbceaed

File tree

5 files changed

+358
-13
lines changed

5 files changed

+358
-13
lines changed
 

‎extensions/dvach/res/values-ru/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
<resources>
44
<string name="preference_captcha_full_keyboard">Использовать полную клавиатуру для капчи</string>
5+
<string name="emoji_captcha_input">Выберите все символы на картинке (в любом порядке)</string>
56
</resources>

‎extensions/dvach/res/values/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
<resources>
44
<string name="preference_captcha_full_keyboard">Use full keyboard for captcha</string>
5+
<string name="emoji_captcha_input">Select all icons from the picture (any order)</string>
56
</resources>

‎extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanConfiguration.java

+18-8
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717

1818
public class DvachChanConfiguration extends ChanConfiguration {
1919
public static final String CAPTCHA_TYPE_2CH_CAPTCHA = "2ch_captcha";
20+
public static final String CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA = "emoji_captcha";
2021

2122
public static final Map<String, String> CAPTCHA_TYPES;
2223

2324
static {
2425
Map<String, String> captchaTypes = new LinkedHashMap<>();
2526
captchaTypes.put(CAPTCHA_TYPE_2CH_CAPTCHA, "2chcaptcha");
27+
captchaTypes.put(CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA, "emoji");
2628
captchaTypes.put(CAPTCHA_TYPE_RECAPTCHA_2, "recaptcha");
2729
captchaTypes.put(CAPTCHA_TYPE_RECAPTCHA_2_INVISIBLE, "invisible_recaptcha");
2830
CAPTCHA_TYPES = Collections.unmodifiableMap(captchaTypes);
@@ -69,15 +71,23 @@ public Board obtainBoardConfiguration(String boardName) {
6971

7072
@Override
7173
public Captcha obtainCustomCaptchaConfiguration(String captchaType) {
72-
if (CAPTCHA_TYPE_2CH_CAPTCHA.equals(captchaType)) {
73-
Captcha captcha = new Captcha();
74-
captcha.title = "2ch Captcha";
75-
captcha.input = Captcha.Input.ALL;
76-
captcha.validity = Captcha.Validity.IN_THREAD;
77-
captcha.ttl = CAPTCHA_TTL;
78-
return captcha;
74+
Captcha captcha = new Captcha();
75+
switch (captchaType) {
76+
case CAPTCHA_TYPE_2CH_CAPTCHA:
77+
captcha.title = "2ch Captcha";
78+
captcha.input = Captcha.Input.ALL;
79+
captcha.validity = Captcha.Validity.IN_THREAD;
80+
captcha.ttl = CAPTCHA_TTL;
81+
return captcha;
82+
case CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA:
83+
captcha.title = "Emoji Captcha";
84+
captcha.input = Captcha.Input.ALL;
85+
captcha.validity = Captcha.Validity.IN_THREAD;
86+
captcha.ttl = CAPTCHA_TTL;
87+
return captcha;
88+
default:
89+
return null;
7990
}
80-
return null;
8191
}
8292

8393
@Override

‎extensions/dvach/src/com/mishiranu/dashchan/chan/dvach/DvachChanPerformer.java

+28-5
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,25 @@ private ReadCaptchaResult onReadCaptcha(ReadCaptchaData data, String captchaPass
10161016
}
10171017
}
10181018

1019-
} else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) ||
1019+
} else if (DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA.equals(data.captchaType)) {
1020+
if (data.mayShowLoadButton) {
1021+
return new ReadCaptchaResult(CaptchaState.NEED_LOAD, null);
1022+
}
1023+
DvachEmojiCaptchaProvider.DvachEmojiCaptchaAnswerRetriever retriever =
1024+
(Bitmap task, Bitmap[] keyboardImages) -> {
1025+
try {
1026+
return requireUserImageSingleChoice(-1,
1027+
keyboardImages,
1028+
configuration.getResources().getString(
1029+
R.string.emoji_captcha_input),
1030+
task);
1031+
} catch (HttpException e) {
1032+
return -1;
1033+
}
1034+
};
1035+
return new DvachEmojiCaptchaProvider(data, locator, id, retriever)
1036+
.loadEmojiCaptcha();
1037+
} else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) ||
10201038
DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2_INVISIBLE.equals(data.captchaType)) {
10211039
result = new ReadCaptchaResult(CaptchaState.CAPTCHA, captchaData);
10221040
captchaData.put(CaptchaData.API_KEY, id);
@@ -1091,14 +1109,19 @@ public SendPostResult onSendPost(SendPostData data) throws HttpException, ApiExc
10911109
String challenge = data.captchaData.get(CaptchaData.CHALLENGE);
10921110
String input = StringUtils.emptyIfNull(data.captchaData.get(CaptchaData.INPUT));
10931111

1094-
String remoteCaptchaType = DvachChanConfiguration.CAPTCHA_TYPES.get(data.captchaType);
1095-
if (remoteCaptchaType != null) {
1096-
entity.add("captcha_type", remoteCaptchaType);
1112+
if (!DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA.equals(data.captchaType)) {
1113+
String remoteCaptchaType = DvachChanConfiguration.CAPTCHA_TYPES.get(data.captchaType);
1114+
if (remoteCaptchaType != null) {
1115+
entity.add("captcha_type", remoteCaptchaType);
1116+
}
10971117
}
10981118
if (DvachChanConfiguration.CAPTCHA_TYPE_2CH_CAPTCHA.equals(data.captchaType)) {
10991119
entity.add("2chcaptcha_id", challenge);
11001120
entity.add("2chcaptcha_value", input);
1101-
} else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) ||
1121+
} else if (DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA.equals(data.captchaType)) {
1122+
entity.add("captcha_type", DvachChanConfiguration.CAPTCHA_TYPE_2CH_EMOJI_CAPTCHA);
1123+
entity.add("emoji_captcha_id", challenge);
1124+
} else if (DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2.equals(data.captchaType) ||
11021125
DvachChanConfiguration.CAPTCHA_TYPE_RECAPTCHA_2_INVISIBLE.equals(data.captchaType)) {
11031126
entity.add("g-recaptcha-response", input);
11041127
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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

Comments
 (0)
Please sign in to comment.