Merge branch 'master' into dev

Conflicts:
	Clover/app/build.gradle
	Clover/app/proguard.cfg
	Clover/app/src/main/java/org/floens/chan/ChanApplication.java
	Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java
	Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java
	Clover/app/src/main/java/org/floens/chan/utils/FileCache.java
filtering
Floens 10 years ago
commit 8036a42eba
  1. 16
      CHANGES.txt
  2. 8
      Clover/app/build.gradle
  3. 6
      Clover/app/proguard.cfg
  4. 72
      Clover/app/src/main/assets/html/licenses.html
  5. 27
      Clover/app/src/main/java/org/floens/chan/ChanApplication.java
  6. 8
      Clover/app/src/main/java/org/floens/chan/chan/ChanUrls.java
  7. 58
      Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java
  8. 65
      Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java
  9. 41
      Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java
  10. 250
      Clover/app/src/main/java/org/floens/chan/utils/FileCache.java
  11. 4
      Clover/app/src/main/java/org/floens/chan/utils/ImageSaver.java
  12. 1
      Clover/app/src/main/res/values/strings.xml
  13. 110
      docs/gcaptcha.txt

@ -1,3 +1,19 @@
New in 1.2.9 (2015-02-06)
- More captcha fixing, should be easier now.
New in 1.2.8 (2015-02-06)
- Fix captcha not working
New in 1.2.7 (2015-01-31)
- Fix some image loading issues
New in 1.2.6 (2015-01-28)
- Fix for captcha image not loading
New in 1.2.5 (2014-12-11) New in 1.2.5 (2014-12-11)
- Fix 4chan pass - Fix 4chan pass

@ -2,14 +2,14 @@ apply plugin: 'com.android.application'
android { android {
compileSdkVersion 21 compileSdkVersion 21
buildToolsVersion "21.1.1" buildToolsVersion "21.1.2"
defaultConfig { defaultConfig {
minSdkVersion 14 minSdkVersion 14
targetSdkVersion 21 targetSdkVersion 21
versionName "v1.2.5" versionName "v1.2.9"
versionCode 43 versionCode 48
} }
compileOptions { compileOptions {
@ -78,8 +78,8 @@ dependencies {
compile 'org.jsoup:jsoup:1.8.1' compile 'org.jsoup:jsoup:1.8.1'
compile 'com.j256.ormlite:ormlite-core:4.48' compile 'com.j256.ormlite:ormlite-core:4.48'
compile 'com.j256.ormlite:ormlite-android:4.48' compile 'com.j256.ormlite:ormlite-android:4.48'
compile 'com.koushikdutta.ion:ion:2.0.1'
compile 'pl.droidsonroids.gif:android-gif-drawable:1.1.0' compile 'pl.droidsonroids.gif:android-gif-drawable:1.1.0'
compile 'com.squareup.okhttp:okhttp:2.2.0'
compile files('libs/httpclientandroidlib-1.2.1.jar') compile files('libs/httpclientandroidlib-1.2.1.jar')
} }

@ -131,5 +131,7 @@
public *; public *;
} }
-keep public class pl.droidsonroids.gif.GifIOException{<init>(int);} -keep public class pl.droidsonroids.gif.GifIOException{*;}
-keep class pl.droidsonroids.gif.GifInfoHandle{<init>(long,int,int,int);}
-dontwarn java.nio.**
-dontwarn org.codehaus.mojo.**

@ -38,34 +38,17 @@ limitations under the License.
</pre> </pre>
<br> <br>
<h3>Jsoup</h3> <h3>subsampling-scale-image-view</h3>
<pre> <a href="https://github.com/davemorrissey/subsampling-scale-image-view">https://github.com/davemorrissey/subsampling-scale-image-view</a>
<code>
The jsoup code-base (include source and compiled packages) are distributed under the open source MIT license as described below.
The MIT License
Copyright © 2009 - 2013 Jonathan Hedley (jonathan@hedley.net)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
</code>
</pre>
<br>
<h3>AndroidFloatLabel from Weddingparty</h3>
<a href="https://github.com/weddingparty/AndroidFloatLabel">https://github.com/weddingparty/AndroidFloatLabel</a>
<pre> <pre>
<code> <code>
Copyright (C) 2014 AndroidFloatLabel team Copyright 2014 David Morrissey
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
@ -76,17 +59,14 @@ limitations under the License.
</pre> </pre>
<br> <br>
<h3>This software includes several Android classes from DashClock</h3> <h3>Square okhttp</h3>
<a href="https://code.google.com/p/dashclock/">https://code.google.com/p/dashclock/</a>
<pre> <pre>
<code> <code>
Copyright 2013 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
@ -97,41 +77,34 @@ limitations under the License.
</pre> </pre>
<br> <br>
<h3>DragSortListView</h3> <h3>Jsoup</h3>
<a href="https://github.com/bauerca/drag-sort-listview">https://github.com/bauerca/drag-sort-listview</a>
<pre> <pre>
<code> <code>
A subclass of the Android ListView component that enables drag The jsoup code-base (include source and compiled packages) are distributed under the open source MIT license as described below.
and drop re-ordering of list items.
Copyright 2012 Carl Bauer The MIT License
Copyright © 2009 - 2013 Jonathan Hedley (jonathan@hedley.net)
Licensed under the Apache License, Version 2.0 (the "License"); Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Unless required by applicable law or agreed to in writing, software THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
</code> </code>
</pre> </pre>
<br> <br>
<h3>Ion</h3> <h3>This software includes several Android classes from DashClock</h3>
<a href="https://github.com/koush/ion">https://github.com/koush/ion</a> <a href="https://code.google.com/p/dashclock/">https://code.google.com/p/dashclock/</a>
<pre> <pre>
<code> <code>
Copyright 2013 Koushik Dutta Copyright 2013 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
@ -142,17 +115,20 @@ limitations under the License.
</pre> </pre>
<br> <br>
<h3>subsampling-scale-image-view</h3> <h3>DragSortListView</h3>
<a href="https://github.com/davemorrissey/subsampling-scale-image-view">https://github.com/davemorrissey/subsampling-scale-image-view</a> <a href="https://github.com/bauerca/drag-sort-listview">https://github.com/bauerca/drag-sort-listview</a>
<pre> <pre>
<code> <code>
Copyright 2014 David Morrissey A subclass of the Android ListView component that enables drag
and drop re-ordering of list items.
Copyright 2012 Carl Bauer
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,

@ -24,9 +24,6 @@ import android.view.ViewConfiguration;
import com.android.volley.RequestQueue; import com.android.volley.RequestQueue;
import com.android.volley.toolbox.ImageLoader; import com.android.volley.toolbox.ImageLoader;
import com.android.volley.toolbox.Volley; import com.android.volley.toolbox.Volley;
import com.koushikdutta.ion.Ion;
import com.koushikdutta.ion.builder.Builders;
import com.koushikdutta.ion.builder.LoadBuilder;
import org.floens.chan.chan.ChanUrls; import org.floens.chan.chan.ChanUrls;
import org.floens.chan.core.manager.BoardManager; import org.floens.chan.core.manager.BoardManager;
@ -104,10 +101,6 @@ public class ChanApplication extends Application {
return fileCache; return fileCache;
} }
public static LoadBuilder<Builders.Any.B> getIon() {
return Ion.getDefault(getInstance()).build(getInstance());
}
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
@ -135,8 +128,7 @@ public class ChanApplication extends Application {
IconCache.createIcons(this); IconCache.createIcons(this);
Ion.getDefault(getInstance()).getCache().clear(); cleanupOutdated();
Ion.getDefault(getInstance()).getCache().setMaxSize(1 * 1024 * 1024);
File cacheDir = getExternalCacheDir() != null ? getExternalCacheDir() : getCacheDir(); File cacheDir = getExternalCacheDir() != null ? getExternalCacheDir() : getCacheDir();
@ -190,6 +182,23 @@ public class ChanApplication extends Application {
foregroundChangedListeners.remove(listener); foregroundChangedListeners.remove(listener);
} }
private void cleanupOutdated() {
File ionCacheFolder = new File(getCacheDir() + "/ion");
if (ionCacheFolder.exists() && ionCacheFolder.isDirectory()) {
Logger.i(TAG, "Clearing old ion folder");
for (File file : ionCacheFolder.listFiles()) {
if (!file.delete()) {
Logger.i(TAG, "Could not delete old ion file " + file.getName());
}
}
if (!ionCacheFolder.delete()) {
Logger.i(TAG, "Could not delete old ion folder");
} else {
Logger.i(TAG, "Deleted old ion folder");
}
}
}
public static interface ForegroundChangedListener { public static interface ForegroundChangedListener {
public void onForegroundChanged(boolean foreground); public void onForegroundChanged(boolean foreground);
} }

@ -38,12 +38,8 @@ public class ChanUrls {
return scheme + "://a.4cdn.org/" + board + "/thread/" + no + ".json"; return scheme + "://a.4cdn.org/" + board + "/thread/" + no + ".json";
} }
public static String getCaptchaChallengeUrl() { public static String getCaptchaDomain() {
return scheme + "://www.google.com/recaptcha/api/challenge?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc"; return scheme + "://www.google.com/";
}
public static String getCaptchaImageUrl(String challenge) {
return scheme + "://www.google.com/recaptcha/api2/payload?c=" + challenge;
} }
public static String getCaptchaFallback() { public static String getCaptchaFallback() {

@ -19,6 +19,7 @@ package org.floens.chan.core.manager;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.text.TextUtils;
import org.floens.chan.ChanApplication; import org.floens.chan.ChanApplication;
import org.floens.chan.R; import org.floens.chan.R;
@ -31,6 +32,7 @@ import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.Logger; import org.floens.chan.utils.Logger;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import java.io.File; import java.io.File;
@ -60,7 +62,6 @@ import ch.boye.httpclientandroidlib.util.EntityUtils;
public class ReplyManager { public class ReplyManager {
private static final String TAG = "ReplyManager"; private static final String TAG = "ReplyManager";
private static final Pattern challengePattern = Pattern.compile("challenge.?:.?'([\\w-]+)'");
private static final Pattern responsePattern = Pattern.compile("<!-- thread:([0-9]+),no:([0-9]+) -->"); private static final Pattern responsePattern = Pattern.compile("<!-- thread:([0-9]+),no:([0-9]+) -->");
private static final int POST_TIMEOUT = 10000; private static final int POST_TIMEOUT = 10000;
@ -170,22 +171,6 @@ public class ReplyManager {
public abstract void onFileLoading(); public abstract void onFileLoading();
} }
/**
* Get the CAPTCHA challenge hash from an JSON response.
*
* @param total The total response from the server
* @return The pattern, or null when none was found.
*/
public static String getChallenge(String total) {
Matcher matcher = challengePattern.matcher(total);
if (matcher.find() && matcher.groupCount() == 1) {
return matcher.group(1);
} else {
return null;
}
}
public void sendPass(Pass pass, final PassListener listener) { public void sendPass(Pass pass, final PassListener listener) {
Logger.i(TAG, "Sending pass login request"); Logger.i(TAG, "Sending pass login request");
@ -336,6 +321,45 @@ public class ReplyManager {
public String responseData = ""; public String responseData = "";
} }
public void getCaptchaChallenge(final CaptchaChallengeListener listener) {
HttpPost httpPost = new HttpPost(ChanUrls.getCaptchaFallback());
httpPost.addHeader("User-Agent", "Android");
HttpPostSendListener postListener = new HttpPostSendListener() {
@Override
public void onResponse(String responseString, HttpClient client, HttpResponse response) {
if (responseString != null) {
Document document = Jsoup.parseBodyFragment(responseString, ChanUrls.getCaptchaDomain());
Elements images = document.select("div.fbc-challenge img");
String imageUrl = images.first() == null ? "" : images.first().absUrl("src");
Elements inputs = document.select("div.fbc-challenge input");
String challenge = "";
for (Element input : inputs) {
if (input.attr("name").equals("c")) {
challenge = input.attr("value");
break;
}
}
if (!TextUtils.isEmpty(imageUrl) && !TextUtils.isEmpty(challenge)) {
listener.onChallenge(imageUrl, challenge);
return;
}
}
listener.onError();
}
};
sendHttpPost(httpPost, postListener);
}
public interface CaptchaChallengeListener {
public void onChallenge(String imageUrl, String challenge);
public void onError();
}
private void getCaptchaHash(final CaptchaHashListener listener, String challenge, String response) { private void getCaptchaHash(final CaptchaHashListener listener, String challenge, String response) {
HttpPost httpPost = new HttpPost(ChanUrls.getCaptchaFallback()); HttpPost httpPost = new HttpPost(ChanUrls.getCaptchaFallback());

@ -44,21 +44,16 @@ import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import android.widget.ViewFlipper; import android.widget.ViewFlipper;
import com.android.volley.Request.Method;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.NetworkImageView; import com.android.volley.toolbox.NetworkImageView;
import com.android.volley.toolbox.StringRequest;
import org.floens.chan.ChanApplication; import org.floens.chan.ChanApplication;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.chan.ChanUrls;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.core.manager.ReplyManager; import org.floens.chan.core.manager.ReplyManager;
import org.floens.chan.core.manager.ReplyManager.ReplyResponse; import org.floens.chan.core.manager.ReplyManager.ReplyResponse;
import org.floens.chan.core.model.Board; import org.floens.chan.core.model.Board;
import org.floens.chan.core.model.Loadable; import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.model.Reply; import org.floens.chan.core.model.Reply;
import org.floens.chan.core.settings.ChanSettings;
import org.floens.chan.ui.animation.ViewFlipperAnimations; import org.floens.chan.ui.animation.ViewFlipperAnimations;
import org.floens.chan.ui.view.LoadView; import org.floens.chan.ui.view.LoadView;
import org.floens.chan.utils.AndroidUtils; import org.floens.chan.utils.AndroidUtils;
@ -81,6 +76,7 @@ public class ReplyFragment extends DialogFragment {
private final Reply draft = new Reply(); private final Reply draft = new Reply();
private boolean shouldSaveDraft = true; private boolean shouldSaveDraft = true;
private boolean gotInitialCaptcha = false;
private boolean gettingCaptcha = false; private boolean gettingCaptcha = false;
private String captchaChallenge = ""; private String captchaChallenge = "";
@ -210,8 +206,6 @@ public class ReplyFragment extends DialogFragment {
} }
}); });
showCommentCount(); showCommentCount();
getCaptcha();
} else { } else {
Logger.e(TAG, "Loadable in ReplyFragment was null"); Logger.e(TAG, "Loadable in ReplyFragment was null");
closeReply(); closeReply();
@ -275,12 +269,6 @@ public class ReplyFragment extends DialogFragment {
}); });
captchaInput = (TextView) container.findViewById(R.id.reply_captcha); captchaInput = (TextView) container.findViewById(R.id.reply_captcha);
if (ChanSettings.getPassEnabled()) {
((TextView) container.findViewById(R.id.reply_captcha_text)).setText(R.string.pass_using);
container.findViewById(R.id.reply_captcha_container).setVisibility(View.GONE);
container.findViewById(R.id.reply_captcha).setVisibility(View.GONE);
}
cancelButton = (Button) container.findViewById(R.id.reply_cancel); cancelButton = (Button) container.findViewById(R.id.reply_cancel);
cancelButton.setOnClickListener(new OnClickListener() { cancelButton.setOnClickListener(new OnClickListener() {
@Override @Override
@ -320,11 +308,11 @@ public class ReplyFragment extends DialogFragment {
submitButton.setOnClickListener(new OnClickListener() { submitButton.setOnClickListener(new OnClickListener() {
@Override @Override
public void onClick(View view) { public void onClick(View view) {
if (page == 0) { if (page == 1 || ChanSettings.passLoggedIn()) {
flipPage(1);
} else if (page == 1) {
flipPage(2); flipPage(2);
submit(); submit();
} else {
flipPage(1);
} }
} }
}); });
@ -412,12 +400,11 @@ public class ReplyFragment extends DialogFragment {
if (flipBack) { if (flipBack) {
flipper.setInAnimation(ViewFlipperAnimations.BACK_IN); flipper.setInAnimation(ViewFlipperAnimations.BACK_IN);
flipper.setOutAnimation(ViewFlipperAnimations.BACK_OUT); flipper.setOutAnimation(ViewFlipperAnimations.BACK_OUT);
flipper.showPrevious();
} else { } else {
flipper.setInAnimation(ViewFlipperAnimations.NEXT_IN); flipper.setInAnimation(ViewFlipperAnimations.NEXT_IN);
flipper.setOutAnimation(ViewFlipperAnimations.NEXT_OUT); flipper.setOutAnimation(ViewFlipperAnimations.NEXT_OUT);
flipper.showNext();
} }
flipper.setDisplayedChild(position);
if (page == 0) { if (page == 0) {
cancelButton.setText(R.string.cancel); cancelButton.setText(R.string.cancel);
@ -426,6 +413,11 @@ public class ReplyFragment extends DialogFragment {
} else if (page == 2) { } else if (page == 2) {
cancelButton.setText(R.string.close); cancelButton.setText(R.string.close);
} }
if (page == 1 && !gotInitialCaptcha) {
gotInitialCaptcha = true;
getCaptcha();
}
} }
/** /**
@ -529,29 +521,22 @@ public class ReplyFragment extends DialogFragment {
captchaContainer.setView(null); captchaContainer.setView(null);
captchaInput.setText(""); captchaInput.setText("");
String url = ChanUrls.getCaptchaChallengeUrl(); ChanApplication.getReplyManager().getCaptchaChallenge(new ReplyManager.CaptchaChallengeListener() {
ChanApplication.getVolleyRequestQueue().add(new StringRequest(Method.GET, url, new Response.Listener<String>() {
@Override @Override
public void onResponse(String result) { public void onChallenge(String imageUrl, String challenge) {
if (context != null) { gettingCaptcha = false;
String challenge = ReplyManager.getChallenge(result);
if (challenge != null) {
captchaChallenge = challenge;
String imageUrl = ChanUrls.getCaptchaImageUrl(challenge);
NetworkImageView captchaImage = new NetworkImageView(context); if (context != null) {
captchaImage.setImageUrl(imageUrl, ChanApplication.getVolleyImageLoader()); captchaChallenge = challenge;
captchaContainer.setView(captchaImage);
gettingCaptcha = false; NetworkImageView captchaImage = new NetworkImageView(context);
} captchaImage.setImageUrl(imageUrl, ChanApplication.getVolleyImageLoader());
captchaContainer.setView(captchaImage);
} }
} }
}, new Response.ErrorListener() {
@Override @Override
public void onErrorResponse(VolleyError error) { public void onError() {
error.printStackTrace();
gettingCaptcha = false; gettingCaptcha = false;
if (context != null) { if (context != null) {
@ -561,7 +546,7 @@ public class ReplyFragment extends DialogFragment {
captchaContainer.setView(text); captchaContainer.setView(text);
} }
} }
})); });
} }
/** /**
@ -622,7 +607,11 @@ public class ReplyFragment extends DialogFragment {
submitButton.setEnabled(true); submitButton.setEnabled(true);
cancelButton.setEnabled(true); cancelButton.setEnabled(true);
setClosable(true); setClosable(true);
flipPage(1); if (ChanSettings.passLoggedIn()) {
flipPage(0);
} else {
flipPage(1);
}
getCaptcha(); getCaptcha();
captchaInput.setText(""); captchaInput.setText("");
} else if (response.isSuccessful) { } else if (response.isSuccessful) {

@ -33,8 +33,6 @@ import android.widget.VideoView;
import com.android.volley.VolleyError; import com.android.volley.VolleyError;
import com.android.volley.toolbox.ImageLoader.ImageContainer; import com.android.volley.toolbox.ImageLoader.ImageContainer;
import com.koushikdutta.async.future.Future;
import com.koushikdutta.ion.Response;
import org.floens.chan.ChanApplication; import org.floens.chan.ChanApplication;
import org.floens.chan.R; import org.floens.chan.R;
@ -46,6 +44,7 @@ import org.floens.chan.utils.Logger;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.Future;
import pl.droidsonroids.gif.GifDrawable; import pl.droidsonroids.gif.GifDrawable;
import pl.droidsonroids.gif.GifImageView; import pl.droidsonroids.gif.GifImageView;
@ -62,12 +61,11 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
private Mode mode = Mode.UNLOADED; private Mode mode = Mode.UNLOADED;
private boolean thumbnailNeeded = true;
private boolean hasContent = false; private boolean hasContent = false;
private ImageContainer thumbnailRequest; private ImageContainer thumbnailRequest;
private Future<Response<File>> bigImageRequest; private Future bigImageRequest;
private Future<Response<File>> gifRequest; private Future gifRequest;
private Future<Response<File>> videoRequest; private Future videoRequest;
private VideoView videoView; private VideoView videoView;
@ -162,13 +160,6 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
}, getWidth(), getHeight()); }, getWidth(), getHeight());
} }
private void cancelThumbnail() {
if (thumbnailRequest != null) {
thumbnailRequest.cancelRequest();
thumbnailRequest = null;
}
}
public void setBigImage(String imageUrl) { public void setBigImage(String imageUrl) {
if (getWidth() == 0 || getHeight() == 0) { if (getWidth() == 0 || getHeight() == 0) {
Logger.e(TAG, "getWidth() or getHeight() returned 0, not loading big image"); Logger.e(TAG, "getWidth() or getHeight() returned 0, not loading big image");
@ -176,12 +167,11 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
} }
callback.setProgress(this, true); callback.setProgress(this, true);
bigImageRequest = ChanApplication.getFileCache().downloadFile(getContext(), imageUrl, new FileCache.DownloadedCallback() { bigImageRequest = ChanApplication.getFileCache().downloadFile(imageUrl, new FileCache.DownloadedCallback() {
@Override @Override
public void onProgress(long downloaded, long total, boolean done) { public void onProgress(long downloaded, long total, boolean done) {
if (done) { if (done) {
// callback.setLinearProgress(0, 0, true); // callback.setLinearProgress(0, 0, true);
thumbnailNeeded = false;
} else { } else {
callback.setLinearProgress(MultiImageView.this, downloaded, total, false); callback.setLinearProgress(MultiImageView.this, downloaded, total, false);
} }
@ -228,13 +218,6 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
}); });
} }
private void cancelBigImage() {
if (bigImageRequest != null) {
bigImageRequest.cancel();
bigImageRequest = null;
}
}
public void setGif(String gifUrl) { public void setGif(String gifUrl) {
if (getWidth() == 0 || getHeight() == 0) { if (getWidth() == 0 || getHeight() == 0) {
Logger.e(TAG, "getWidth() or getHeight() returned 0, not loading"); Logger.e(TAG, "getWidth() or getHeight() returned 0, not loading");
@ -242,13 +225,12 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
} }
callback.setProgress(this, true); callback.setProgress(this, true);
gifRequest = ChanApplication.getFileCache().downloadFile(getContext(), gifUrl, new FileCache.DownloadedCallback() { gifRequest = ChanApplication.getFileCache().downloadFile(gifUrl, new FileCache.DownloadedCallback() {
@Override @Override
public void onProgress(long downloaded, long total, boolean done) { public void onProgress(long downloaded, long total, boolean done) {
if (done) { if (done) {
callback.setProgress(MultiImageView.this, false); callback.setProgress(MultiImageView.this, false);
callback.setLinearProgress(MultiImageView.this, 0, 0, true); callback.setLinearProgress(MultiImageView.this, 0, 0, true);
thumbnailNeeded = false;
} else { } else {
callback.setLinearProgress(MultiImageView.this, downloaded, total, false); callback.setLinearProgress(MultiImageView.this, downloaded, total, false);
} }
@ -295,13 +277,12 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
public void setVideo(String videoUrl) { public void setVideo(String videoUrl) {
callback.setProgress(this, true); callback.setProgress(this, true);
videoRequest = ChanApplication.getFileCache().downloadFile(getContext(), videoUrl, new FileCache.DownloadedCallback() { videoRequest = ChanApplication.getFileCache().downloadFile(videoUrl, new FileCache.DownloadedCallback() {
@Override @Override
public void onProgress(long downloaded, long total, boolean done) { public void onProgress(long downloaded, long total, boolean done) {
if (done) { if (done) {
callback.setProgress(MultiImageView.this, false); callback.setProgress(MultiImageView.this, false);
callback.setLinearProgress(MultiImageView.this, 0, 0, true); callback.setLinearProgress(MultiImageView.this, 0, 0, true);
thumbnailNeeded = false;
} else { } else {
callback.setLinearProgress(MultiImageView.this, downloaded, total, false); callback.setLinearProgress(MultiImageView.this, downloaded, total, false);
} }
@ -381,8 +362,8 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
} }
public void onNotFoundError() { public void onNotFoundError() {
Toast.makeText(getContext(), R.string.image_not_found, Toast.LENGTH_LONG).show();
callback.setProgress(this, false); callback.setProgress(this, false);
Toast.makeText(getContext(), R.string.image_not_found, Toast.LENGTH_SHORT).show();
} }
public void onOutOfMemoryError() { public void onOutOfMemoryError() {
@ -392,13 +373,13 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
public void cancelLoad() { public void cancelLoad() {
if (bigImageRequest != null) { if (bigImageRequest != null) {
bigImageRequest.cancel(); bigImageRequest.cancel(true);
} }
if (gifRequest != null) { if (gifRequest != null) {
gifRequest.cancel(); gifRequest.cancel(true);
} }
if (videoRequest != null) { if (videoRequest != null) {
videoRequest.cancel(); videoRequest.cancel(true);
} }
} }

@ -17,22 +17,34 @@
*/ */
package org.floens.chan.utils; package org.floens.chan.utils;
import android.content.Context;
import android.util.Log; import android.util.Log;
import com.koushikdutta.async.future.Future; import com.squareup.okhttp.Call;
import com.koushikdutta.async.future.FutureCallback; import com.squareup.okhttp.OkHttpClient;
import com.koushikdutta.ion.ProgressCallback; import com.squareup.okhttp.Request;
import com.koushikdutta.ion.Response; import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;
import org.floens.chan.ChanApplication; import com.squareup.okhttp.internal.Util;
import java.io.BufferedOutputStream;
import java.io.Closeable;
import java.io.File; import java.io.File;
import java.util.concurrent.CancellationException; import java.io.FileOutputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import okio.BufferedSource;
public class FileCache { public class FileCache {
private static final String TAG = "FileCache"; private static final String TAG = "FileCache";
private static final ExecutorService executor = Executors.newFixedThreadPool(2);
private OkHttpClient httpClient;
private final File directory; private final File directory;
private final long maxSize; private final long maxSize;
@ -42,6 +54,8 @@ public class FileCache {
this.directory = directory; this.directory = directory;
this.maxSize = maxSize; this.maxSize = maxSize;
httpClient = new OkHttpClient();
makeDir(); makeDir();
calculateSize(); calculateSize();
} }
@ -64,57 +78,16 @@ public class FileCache {
return file.delete(); return file.delete();
} }
public Future<Response<File>> downloadFile(Context context, String url, final DownloadedCallback callback) { public Future<?> downloadFile(final String urlString, final DownloadedCallback callback) {
File file = get(url); File file = get(urlString);
if (file.exists()) { if (file.exists()) {
file.setLastModified(Time.get()); file.setLastModified(Time.get());
callback.onProgress(0, 0, true); callback.onProgress(0, 0, true);
callback.onSuccess(file); callback.onSuccess(file);
return null; return null;
} else { } else {
return ChanApplication.getIon() FileCacheDownloader downloader = new FileCacheDownloader(this, urlString, file, callback);
.load(url) return executor.submit(downloader);
.progress(new ProgressCallback() {
@Override
public void onProgress(final long downloaded, final long total) {
AndroidUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
callback.onProgress(downloaded, total, false);
}
});
}
})
.write(file)
.withResponse()
.setCallback(new FutureCallback<Response<File>>() {
@Override
public void onCompleted(Exception e, Response<File> result) {
callback.onProgress(0, 0, true);
if (result != null && result.getHeaders() != null && result.getHeaders().code() / 100 != 2) {
if (result.getResult() != null) {
delete(result.getResult());
}
callback.onFail(true);
return;
}
if (e != null && !(e instanceof CancellationException)) {
e.printStackTrace();
if (result != null && result.getResult() != null) {
delete(result.getResult());
}
callback.onFail(false);
return;
}
if (result != null && result.getResult() != null) {
put(result.getResult());
callback.onSuccess(result.getResult());
}
}
});
} }
} }
@ -180,4 +153,175 @@ public class FileCache {
public void onFail(boolean notFound); public void onFail(boolean notFound);
} }
private static class FileCacheDownloader implements Runnable {
private final FileCache fileCache;
private final String url;
private final File output;
private final DownloadedCallback callback;
private boolean cancelled = false;
private Closeable downloadInput;
private Closeable downloadOutput;
private Call call;
private ResponseBody body;
public FileCacheDownloader(FileCache fileCache, String url, File output, DownloadedCallback callback) {
this.fileCache = fileCache;
this.url = url;
this.output = output;
this.callback = callback;
}
public void run() {
try {
execute();
} catch (InterruptedIOException | InterruptedException e) {
cancelDueToCancellation(e);
} catch (Exception e) {
cancelDueToException(e);
} finally {
finish();
}
}
private void cancelDueToException(Exception e) {
if (cancelled) return;
cancelled = true;
Log.w(TAG, "IOException downloading file", e);
purgeOutput();
post(new Runnable() {
@Override
public void run() {
callback.onProgress(0, 0, true);
callback.onFail(false);
}
});
}
private void cancelDueToHttpError(final int code) {
if (cancelled) return;
cancelled = true;
Log.w(TAG, "Cancel due to http error, code: " + code);
purgeOutput();
post(new Runnable() {
@Override
public void run() {
callback.onProgress(0, 0, true);
callback.onFail(code == 404);
}
});
}
private void cancelDueToCancellation(Exception e) {
if (cancelled) return;
cancelled = true;
Log.d(TAG, "Cancel due to cancellation");
purgeOutput();
// No callback
}
private void success() {
fileCache.put(output);
post(new Runnable() {
@Override
public void run() {
callback.onProgress(0, 0, true);
callback.onSuccess(output);
}
});
call = null;
}
private void finish() {
Util.closeQuietly(downloadInput);
Util.closeQuietly(downloadOutput);
if (call != null) {
call.cancel();
call = null;
}
if (body != null) {
Util.closeQuietly(body);
body = null;
}
}
private void purgeOutput() {
if (output.exists()) {
if (!output.delete()) {
Log.w(TAG, "Could not delete the file in purgeOutput");
}
}
}
private long progressDownloaded;
private long progressTotal;
private boolean progressDone;
private final Runnable progressRunnable = new Runnable() {
@Override
public void run() {
callback.onProgress(progressDownloaded, progressTotal, progressDone);
}
};
private void progress(long downloaded, long total, boolean done) {
progressDownloaded = downloaded;
progressTotal = total;
progressDone = done;
post(progressRunnable);
}
private void post(Runnable runnable) {
AndroidUtils.runOnUiThread(runnable);
}
private void execute() throws Exception {
Request request = new Request.Builder().url(url).build();
call = fileCache.httpClient.newCall(request);
Response response = call.execute();
if (!response.isSuccessful()) {
cancelDueToHttpError(response.code());
return;
}
body = response.body();
long contentLength = body.contentLength();
BufferedSource source = body.source();
OutputStream outputStream = new BufferedOutputStream(new FileOutputStream(output));
downloadInput = source;
downloadOutput = outputStream;
int read;
long total = 0;
long totalLast = 0;
byte[] buffer = new byte[4096];
while ((read = source.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
total += read;
if (total >= totalLast + 16384) {
totalLast = total;
progress(total, contentLength, false);
}
}
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException();
success();
}
}
} }

@ -28,8 +28,6 @@ import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.widget.Toast; import android.widget.Toast;
import com.koushikdutta.async.future.Future;
import org.floens.chan.ChanApplication; import org.floens.chan.ChanApplication;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.core.settings.ChanSettings;
@ -69,7 +67,7 @@ public class ImageSaver {
} }
public void saveImage(final Context context, String imageUrl, final String name, final String extension, final boolean share) { public void saveImage(final Context context, String imageUrl, final String name, final String extension, final boolean share) {
Future<?> ionRequest = ChanApplication.getFileCache().downloadFile(context, imageUrl, new FileCache.DownloadedCallback() { ChanApplication.getFileCache().downloadFile(imageUrl, new FileCache.DownloadedCallback() {
@Override @Override
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public void onProgress(long downloaded, long total, boolean done) { public void onProgress(long downloaded, long total, boolean done) {

@ -372,7 +372,6 @@ Don't have a 4chan Pass?&lt;br>
<string name="pass_error">Connection error</string> <string name="pass_error">Connection error</string>
<string name="pass_summary_enabled">Using 4chan pass</string> <string name="pass_summary_enabled">Using 4chan pass</string>
<string name="pass_summary_disabled">Off</string> <string name="pass_summary_disabled">Off</string>
<string name="pass_using">Using 4chan pass</string>
<string name="download_confirm">%1$s images will be downloaded to %2$s</string> <string name="download_confirm">%1$s images will be downloaded to %2$s</string>

@ -7,25 +7,121 @@ https://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLV
Put this in an iframe and when the user has successfully filled in the captcha the user is told to copy paste the key into the real website field (outside the iframe, g-captcha-response) Put this in an iframe and when the user has successfully filled in the captcha the user is told to copy paste the key into the real website field (outside the iframe, g-captcha-response)
https://www.google.com/recaptcha/api/challenge?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc https://www.google.com/recaptcha/api/challenge?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc
contains the key 'challenge' contains an image and and a field named "c" with the challenge
Now load the image
https://www.google.com/recaptcha/api2/payload?c=CHALLENGE
after the user has solved the image, do a POST to after the user has solved the image, do a POST to
https://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc https://www.google.com/recaptcha/api/fallback?k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc
POST: POST:
c = CHALLENGE c = the challenge given
response = USER_RESPONSE response = text response from the user
You'll have to get hash inside the textarea of div.fbc-verification-token Next if the captcha was solved, get the hash inside the textarea of div.fbc-verification-token
If the textarea isn't present, the captcha wasn't solved correctly you'll reuse the received html.
next send off the reply to 4chan next send off the reply to 4chan
POST: POST:
g-captcha-response: HASH g-captcha-response: HASH
Initial HTML:
<!DOCTYPE HTML>
<html dir="ltr">
<head>
<meta http-equiv='content-type' content='text/html; charset=UTF-8'>
<title>reCAPTCHA-uitdaging</title>
<link rel="stylesheet" href="https://www.gstatic.com/recaptcha/api2/r20150202092117/fallback__ltr.css" type="text/css" charset="utf-8">
</head>
<body>
<div class="fbc">
<div class="fbc-alert"></div>
<div class="fbc-header">
<div class="fbc-logo">
<div class="fbc-logo-img"></div>
<div class="fbc-logo-text">reCAPTCHA</div>
</div>
</div>
<div>
<div class="fbc-challenge">
<form method="POST">
<input type="hidden" name="c" value="03AHJ_VuvGIGhhDoAQAIA8K-vUZHFTAlzF2lAYqmqNIEACc2s6Tu1pZMJScewJo48x0zPzepF1fSgczr9N6TqRoqw-OfTbd4IcAOi7qIQHaTjiftUCI4kpZxRCfmwnJbpeQWl_-SB9Hcg2Jfn-Ri8hnlpHFi5_TRB__Qd5Ni3NoSRP4RBSITkzDB7hQcZN4dZFzGXuB7NJalhTDw5eqwfVre6zIPqxgMW3jkXPTh5C4vAL_NNvNlAXRhMSLBAwxWKV_72fBpr_tWaOOi4z1Jvall4PuYmPi9vj5Ls2A46XKZr85YnJFzaZ4F8ooG5OEv7yM9ZKVByw95rO" />
<div class="fbc-message">
<label for="response">Voer de tekst in</label>
</div>
<div class="fbc-input-field">
<input dir="ltr" type="text" id="response" name="response" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</div>
<div class="fbc-payload">
<img src="/recaptcha/api2/payload?c=03AHJ_VuvGIGhhDoAQAIA8K-vUZHFTAlzF2lAYqmqNIEACc2s6Tu1pZMJScewJo48x0zPzepF1fSgczr9N6TqRoqw-OfTbd4IcAOi7qIQHaTjiftUCI4kpZxRCfmwnJbpeQWl_-SB9Hcg2Jfn-Ri8hnlpHFi5_TRB__Qd5Ni3NoSRP4RBSITkzDB7hQcZN4dZFzGXuB7NJalhTDw5eqwfVre6zIPqxgMW3jkXPTh5C4vAL_NNvNlAXRhMSLBAwxWKV_72fBpr_tWaOOi4z1Jvall4PuYmPi9vj5Ls2A46XKZr85YnJFzaZ4F8ooG5OEv7yM9ZKVByw95rO&amp;k=6Ldp2bsSAAAAAAJ5uyx_lx34lJeEpTLVkP5k04qc" alt="reCAPTCHA-uitdagingsafbeelding" />
</div>
<div class="fbc-button-verify">
<input type="submit" value="Verifiëren" />
</div>
</form>
</div>
<div class="fbc-buttons">
<div class="fbc-button-holder">
<form method="POST" tabindex="-1">
<input type="hidden" name="c" value="03AHJ_VuvGIGhhDoAQAIA8K-vUZHFTAlzF2lAYqmqNIEACc2s6Tu1pZMJScewJo48x0zPzepF1fSgczr9N6TqRoqw-OfTbd4IcAOi7qIQHaTjiftUCI4kpZxRCfmwnJbpeQWl_-SB9Hcg2Jfn-Ri8hnlpHFi5_TRB__Qd5Ni3NoSRP4RBSITkzDB7hQcZN4dZFzGXuB7NJalhTDw5eqwfVre6zIPqxgMW3jkXPTh5C4vAL_NNvNlAXRhMSLBAwxWKV_72fBpr_tWaOOi4z1Jvall4PuYmPi9vj5Ls2A46XKZr85YnJFzaZ4F8ooG5OEv7yM9ZKVByw95rO" />
<input type="hidden" name="reason" value="r" />
<input class="fbc-button-reload fbc-button" type="submit" value="Een nieuwe uitdaging proberen" />
</form>
</div>
<div class="fbc-button-holder">
<form method="POST" tabindex="-1">
<input type="hidden" name="c" value="03AHJ_VuvGIGhhDoAQAIA8K-vUZHFTAlzF2lAYqmqNIEACc2s6Tu1pZMJScewJo48x0zPzepF1fSgczr9N6TqRoqw-OfTbd4IcAOi7qIQHaTjiftUCI4kpZxRCfmwnJbpeQWl_-SB9Hcg2Jfn-Ri8hnlpHFi5_TRB__Qd5Ni3NoSRP4RBSITkzDB7hQcZN4dZFzGXuB7NJalhTDw5eqwfVre6zIPqxgMW3jkXPTh5C4vAL_NNvNlAXRhMSLBAwxWKV_72fBpr_tWaOOi4z1Jvall4PuYmPi9vj5Ls2A46XKZr85YnJFzaZ4F8ooG5OEv7yM9ZKVByw95rO" />
<input type="hidden" name="reason" value="a" />
<input class="fbc-button-audio fbc-button" type="submit" value="Een audio-uitdaging proberen" />
</form>
</div>
</div>
</div>
<div class="fbc-separator"></div>
<div class="fbc-paste-area">
<div class="fbc-privacy">
<a href="https://www.google.com/intl/nl/policies/privacy/" target="_blank">Privacy</a> - <a href="https://www.google.com/intl/nl/policies/terms/" target="_blank">Voorwaarden</a>
</div>
</div>
</div>
</body>
</html>
Copy-paste code HTML:
<!DOCTYPE HTML>
<html dir="ltr">
<head>
<meta http-equiv='content-type' content='text/html; charset=UTF-8'>
<title>reCAPTCHA-uitdaging</title>
<link rel="stylesheet" href="https://www.gstatic.com/recaptcha/api2/r20150202092117/fallback__ltr.css" type="text/css" charset="utf-8">
</head>
<body>
<div class="fbc">
<div class="fbc-alert"></div>
<div class="fbc-header">
<div class="fbc-logo">
<div class="fbc-logo-img"></div>
<div class="fbc-logo-text">reCAPTCHA</div>
</div>
</div>
<div class="fbc-success">
<div class="fbc-message">Kopieëer deze code</div>
<div class="fbc-verification-token">
<textarea dir="ltr" readonly onclick="this.select()">
03AHJ_Vuvppg-3fh3FZzsT5ArpJD66n9npxpdMArjhFJTH9F_l0-Yp4hQ8LNCDvULQL1hM-WsiMotodqbXN1XDYtmwS18KHmCbLWJEK8UTw4nXKUouTQhKqBK_smQtUPo4nO8vb5VWgFEDjutq3rajk_pwF-TeeXovo3l7prXLHGkZZ6751kGoys3uf9v6QZkANtvVoFtzMvLQ1aOjHB5EftJ7baw6ZFP9GfB6J6r5ANqAvtDrt0ZD7myVIMbYes24QuMOOhl4x8_g
</textarea>
</div>
<div class="fbc-message">Deze code is 2 minuten geldig</div>
</div>
<div class="fbc-separator"></div>
<div class="fbc-message fbc-paste">Plak de code hier</div>
<div class="fbc-paste-area">
<div class="fbc-privacy">
<a href="https://www.google.com/intl/nl/policies/privacy/" target="_blank">Privacy</a> - <a href="https://www.google.com/intl/nl/policies/terms/" target="_blank">Voorwaarden</a>
</div>
</div>
</div>
</body>
</html>

Loading…
Cancel
Save