diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/SiteRegistry.java b/Clover/app/src/main/java/org/floens/chan/core/site/SiteRegistry.java index d0dc8583..dc793adb 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/site/SiteRegistry.java +++ b/Clover/app/src/main/java/org/floens/chan/core/site/SiteRegistry.java @@ -24,6 +24,7 @@ import org.floens.chan.core.site.sites.lainchan.Lainchan; import org.floens.chan.core.site.sites.chan8.Chan8; import org.floens.chan.core.site.sites.arisuchan.Arisuchan; import org.floens.chan.core.site.sites.sushichan.Sushichan; +import org.floens.chan.core.site.sites.dvach.Dvach; import java.util.ArrayList; import java.util.List; @@ -41,6 +42,7 @@ public class SiteRegistry { URL_HANDLERS.add(Lainchan.URL_HANDLER); URL_HANDLERS.add(Arisuchan.URL_HANDLER); URL_HANDLERS.add(Sushichan.URL_HANDLER); + URL_HANDLERS.add(Dvach.URL_HANDLER); } static { @@ -53,5 +55,6 @@ public class SiteRegistry { SITE_CLASSES.put(2, Lainchan.class); SITE_CLASSES.put(3, Arisuchan.class); SITE_CLASSES.put(4, Sushichan.class); + SITE_CLASSES.put(5, Dvach.class); } } diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/Dvach.java b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/Dvach.java new file mode 100644 index 00000000..fad5bf0b --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/Dvach.java @@ -0,0 +1,213 @@ +package org.floens.chan.core.site.sites.dvach; + +import android.support.annotation.Nullable; + +import org.floens.chan.core.model.Post; +import org.floens.chan.core.model.orm.Board; +import org.floens.chan.core.model.orm.Loadable; +import org.floens.chan.core.settings.OptionsSetting; +import org.floens.chan.core.site.Boards; +import org.floens.chan.core.site.Site; +import org.floens.chan.core.site.SiteAuthentication; +import org.floens.chan.core.site.SiteIcon; +import org.floens.chan.core.site.SiteSetting; +import org.floens.chan.core.site.common.CommonReplyHttpCall; +import org.floens.chan.core.site.common.CommonSite; +import org.floens.chan.core.site.common.MultipartHttpCall; +import org.floens.chan.core.site.common.vichan.VichanActions; +import org.floens.chan.core.site.common.vichan.VichanCommentParser; +import org.floens.chan.core.site.common.vichan.VichanEndpoints; +import org.floens.chan.core.site.http.DeleteRequest; +import org.floens.chan.core.site.http.HttpCall; +import org.floens.chan.core.site.http.Reply; +import org.floens.chan.core.site.sites.chan4.Chan4; +import org.floens.chan.utils.Logger; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import okhttp3.HttpUrl; + +import static android.support.constraint.Constraints.TAG; + +public class Dvach extends CommonSite { + public static final CommonSiteUrlHandler URL_HANDLER = new CommonSiteUrlHandler() { + @Override + public Class getSiteClass() { + return Dvach.class; + } + + @Override + public HttpUrl getUrl() { + return HttpUrl.parse("https:/2ch.hk"); + } + + @Override + public String[] getNames() { + return new String[]{"dvach", "2ch"}; + } + + @Override + public String desktopUrl(Loadable loadable, @Nullable Post post) { + if (loadable.isCatalogMode()) { + return getUrl().newBuilder().addPathSegment(loadable.boardCode).toString(); + } else if (loadable.isThreadMode()) { + return getUrl().newBuilder() + .addPathSegment(loadable.boardCode).addPathSegment("res") + .addPathSegment(String.valueOf(loadable.no) + ".html") + .toString(); + } else { + return getUrl().toString(); + } + } + }; + + static final String CAPTCHA_KEY = "6LeQYz4UAAAAAL8JCk35wHSv6cuEV5PyLhI6IxsM"; + + private OptionsSetting captchaType; + + @Override + public void initializeSettings() { + super.initializeSettings(); + captchaType = new OptionsSetting<>(settingsProvider, + "preference_captcha_type", + Chan4.CaptchaType.class, Chan4.CaptchaType.V2JS); + } + + @Override + public List settings() { + return Arrays.asList( + SiteSetting.forOption( + captchaType, + "Captcha type", + Arrays.asList("Javascript", "Noscript")) + ); + } + + @Override + public void setup() { + setName("2ch.hk"); + setIcon(SiteIcon.fromFavicon(HttpUrl.parse("https://2ch.hk/favicon.ico"))); + setBoardsType(BoardsType.DYNAMIC); + + setResolvable(URL_HANDLER); + + setConfig(new CommonConfig() { + @Override + public boolean feature(Feature feature) { + return feature == Feature.POSTING; + } + }); + + setEndpoints(new VichanEndpoints(this, + "https://2ch.hk", + "https://2ch.hk") { + @Override + public HttpUrl imageUrl(Post.Builder post, Map arg) { + return root.builder().s(arg.get("path")).url(); + } + + @Override + public HttpUrl thumbnailUrl(Post.Builder post, boolean spoiler, Map arg) { + return root.builder().s(arg.get("thumbnail")).url(); + } + + @Override + public HttpUrl boards() { + return new HttpUrl.Builder().scheme("https").host("2ch.hk").addPathSegment("boards.json").build(); + } + + @Override + public HttpUrl reply(Loadable loadable) { + return new HttpUrl.Builder().scheme("https").host("2ch.hk").addPathSegment("makaba").addPathSegment("posting.fcgi").addQueryParameter("json", "1").build(); + } + }); + + setActions(new VichanActions(this) { + + @Override + public void setupPost(Reply reply, MultipartHttpCall call) { + super.setupPost(reply, call); + + if (reply.loadable.isThreadMode()) { + // "thread" is already added in VichanActions. + call.parameter("post", "New Reply"); + } else { + call.parameter("post", "New Thread"); + call.parameter("page", "1"); + } + } + + @Override + public boolean requirePrepare() { + return false; + } + + @Override + public void post(Reply reply, final PostListener postListener) { + httpCallManager.makeHttpCall(new DvachReplyCall(Dvach.this, reply), new HttpCall.HttpCallback() { + @Override + public void onHttpSuccess(CommonReplyHttpCall httpPost) { + postListener.onPostComplete(httpPost, httpPost.replyResponse); + } + + @Override + public void onHttpFail(CommonReplyHttpCall httpPost, Exception e) { + postListener.onPostError(httpPost, e); + } + }); + } + + @Override + public boolean postRequiresAuthentication() { + return !isLoggedIn(); + } + + @Override + public SiteAuthentication postAuthenticate() { + if (isLoggedIn()) { + return SiteAuthentication.fromNone(); + } else { + switch (captchaType.get()) { + case V2JS: + return SiteAuthentication.fromCaptcha2(CAPTCHA_KEY, "https://2ch.hk/api/captcha/recaptcha/mobile"); + case V2NOJS: + return SiteAuthentication.fromCaptcha2nojs(CAPTCHA_KEY, "https://2ch.hk/api/captcha/recaptcha/mobile"); + default: + throw new IllegalArgumentException(); + } + } + } + + @Override + public void delete(DeleteRequest deleteRequest, DeleteListener deleteListener) { + super.delete(deleteRequest, deleteListener); + } + + @Override + public void boards(final BoardsListener listener) { + requestQueue.add(new DvachBoardsRequest(Dvach.this, response -> { + listener.onBoardsReceived(new Boards(response)); + }, (error) -> { + Logger.e(TAG, "Failed to get boards from server", error); + + // API fail, provide some default boards + List list = new ArrayList<>(); + list.add(Board.fromSiteNameCode(Dvach.this, "бред", "b")); + list.add(Board.fromSiteNameCode(Dvach.this, "Видеоигры, general, официальные треды", "vg")); + list.add(Board.fromSiteNameCode(Dvach.this, "новости", "news")); + list.add(Board.fromSiteNameCode(Dvach.this, "политика, новости, ольгинцы, хохлы, либерахи, рептилоиды.. oh shi", "po")); + Collections.shuffle(list); + listener.onBoardsReceived(new Boards(list)); + })); + } + }); + + setApi(new DvachApi(this)); + + setParser(new VichanCommentParser()); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachApi.java b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachApi.java new file mode 100644 index 00000000..ffcb99bf --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachApi.java @@ -0,0 +1,234 @@ +package org.floens.chan.core.site.sites.dvach; + +import android.util.JsonReader; + +import org.floens.chan.core.model.Post; +import org.floens.chan.core.model.PostImage; +import org.floens.chan.core.site.SiteEndpoints; +import org.floens.chan.core.site.common.CommonSite; +import org.floens.chan.core.site.parser.ChanReaderProcessingQueue; +import org.jsoup.parser.Parser; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.floens.chan.core.site.SiteEndpoints.makeArgument; + +public class DvachApi extends CommonSite.CommonApi { + DvachApi(CommonSite commonSite) { + super(commonSite); + } + + @Override + public void loadThread(JsonReader reader, ChanReaderProcessingQueue queue) throws Exception { + reader.beginObject(); // Main object + + while (reader.hasNext()) { + + if (reader.nextName().equals("threads")) { + reader.beginArray(); // Threads array + + while (reader.hasNext()) { + reader.beginObject(); // Posts object + if (reader.nextName().equals("posts")) { + reader.beginArray(); // Posts array + while (reader.hasNext()) { + readPostObject(reader, queue); + } + reader.endArray(); + } + reader.endObject(); + } + + reader.endArray(); + } else { + reader.skipValue(); + } + } + + reader.endObject(); + } + + @Override + public void loadCatalog(JsonReader reader, ChanReaderProcessingQueue queue) throws Exception { + reader.beginObject(); // Main object + + while (reader.hasNext()) { + if (reader.nextName().equals("threads")) { + reader.beginArray(); // Threads array + + while (reader.hasNext()) { + readPostObject(reader, queue); + } + + reader.endArray(); + } else { + reader.skipValue(); + } + } + + reader.endObject(); + } + + @Override + public void readPostObject(JsonReader reader, ChanReaderProcessingQueue queue) throws Exception { + Post.Builder builder = new Post.Builder(); + builder.board(queue.getLoadable().board); + + SiteEndpoints endpoints = queue.getLoadable().getSite().endpoints(); + + List files = new ArrayList<>(); + + reader.beginObject(); + while (reader.hasNext()) { + String key = reader.nextName(); + + switch (key) { + case "name": + builder.name(reader.nextString()); + break; + case "comment": + builder.comment(reader.nextString()); + break; + case "timestamp": + builder.setUnixTimestampSeconds(reader.nextLong()); + break; + case "trip": + builder.tripcode(reader.nextString()); + break; + case "op": + int opId = reader.nextInt(); + builder.op(opId == 0); + builder.opId(opId); + break; + case "sticky": + builder.sticky(reader.nextInt() == 1); + break; + case "closed": + builder.closed(reader.nextInt() == 1); + break; + case "archived": + builder.archived(reader.nextInt() == 1); + break; + case "posts_count": + builder.replies(reader.nextInt() - 1); + break; + case "files_count": + builder.images(reader.nextInt()); + break; + case "lasthit": + builder.lastModified(reader.nextLong()); + break; + case "num": + String num = reader.nextString(); + builder.id(Integer.parseInt(num)); + break; + case "files": + reader.beginArray(); + + while (reader.hasNext()) { + PostImage postImage = readPostImage(reader, builder, endpoints); + if (postImage != null) { + files.add(postImage); + } + } + + reader.endArray(); + break; + default: + // Unknown/ignored key + reader.skipValue(); + break; + } + } + reader.endObject(); + + builder.images(files); + + if (builder.op) { + // Update OP fields later on the main thread + Post.Builder op = new Post.Builder(); + op.closed(builder.closed); + op.archived(builder.archived); + op.sticky(builder.sticky); + op.replies(builder.replies); + op.images(builder.imagesCount); + op.uniqueIps(builder.uniqueIps); + op.lastModified(builder.lastModified); + queue.setOp(op); + } + + Post cached = queue.getCachedPost(builder.id); + if (cached != null) { + // Id is known, use the cached post object. + queue.addForReuse(cached); + return; + } + + queue.addForParse(builder); + } + + private PostImage readPostImage(JsonReader reader, Post.Builder builder, + SiteEndpoints endpoints) throws IOException { + reader.beginObject(); + + String path = null; + long fileSize = 0; + String fileExt = null; + int fileWidth = 0; + int fileHeight = 0; + String fileName = null; + String thumbnail = null; + + while (reader.hasNext()) { + switch (reader.nextName()) { + case "path": + path = reader.nextString(); + break; + case "name": + fileName = reader.nextString(); + break; + case "size": + fileSize = reader.nextLong(); + break; + case "width": + fileWidth = reader.nextInt(); + break; + case "height": + fileHeight = reader.nextInt(); + break; + case "thumbnail": + thumbnail = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + + reader.endObject(); + + if (fileName != null) { + fileExt = fileName.substring(fileName.lastIndexOf('.') + 1); + fileName = fileName.substring(0, fileName.lastIndexOf('.')); + } + + if (path != null && fileName != null) { + Map args = makeArgument("path", path, "thumbnail", thumbnail); + return new PostImage.Builder() + .originalName(fileName) + .thumbnailUrl(endpoints.thumbnailUrl(builder, false, args)) + .spoilerThumbnailUrl(endpoints.thumbnailUrl(builder, true, args)) + .imageUrl(endpoints.imageUrl(builder, args)) + .filename(Parser.unescapeEntities(fileName, false)) + .extension(fileExt) + .imageWidth(fileWidth) + .imageHeight(fileHeight) + .size(fileSize) + .build(); + } + return null; + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachBoardsRequest.java b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachBoardsRequest.java new file mode 100644 index 00000000..b34af8b6 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachBoardsRequest.java @@ -0,0 +1,108 @@ +/* + * Clover - 4chan browser https://github.com/Floens/Clover/ + * Copyright (C) 2014 Floens + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.floens.chan.core.site.sites.dvach; + +import android.util.JsonReader; + +import com.android.volley.Response.ErrorListener; +import com.android.volley.Response.Listener; + +import org.floens.chan.core.model.orm.Board; +import org.floens.chan.core.net.JsonReaderRequest; +import org.floens.chan.core.site.Site; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class DvachBoardsRequest extends JsonReaderRequest> { + private final Site site; + + DvachBoardsRequest(Site site, Listener> listener, ErrorListener errorListener) { + super(site.endpoints().boards().toString(), listener, errorListener); + this.site = site; + } + + @Override + public List readJson(JsonReader reader) throws Exception { + List list = new ArrayList<>(); + + reader.beginObject(); + while (reader.hasNext()) { + String key = reader.nextName(); + if (key.equals("boards")) { + reader.beginArray(); + + while (reader.hasNext()) { + Board board = readBoardEntry(reader); + if (board != null) { + list.add(board); + } + } + + reader.endArray(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + + return list; + } + + private Board readBoardEntry(JsonReader reader) throws IOException { + reader.beginObject(); + + Board board = new Board(); + board.siteId = site.id(); + board.site = site; + + while (reader.hasNext()) { + String key = reader.nextName(); + + switch (key) { + case "name": + board.name = reader.nextString(); + break; + case "id": + board.code = reader.nextString(); + break; + case "bump_limit": + board.bumpLimit = reader.nextInt(); + break; + case "info": + board.description = reader.nextString(); + break; + case "category": + board.workSafe = !"Взрослым".equals(reader.nextString()); + break; + default: + reader.skipValue(); + break; + } + } + + reader.endObject(); + + if (!board.finish()) { + // Invalid data, ignore + return null; + } + return board; + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachReplyCall.java b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachReplyCall.java new file mode 100644 index 00000000..e9da6955 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/site/sites/dvach/DvachReplyCall.java @@ -0,0 +1,101 @@ +/* + * Clover - 4chan browser https://github.com/Floens/Clover/ + * Copyright (C) 2014 Floens + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.floens.chan.core.site.sites.dvach; + +import android.text.TextUtils; + +import org.floens.chan.core.site.Site; +import org.floens.chan.core.site.common.CommonReplyHttpCall; +import org.floens.chan.core.site.http.Reply; +import org.jsoup.Jsoup; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class DvachReplyCall extends CommonReplyHttpCall { + private static final Pattern ERROR_MESSAGE = Pattern.compile("^\\{\"Error\":-\\d+,\"Reason\":\"(.*)\""); + private static final Pattern POST_MESSAGE = Pattern.compile("^\\{\"Error\":null,\"Status\":\"OK\",\"Num\":(\\d+)"); + private static final Pattern THREAD_MESSAGE = Pattern.compile("^\\{\"Error\":null,\"Status\":\"Redirect\",\"Target\":(\\d+)"); + private static final String PROBABLY_BANNED_TEXT = "banned"; + + DvachReplyCall(Site site, Reply reply) { + super(site, reply); + } + + @Override + public void addParameters(MultipartBody.Builder formBuilder) { + formBuilder.addFormDataPart("task", "post"); + formBuilder.addFormDataPart("board", reply.loadable.boardCode); + formBuilder.addFormDataPart("comment", reply.comment); + formBuilder.addFormDataPart("thread", String.valueOf(reply.loadable.no)); + + formBuilder.addFormDataPart("name", reply.name); + formBuilder.addFormDataPart("email", reply.options); + + if (!reply.loadable.isThreadMode() && !TextUtils.isEmpty(reply.subject)) { + formBuilder.addFormDataPart("subject", reply.subject); + } + + + if (reply.captchaResponse != null) { + formBuilder.addFormDataPart("captcha_type", "recaptcha"); + formBuilder.addFormDataPart("captcha_key", Dvach.CAPTCHA_KEY); + + if (reply.captchaChallenge != null) { + formBuilder.addFormDataPart("recaptcha_challenge_field", reply.captchaChallenge); + formBuilder.addFormDataPart("recaptcha_response_field", reply.captchaResponse); + } else { + formBuilder.addFormDataPart("g-recaptcha-response", reply.captchaResponse); + } + } + + if (reply.file != null) { + formBuilder.addFormDataPart("image", reply.fileName, RequestBody.create( + MediaType.parse("application/octet-stream"), reply.file + )); + } + } + + @Override + public void process(Response response, String result) throws IOException { + Matcher errorMessageMatcher = ERROR_MESSAGE.matcher(result); + if (errorMessageMatcher.find()) { + replyResponse.errorMessage = Jsoup.parse(errorMessageMatcher.group(1)).body().text(); + replyResponse.probablyBanned = replyResponse.errorMessage.contains(PROBABLY_BANNED_TEXT); + } else { + replyResponse.posted = true; + Matcher postMessageMatcher = POST_MESSAGE.matcher(result); + if (postMessageMatcher.find()) { + replyResponse.postNo = Integer.parseInt(postMessageMatcher.group(1)); + } else { + Matcher threadMessageMatcher = THREAD_MESSAGE.matcher(result); + if (threadMessageMatcher.find()) { + int threadNo = Integer.parseInt(threadMessageMatcher.group(1)); + replyResponse.threadNo = threadNo; + replyResponse.postNo = threadNo; + } + } + } + } +}