diff --git a/.travis.yml b/.travis.yml index b2329902..e953821d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ android: - platform-tools - tools - extra-android-m2repository - - build-tools-24.0.3 - - android-24 + - build-tools-25.0.2 + - android-25 script: cd Clover && ./gradlew build --console plain -x lint diff --git a/CHANGES.txt b/CHANGES.txt index eaeef830..40a7e911 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,17 @@ +New in 2.3.1 (2017-07-15) +- Better link detection. +- More themes. +- Fix image loading taking a long time if the cache is full. +- Fix crashing bug related to the status view. +- Bug fixes. + + +New in 2.3.0 (2017-03-19) +- Added update checker to notify of new releases. +- Force new captcha for thread making. +- Bug fixes. + + New in 2.2.0 (2016-10-03) - Add sliding of threads back. - Rewrote thread watching, it's more stable and works correctly with doze now. diff --git a/Clover/app/build.gradle b/Clover/app/build.gradle index cd0bb4b7..2946d886 100644 --- a/Clover/app/build.gradle +++ b/Clover/app/build.gradle @@ -1,17 +1,30 @@ apply plugin: 'com.android.application' +/** + * Gets the version name from the latest Git tag + */ +def getCommitHash = { -> + def stdout = new ByteArrayOutputStream() + exec { + commandLine 'git', 'rev-parse', '--short', 'HEAD' + standardOutput = stdout + } + return "-" + stdout.toString().trim() +} + android { - compileSdkVersion 24 + compileSdkVersion 25 // update the travis config when changing this - buildToolsVersion '24.0.3' + buildToolsVersion '25.0.2' defaultConfig { - applicationId "org.floens.chan" minSdkVersion 15 - targetSdkVersion 24 + targetSdkVersion 25 - versionName "v2.2.0" - versionCode 56 + versionName "v2.3.1" + // of the format XXYYZZ, where XX is major, YY is minor, ZZ is patch + // (watch out for octal notation, never start with a 0) + versionCode 20301 } compileOptions { @@ -54,6 +67,32 @@ android { is.close() } + defaultPublishConfig "default" + productFlavors { + // The app name refers to the name as displayed on the launcher. + // the flavor name is appended to the name in the settings. + "default" { + applicationId "org.floens.chan" + resValue "string", "app_name", "Clover" + resValue "string", "app_flavor_name", "" + buildConfigField "String", "UPDATE_API_ENDPOINT", "\"https://floens.github.io/Clover/api/update\"" + } + + dev { + applicationId "org.floens.chan.dev" + resValue "string", "app_name", "Clover dev" + resValue "string", "app_flavor_name", "" + buildConfigField "String", "UPDATE_API_ENDPOINT", "\"\"" + } + + fdroid { + applicationId "org.floens.chan" + resValue "string", "app_name", "Clover" + resValue "string", "app_flavor_name", "F-Droid" + buildConfigField "String", "UPDATE_API_ENDPOINT", "\"https://floens.github.io/Clover/api/update\"" + } + } + buildTypes { release { if (doSign) { @@ -64,33 +103,25 @@ android { } debug { - versionNameSuffix " Debug" + versionNameSuffix getCommitHash() // minifyEnabled true // proguardFiles 'proguard.cfg' } } - productFlavors { - normal { - applicationId = "org.floens.chan" - resValue "string", "app_name", "Clover" - resValue "string", "app_flavor_name", "" - } - } - sourceSets { beta.java.srcDirs = ['src/release/java'] } } dependencies { - compile 'com.android.support:support-v13:24.2.1' - compile 'com.android.support:appcompat-v7:24.2.1' - compile 'com.android.support:recyclerview-v7:24.2.1' - compile 'com.android.support:cardview-v7:24.2.1' - compile 'com.android.support:support-annotations:24.2.1' - compile 'com.android.support:design:24.2.1' - compile 'com.android.support:customtabs:24.2.1' + compile 'com.android.support:support-v13:25.3.1' + compile 'com.android.support:appcompat-v7:25.3.1' + compile 'com.android.support:recyclerview-v7:25.3.1' + compile 'com.android.support:cardview-v7:25.3.1' + compile 'com.android.support:support-annotations:25.3.1' + compile 'com.android.support:design:25.3.1' + compile 'com.android.support:customtabs:25.3.1' compile 'org.jsoup:jsoup:1.9.2' compile 'com.j256.ormlite:ormlite-core:4.48' @@ -99,6 +130,9 @@ dependencies { compile 'com.davemorrissey.labs:subsampling-scale-image-view:3.5.0' compile 'com.squareup.okhttp3:okhttp:3.4.1' compile 'de.greenrobot:eventbus:2.4.0' + compile 'com.google.dagger:dagger:2.2' annotationProcessor 'com.google.dagger:dagger-compiler:2.2' + + compile 'org.nibor.autolink:autolink:0.6.0' } diff --git a/Clover/app/src/main/AndroidManifest.xml b/Clover/app/src/main/AndroidManifest.xml index 52e330fb..b311a3cc 100644 --- a/Clover/app/src/main/AndroidManifest.xml +++ b/Clover/app/src/main/AndroidManifest.xml @@ -76,8 +76,6 @@ along with this program. If not, see . - - diff --git a/Clover/app/src/main/assets/html/licenses.html b/Clover/app/src/main/assets/html/licenses.html index f7726300..a0ae3211 100644 --- a/Clover/app/src/main/assets/html/licenses.html +++ b/Clover/app/src/main/assets/html/licenses.html @@ -194,5 +194,33 @@ The GIFLIB distribution is Copyright (c) 1997 Eric S. Raymond
+

autolink-java

+ https://github.com/robinst/autolink-java +
+        
+The MIT License (MIT)
+
+Copyright (c) 2015 Robin Stocker
+
+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.
+        
+    
+
diff --git a/Clover/app/src/main/java/org/floens/chan/chan/ChanParser.java b/Clover/app/src/main/java/org/floens/chan/chan/ChanParser.java index e7a502a9..943225e0 100644 --- a/Clover/app/src/main/java/org/floens/chan/chan/ChanParser.java +++ b/Clover/app/src/main/java/org/floens/chan/chan/ChanParser.java @@ -46,8 +46,12 @@ import org.jsoup.parser.Parser; import org.jsoup.select.Elements; import org.jsoup.select.NodeTraversor; import org.jsoup.select.NodeVisitor; +import org.nibor.autolink.LinkExtractor; +import org.nibor.autolink.LinkSpan; +import org.nibor.autolink.LinkType; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Set; import java.util.regex.Matcher; @@ -68,6 +72,8 @@ public class ChanParser { @Inject DatabaseManager databaseManager; + private final LinkExtractor linkExtractor = LinkExtractor.builder().linkTypes(EnumSet.of(LinkType.URL)).build(); + @Inject public ChanParser() { } @@ -457,40 +463,16 @@ public class ChanParser { } private void detectLinks(Theme theme, Post.Builder post, String text, SpannableString spannable) { - int startPos = 0; - int endPos; - while (true) { - startPos = text.indexOf("://", startPos); - if (startPos < 0) break; - - // go back to the first space - while (startPos > 0 && !isWhitespace(text.charAt(startPos - 1))) { - startPos--; - } - - // find the last non whitespace character - endPos = startPos; - while (endPos < text.length() - 1 && !isWhitespace(text.charAt(endPos + 1))) { - endPos++; - } - - // one past - endPos++; - - String linkString = text.substring(startPos, endPos); - - PostLinkable pl = new PostLinkable(theme, linkString, linkString, PostLinkable.Type.LINK); - spannable.setSpan(pl, startPos, endPos, 0); + // use autolink-java lib to detect links + final Iterable links = linkExtractor.extractLinks(text); + for(final LinkSpan link : links) { + final String linkText = text.substring(link.getBeginIndex(), link.getEndIndex()); + final PostLinkable pl = new PostLinkable(theme, linkText, linkText, PostLinkable.Type.LINK); + spannable.setSpan(pl, link.getBeginIndex(), link.getEndIndex(), 0); post.addLinkable(pl); - - startPos = endPos; } } - private boolean isWhitespace(char c) { - return Character.isWhitespace(c) || c == '>'; // consider > as a link separator - } - // Below code taken from org.jsoup.nodes.Element.text(), but it preserves
private String getNodeText(Element node) { final StringBuilder accum = new StringBuilder(); diff --git a/Clover/app/src/main/java/org/floens/chan/chan/ImageSearch.java b/Clover/app/src/main/java/org/floens/chan/chan/ImageSearch.java index 82ab08c1..315857cf 100644 --- a/Clover/app/src/main/java/org/floens/chan/chan/ImageSearch.java +++ b/Clover/app/src/main/java/org/floens/chan/chan/ImageSearch.java @@ -85,5 +85,33 @@ public abstract class ImageSearch { return "http://tineye.com/search/?url=" + imageUrl; } }); + + engines.add(new ImageSearch() { + public int getId() { + return 4; + } + + public String getName() { + return "WAIT"; + } + + public String getUrl(String imageUrl) { + return "https://whatanime.ga/?url=" + imageUrl; + } + }); + + engines.add(new ImageSearch() { + public int getId() { + return 5; + } + + public String getName() { + return "Yandex"; + } + + public String getUrl(String imageUrl) { + return "https://www.yandex.com/images/search?rpt=imageview&img_url=" + imageUrl; + } + }); } } diff --git a/Clover/app/src/main/java/org/floens/chan/core/cache/FileCache.java b/Clover/app/src/main/java/org/floens/chan/core/cache/FileCache.java index a4aee94a..541b4dbc 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/cache/FileCache.java +++ b/Clover/app/src/main/java/org/floens/chan/core/cache/FileCache.java @@ -29,6 +29,7 @@ import java.io.FileOutputStream; import java.io.InterruptedIOException; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutorService; @@ -36,6 +37,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import okhttp3.Call; import okhttp3.OkHttpClient; @@ -58,7 +60,8 @@ public class FileCache { private final File directory; private final long maxSize; - private long size; + private AtomicLong size = new AtomicLong(); + private AtomicBoolean trimRunning = new AtomicBoolean(false); private List downloaders = new ArrayList<>(); @@ -75,16 +78,8 @@ public class FileCache { .protocols(Collections.singletonList(Protocol.HTTP_1_1)) .build(); - makeDir(); - calculateSize(); - } - - public void logStats() { - Logger.i(TAG, "Cache size = " + size + "/" + maxSize); - Logger.i(TAG, "downloaders.size() = " + downloaders.size()); - for (FileCacheDownloader downloader : downloaders) { - Logger.i(TAG, "url = " + downloader.getUrl() + " cancelled = " + downloader.cancelled); - } + createDirectories(); + recalculateSize(); } public void clearCache() { @@ -100,7 +95,7 @@ public class FileCache { } } } - calculateSize(); + recalculateSize(); } /** @@ -151,76 +146,99 @@ public class FileCache { } public File get(String key) { - makeDir(); + createDirectories(); return new File(directory, Integer.toString(key.hashCode())); } - private void put(File file) { - size += file.length(); - - trim(); - } - - private boolean delete(File file) { - size -= file.length(); - - return file.delete(); - } - - private void makeDir() { + private void createDirectories() { if (!directory.exists()) { if (!directory.mkdirs()) { Logger.e(TAG, "Unable to create file cache dir " + directory.getAbsolutePath()); } else { - calculateSize(); + recalculateSize(); } } } + private void fileWasAdded(File file) { + long adjustedSize = size.addAndGet(file.length()); + + if (adjustedSize > maxSize && trimRunning.compareAndSet(false, true)) { + executor.submit(new Runnable() { + @Override + public void run() { + try { + trim(); + } catch (Exception e) { + Logger.e(TAG, "Error trimming", e); + } finally { + trimRunning.set(false); + } + } + }); + } + } + + // Called on a background thread private void trim() { + File[] directoryFiles = directory.listFiles(); + + // Don't try to trim empty directories or just one image in it. + if (directoryFiles == null || directoryFiles.length <= 1) { + return; + } + + List files = new ArrayList<>(Arrays.asList(directoryFiles)); + + int trimmed = 0; + long workingSize = size.get(); int tries = 0; - while (size > maxSize && tries++ < TRIM_TRIES) { - File[] files = directory.listFiles(); - if (files == null || files.length <= 1) { - break; - } - long age = Long.MAX_VALUE; - long last; - File oldest = null; + while (workingSize > maxSize && tries++ < TRIM_TRIES) { + // Find the oldest file + long oldest = Long.MAX_VALUE; + File oldestFile = null; for (File file : files) { - last = file.lastModified(); - if (last < age && last != 0L) { - age = last; - oldest = file; + long modified = file.lastModified(); + if (modified != 0L && modified < oldest) { + oldest = modified; + oldestFile = file; } } - if (oldest == null) { - Logger.e(TAG, "No files to trim"); - break; - } else { - Logger.d(TAG, "Deleting " + oldest.getAbsolutePath()); - if (!delete(oldest)) { - Logger.e(TAG, "Cannot delete cache file while trimming"); - calculateSize(); + if (oldestFile != null) { + Logger.d(TAG, "Delete for trim" + oldestFile.getAbsolutePath()); + workingSize -= oldestFile.length(); + trimmed++; + files.remove(oldestFile); + + if (!oldestFile.delete()) { + Logger.e(TAG, "Failed to delete cache file for trim"); break; } + } else { + Logger.e(TAG, "No files to trim"); + break; } + } - calculateSize(); + if (trimmed > 0) { + recalculateSize(); } } - private void calculateSize() { - size = 0; + // Called on a background thread + private void recalculateSize() { + long calculatedSize = 0; File[] files = directory.listFiles(); if (files != null) { for (File file : files) { - size += file.length(); + calculatedSize += file.length(); } } + + size.set(calculatedSize); } private void removeFromDownloaders(FileCacheDownloader downloader) { @@ -365,7 +383,7 @@ public class FileCache { post(new Runnable() { @Override public void run() { - fileCache.put(output); + fileCache.fileWasAdded(output); removeFromDownloadersList(); for (DownloadedCallback callback : callbacks) { callback.onProgress(0, 0, true); diff --git a/Clover/app/src/main/java/org/floens/chan/core/di/ChanGraph.java b/Clover/app/src/main/java/org/floens/chan/core/di/ChanGraph.java index 47d6b325..be9d9870 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/di/ChanGraph.java +++ b/Clover/app/src/main/java/org/floens/chan/core/di/ChanGraph.java @@ -8,18 +8,19 @@ import org.floens.chan.chan.ChanLoader; import org.floens.chan.chan.ChanParser; import org.floens.chan.core.cache.FileCache; import org.floens.chan.core.database.DatabaseManager; -import org.floens.chan.core.manager.ReplyManager; import org.floens.chan.core.manager.BoardManager; import org.floens.chan.core.manager.FilterEngine; +import org.floens.chan.core.manager.ReplyManager; import org.floens.chan.core.manager.WatchManager; -import org.floens.chan.core.site.http.HttpCallManager; -import org.floens.chan.core.site.sites.chan4.Chan4ReaderRequest; import org.floens.chan.core.presenter.ImageViewerPresenter; import org.floens.chan.core.presenter.ReplyPresenter; import org.floens.chan.core.presenter.ThreadPresenter; import org.floens.chan.core.receiver.WatchUpdateReceiver; import org.floens.chan.core.saver.ImageSaveTask; +import org.floens.chan.core.site.http.HttpCallManager; import org.floens.chan.core.site.sites.chan4.Chan4; +import org.floens.chan.core.site.sites.chan4.Chan4ReaderRequest; +import org.floens.chan.core.update.UpdateManager; import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.adapter.DrawerAdapter; import org.floens.chan.ui.adapter.PostsFilter; @@ -124,5 +125,7 @@ public interface ChanGraph { void inject(WatchManager.PinWatcher pinWatcher); + void inject(UpdateManager updateManager); + void inject(Chan4 chan4); } diff --git a/Clover/app/src/main/java/org/floens/chan/core/model/FileItem.java b/Clover/app/src/main/java/org/floens/chan/core/model/FileItem.java deleted file mode 100644 index b6498a42..00000000 --- a/Clover/app/src/main/java/org/floens/chan/core/model/FileItem.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.model; - -import org.floens.chan.core.saver.StorageHelper; - -import java.io.File; - -public class FileItem { - public File file; - - public FileItem(File file) { - this.file = file; - } - - public boolean isFile() { - return file.isFile(); - } - - public boolean isFolder() { - return file.isDirectory(); - } - - public boolean canNavigate() { - return StorageHelper.canNavigate(file); - } - - public boolean canOpen() { - return StorageHelper.canOpen(file); - } -} diff --git a/Clover/app/src/main/java/org/floens/chan/core/model/FileItems.java b/Clover/app/src/main/java/org/floens/chan/core/model/FileItems.java deleted file mode 100644 index 45a97feb..00000000 --- a/Clover/app/src/main/java/org/floens/chan/core/model/FileItems.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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.model; - -import java.io.File; -import java.util.List; - -public class FileItems { - public final File path; - public final List fileItems; - - public final boolean canNavigateUp; - - public FileItems(File path, List fileItems, boolean canNavigateUp) { - this.path = path; - this.fileItems = fileItems; - this.canNavigateUp = canNavigateUp; - } -} diff --git a/Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java b/Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java new file mode 100644 index 00000000..226e5c59 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java @@ -0,0 +1,162 @@ +/* + * 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.net; + + +import android.util.JsonReader; + +import com.android.volley.Response; + +import org.floens.chan.BuildConfig; + +import java.io.IOException; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import okhttp3.HttpUrl; + +public class UpdateApiRequest extends JsonReaderRequest { + public static final String TYPE_UPDATE = "update"; + + private static final int API_VERSION = 1; + + private String forFlavor; + + public UpdateApiRequest(Response.Listener listener, Response.ErrorListener errorListener) { + super(BuildConfig.UPDATE_API_ENDPOINT, listener, errorListener); + forFlavor = BuildConfig.FLAVOR; + } + + @Override + public UpdateApiResponse readJson(JsonReader reader) throws Exception { + reader.beginObject(); + + UpdateApiResponse response = new UpdateApiResponse(); + + int apiVersion; + out: + while (reader.hasNext()) { + switch (reader.nextName()) { + case "api_version": + apiVersion = reader.nextInt(); + + if (apiVersion > API_VERSION) { + response.newerApiVersion = true; + + while (reader.hasNext()) reader.skipValue(); + + break out; + } + + break; + case "messages": + reader.beginArray(); + while (reader.hasNext()) { + response.messages.add(readMessage(reader)); + } + reader.endArray(); + break; + case "check_interval": + response.checkIntervalMs = reader.nextLong(); + break; + default: + reader.skipValue(); + break; + } + } + + reader.endObject(); + + return response; + } + + private UpdateApiMessage readMessage(JsonReader reader) throws IOException { + reader.beginObject(); + + UpdateApiMessage message = new UpdateApiMessage(); + + while (reader.hasNext()) { + switch (reader.nextName()) { + case "type": + message.type = reader.nextString(); + break; + case "code": + message.code = reader.nextInt(); + break; + case "date": + DateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US); + try { + message.date = format.parse(reader.nextString()); + } catch (ParseException ignore) { + } + break; + case "message_html": + message.messageHtml = reader.nextString(); + break; + case "apk": + reader.beginObject(); + while (reader.hasNext()) { + if (reader.nextName().equals(forFlavor)) { + reader.beginObject(); + while (reader.hasNext()) { + switch (reader.nextName()) { + case "url": + message.apkUrl = HttpUrl.parse(reader.nextString()); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + break; + default: + reader.skipValue(); + break; + } + } + + reader.endObject(); + + return message; + } + + public static class UpdateApiResponse { + public boolean newerApiVersion; + public List messages = new ArrayList<>(); + public long checkIntervalMs; + } + + public static class UpdateApiMessage { + public String type; + public int code; + public Date date; + public String messageHtml; + public HttpUrl apkUrl; + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java b/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java index 6547c234..6fd349ec 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java +++ b/Clover/app/src/main/java/org/floens/chan/core/presenter/ReplyPresenter.java @@ -415,6 +415,8 @@ public class ReplyPresenter implements CaptchaCallback, ImagePickDelegate.ImageP void setPage(Page page, boolean animate); + void setCaptchaVersion(boolean newCaptcha); + void initCaptcha(String baseUrl, String siteKey, CaptchaCallback callback); void resetCaptcha(); diff --git a/Clover/app/src/main/java/org/floens/chan/core/presenter/ThreadPresenter.java b/Clover/app/src/main/java/org/floens/chan/core/presenter/ThreadPresenter.java index 3799fc31..ed712099 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/presenter/ThreadPresenter.java +++ b/Clover/app/src/main/java/org/floens/chan/core/presenter/ThreadPresenter.java @@ -585,7 +585,7 @@ public class ThreadPresenter implements ChanLoader.ChanLoaderCallback, PostAdapt @Override public ChanThread getChanThread() { - return chanLoader.getThread(); + return chanLoader == null ? null : chanLoader.getThread(); } @Override diff --git a/Clover/app/src/main/java/org/floens/chan/core/saver/FileWatcher.java b/Clover/app/src/main/java/org/floens/chan/core/saver/FileWatcher.java index 7b54a66d..9fa4cd6a 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/saver/FileWatcher.java +++ b/Clover/app/src/main/java/org/floens/chan/core/saver/FileWatcher.java @@ -17,12 +17,10 @@ */ package org.floens.chan.core.saver; +import android.os.Environment; import android.os.FileObserver; import android.util.Log; -import org.floens.chan.core.model.FileItem; -import org.floens.chan.core.model.FileItems; - import java.io.File; import java.util.ArrayList; import java.util.Collections; @@ -54,6 +52,11 @@ public class FileWatcher { public void initialize() { initialized = true; + + if (!StorageHelper.canNavigate(startingPath)) { + startingPath = Environment.getExternalStorageDirectory(); + } + navigateTo(startingPath); } @@ -126,4 +129,41 @@ public class FileWatcher { public interface FileWatcherCallback { void onFiles(FileItems fileItems); } + + public static class FileItem { + public File file; + + public FileItem(File file) { + this.file = file; + } + + public boolean isFile() { + return file.isFile(); + } + + public boolean isFolder() { + return file.isDirectory(); + } + + public boolean canNavigate() { + return StorageHelper.canNavigate(file); + } + + public boolean canOpen() { + return StorageHelper.canOpen(file); + } + } + + public static class FileItems { + public final File path; + public final List fileItems; + + public final boolean canNavigateUp; + + public FileItems(File path, List fileItems, boolean canNavigateUp) { + this.path = path; + this.fileItems = fileItems; + this.canNavigateUp = canNavigateUp; + } + } } diff --git a/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java b/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java index 321f99fc..e1b0c5ab 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java +++ b/Clover/app/src/main/java/org/floens/chan/core/saver/ImageSaveTask.java @@ -30,11 +30,7 @@ import org.floens.chan.utils.ImageDecoder; import org.floens.chan.utils.Logger; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import javax.inject.Inject; @@ -117,8 +113,11 @@ public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback { try { if (destination.exists()) { onDestination(); + // Manually call postFinished() + postFinished(success); } else { FileCache.FileCacheDownloader fileCacheDownloader = fileCache.downloadFile(postImage.imageUrl.toString(), this); + // If the fileCacheDownloader is null then the destination already existed and onSuccess() has been called. // Wait otherwise for the download to finish to avoid that the next task is immediately executed. if (fileCacheDownloader != null) { @@ -175,8 +174,6 @@ public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback { private boolean copyToDestination(File source) { boolean result = false; - InputStream is = null; - OutputStream os = null; try { File parent = destination.getParentFile(); if (!parent.mkdirs() && !parent.isDirectory()) { @@ -187,16 +184,11 @@ public class ImageSaveTask implements Runnable, FileCache.DownloadedCallback { throw new IOException("Destination file is already a directory"); } - is = new FileInputStream(source); - os = new FileOutputStream(destination); - IOUtils.copy(is, os); + IOUtils.copyFile(source, destination); result = true; } catch (IOException e) { Logger.e(TAG, "Error writing to file", e); - } finally { - IOUtils.closeQuietly(is); - IOUtils.closeQuietly(os); } return result; diff --git a/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java b/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java index a4694bbf..8a2543e4 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java +++ b/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java @@ -23,6 +23,7 @@ import android.text.TextUtils; import org.floens.chan.R; import org.floens.chan.core.manager.WatchManager; +import org.floens.chan.core.update.UpdateManager; import org.floens.chan.ui.adapter.PostsFilter; import org.floens.chan.utils.AndroidUtils; @@ -154,6 +155,9 @@ public class ChanSettings { public static final CounterSetting replyOpenCounter; public static final CounterSetting threadOpenCounter; + public static final LongSetting updateCheckTime; + public static final LongSetting updateCheckInterval; + static { SharedPreferences p = AndroidUtils.getPreferences(); @@ -179,7 +183,7 @@ public class ChanSettings { postDefaultName = new StringSetting(p, "preference_default_name", ""); postPinThread = new BooleanSetting(p, "preference_pin_on_post", false); - postNewCaptcha = new BooleanSetting(p, "preference_new_captcha", false); + postNewCaptcha = new BooleanSetting(p, "preference_new_captcha", true); developer = new BooleanSetting(p, "preference_developer", false); @@ -263,6 +267,9 @@ public class ChanSettings { replyOpenCounter = new CounterSetting(p, "counter_reply_open"); threadOpenCounter = new CounterSetting(p, "counter_thread_open"); + updateCheckTime = new LongSetting(p, "update_check_time", 0L); + updateCheckInterval = new LongSetting(p, "update_check_interval", UpdateManager.DEFAULT_UPDATE_CHECK_INTERVAL_MS); + // Old (but possibly still in some users phone) // preference_board_view_mode default "list" // preference_board_editor_filler default false diff --git a/Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java b/Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java new file mode 100644 index 00000000..c68b88d8 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java @@ -0,0 +1,49 @@ +/* + * 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.settings; + +import android.content.SharedPreferences; + +public class LongSetting extends Setting { + private boolean hasCached = false; + private Long cached; + + public LongSetting(SharedPreferences sharedPreferences, String key, Long def) { + super(sharedPreferences, key, def); + } + + @Override + public Long get() { + if (hasCached) { + return cached; + } else { + cached = sharedPreferences.getLong(key, def); + hasCached = true; + return cached; + } + } + + @Override + public void set(Long value) { + if (!value.equals(get())) { + sharedPreferences.edit().putLong(key, value).apply(); + cached = value; + onValueChanged(); + } + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4.java b/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4.java index d1eecc34..e81ec824 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4.java +++ b/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4.java @@ -151,6 +151,11 @@ public class Chan4 implements Site { b.addPathSegment("country"); b.addPathSegment(arg.get("country_code").toLowerCase(Locale.ENGLISH) + ".gif"); break; + case "troll_country": + b.addPathSegment("country"); + b.addPathSegment("troll"); + b.addPathSegment(arg.get("troll_country_code").toLowerCase(Locale.ENGLISH) + ".gif"); + break; case "since4pass": b.addPathSegment("minileaf.gif"); break; diff --git a/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4ReaderRequest.java b/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4ReaderRequest.java index 2a04e0e8..da6722b1 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4ReaderRequest.java +++ b/Clover/app/src/main/java/org/floens/chan/core/site/sites/chan4/Chan4ReaderRequest.java @@ -370,6 +370,7 @@ public class Chan4ReaderRequest extends JsonReaderRequest { // Country flag String countryCode = null; + String trollCountryCode = null; String countryName = null; // 4chan pass leaf @@ -422,6 +423,9 @@ public class Chan4ReaderRequest extends JsonReaderRequest { case "country": countryCode = reader.nextString(); break; + case "troll_country": + trollCountryCode = reader.nextString(); + break; case "country_name": countryName = reader.nextString(); break; @@ -511,6 +515,13 @@ public class Chan4ReaderRequest extends JsonReaderRequest { builder.addHttpIcon(new PostHttpIcon(countryUrl, countryName)); } + if (trollCountryCode != null && countryName != null) { + Map arg = new HashMap<>(1); + arg.put("troll_country_code", trollCountryCode); + HttpUrl countryUrl = endpoints.icon(builder, "troll_country", arg); + builder.addHttpIcon(new PostHttpIcon(countryUrl, countryName)); + } + if (since4pass != 0) { HttpUrl iconUrl = endpoints.icon(builder, "since4pass", null); builder.addHttpIcon(new PostHttpIcon(iconUrl, String.valueOf(since4pass))); diff --git a/Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java b/Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java new file mode 100644 index 00000000..31c53ac0 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java @@ -0,0 +1,249 @@ +/* + * 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.update; + + +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.os.StrictMode; +import android.text.TextUtils; + +import com.android.volley.RequestQueue; +import com.android.volley.Response; +import com.android.volley.VolleyError; + +import org.floens.chan.BuildConfig; +import org.floens.chan.core.cache.FileCache; +import org.floens.chan.core.net.UpdateApiRequest; +import org.floens.chan.core.settings.ChanSettings; +import org.floens.chan.utils.IOUtils; +import org.floens.chan.utils.Logger; +import org.floens.chan.utils.Time; + +import java.io.File; +import java.io.IOException; + +import javax.inject.Inject; + +import okhttp3.HttpUrl; + +import static org.floens.chan.Chan.getGraph; + +/** + * Calls the update API and downloads and requests installs of APK files. + *

The APK files are downloaded to the public Download directory, and the default APK install + * screen is launched after downloading. + */ +public class UpdateManager { + public static final long DEFAULT_UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 24 * 5; // 5 days + + private static final String TAG = "UpdateManager"; + + private static final String DOWNLOAD_FILE = "Clover_update.apk"; + + @Inject + RequestQueue volleyRequestQueue; + + @Inject + FileCache fileCache; + + private UpdateCallback callback; + + public UpdateManager(UpdateCallback callback) { + getGraph().inject(this); + this.callback = callback; + } + + public boolean isUpdatingAvailable() { + return !TextUtils.isEmpty(BuildConfig.UPDATE_API_ENDPOINT); + } + + public void runUpdateApi(final boolean manual) { + if (!manual) { + long lastUpdateTime = ChanSettings.updateCheckTime.get(); + long interval = ChanSettings.updateCheckInterval.get(); + long now = Time.get(); + long delta = (lastUpdateTime + interval) - now; + if (delta > 0) { + return; + } else { + ChanSettings.updateCheckTime.set(now); + } + } + + Logger.d(TAG, "Calling update API"); + volleyRequestQueue.add(new UpdateApiRequest(new Response.Listener() { + @Override + public void onResponse(UpdateApiRequest.UpdateApiResponse response) { + if (!processUpdateApiResponse(response) && manual) { + callback.onManualCheckNone(); + } + } + }, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + Logger.e(TAG, "Failed to process API call for updating", error); + + if (manual) { + callback.onManualCheckFailed(); + } + } + })); + } + + private boolean processUpdateApiResponse(UpdateApiRequest.UpdateApiResponse response) { + if (response.newerApiVersion) { + Logger.e(TAG, "API endpoint reports a higher API version than we support, aborting update check."); + + // ignore + return false; + } + + if (response.checkIntervalMs != 0) { + ChanSettings.updateCheckInterval.set(response.checkIntervalMs); + } + + for (UpdateApiRequest.UpdateApiMessage message : response.messages) { + if (processUpdateMessage(message)) { + return true; + } + } + + return false; + } + + private boolean processUpdateMessage(UpdateApiRequest.UpdateApiMessage message) { + if (message.code <= BuildConfig.VERSION_CODE) { + Logger.d(TAG, "No newer version available (" + BuildConfig.VERSION_CODE + " >= " + message.code + ")."); + // Our code is newer than the message + return false; + } + + if (message.type.equals(UpdateApiRequest.TYPE_UPDATE)) { + if (message.apkUrl == null) { + Logger.i(TAG, "Update available but none for this build flavor."); + // Not for this flavor, discard. + return false; + } + + Logger.i(TAG, "Update available (" + message.code + ") with url \"" + message.apkUrl + "\"."); + callback.showUpdateAvailableDialog(message); + return true; + } + + return false; + } + + /** + * Install the APK file specified in {@code update}. This methods needs the storage permission. + * + * @param update update with apk details. + */ + public void doUpdate(Update update) { + fileCache.downloadFile(update.apkUrl.toString(), new FileCache.DownloadedCallback() { + @Override + public void onProgress(long downloaded, long total, boolean done) { + if (!done) callback.onUpdateDownloadProgress(downloaded, total); + } + + @Override + public void onSuccess(File file) { + callback.onUpdateDownloadSuccess(); + copyToPublicDirectory(file); + } + + @Override + public void onFail(boolean notFound) { + callback.onUpdateDownloadFailed(); + } + }); + } + + public void retry(Install install) { + installApk(install); + } + + private void copyToPublicDirectory(File cacheFile) { + File out = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), DOWNLOAD_FILE); + try { + IOUtils.copyFile(cacheFile, out); + } catch (IOException e) { + Logger.e(TAG, "requestApkInstall", e); + callback.onUpdateDownloadMoveFailed(); + return; + } + installApk(new Install(out)); + } + + private void installApk(Install install) { + // First open the dialog that asks to retry and calls this method again. + callback.openUpdateRetryDialog(install); + + // Then launch the APK install intent. + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setDataAndType(Uri.fromFile(install.installFile), "application/vnd.android.package-archive"); + + // The installer wants a content scheme from android N and up, + // but I don't feel like implementing a content provider just for this feature. + // Temporary change the strictmode policy while starting the intent. + StrictMode.VmPolicy vmPolicy = StrictMode.getVmPolicy(); + StrictMode.setVmPolicy(StrictMode.VmPolicy.LAX); + + callback.onUpdateOpenInstallScreen(intent); + + StrictMode.setVmPolicy(vmPolicy); + } + + public static class Update { + private HttpUrl apkUrl; + + public Update(HttpUrl apkUrl) { + this.apkUrl = apkUrl; + } + } + + public static class Install { + private File installFile; + + public Install(File installFile) { + this.installFile = installFile; + } + } + + public interface UpdateCallback { + void onManualCheckNone(); + + void onManualCheckFailed(); + + void showUpdateAvailableDialog(UpdateApiRequest.UpdateApiMessage message); + + void onUpdateDownloadProgress(long downloaded, long total); + + void onUpdateDownloadSuccess(); + + void onUpdateDownloadFailed(); + + void onUpdateDownloadMoveFailed(); + + void onUpdateOpenInstallScreen(Intent intent); + + void openUpdateRetryDialog(Install install); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/test/TestActivity.java b/Clover/app/src/main/java/org/floens/chan/test/TestActivity.java deleted file mode 100644 index 5a1e5374..00000000 --- a/Clover/app/src/main/java/org/floens/chan/test/TestActivity.java +++ /dev/null @@ -1,303 +0,0 @@ -/* - * 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.test; - -import android.app.Activity; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.View; -import android.widget.Button; -import android.widget.LinearLayout; - -import org.floens.chan.Chan; -import org.floens.chan.chan.ChanLoader; -import org.floens.chan.core.cache.FileCache; -import org.floens.chan.core.exception.ChanLoaderException; -import org.floens.chan.core.model.ChanThread; -import org.floens.chan.core.model.Loadable; -import org.floens.chan.core.model.Post; -import org.floens.chan.ui.theme.ThemeHelper; -import org.floens.chan.utils.Logger; - -import java.io.File; - -// Poor mans unit testing. -// Move to proper unit testing when the gradle plugin fully supports it. -public class TestActivity extends Activity implements View.OnClickListener { - private static final String TAG = "FileCacheTest"; - private final Handler handler = new Handler(Looper.getMainLooper()); - - private Button clearCache; - private Button stats; - private Button simpleTest; - private Button cacheTest; - private Button timeoutTest; - - private FileCache fileCache; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - ThemeHelper.getInstance().setupContext(this); - - LinearLayout linearLayout = new LinearLayout(this); - linearLayout.setOrientation(LinearLayout.VERTICAL); - - clearCache = new Button(this); - clearCache.setText("Clear cache"); - clearCache.setOnClickListener(this); - linearLayout.addView(clearCache); - - stats = new Button(this); - stats.setText("Stats"); - stats.setOnClickListener(this); - linearLayout.addView(stats); - - simpleTest = new Button(this); - simpleTest.setText("Test download and cancel"); - simpleTest.setOnClickListener(this); - linearLayout.addView(simpleTest); - - cacheTest = new Button(this); - cacheTest.setText("Test cache size"); - cacheTest.setOnClickListener(this); - linearLayout.addView(cacheTest); - - timeoutTest = new Button(this); - timeoutTest.setText("Test multiple parallel"); - timeoutTest.setOnClickListener(this); - linearLayout.addView(timeoutTest); - - setContentView(linearLayout); - - File cacheDir = getExternalCacheDir() != null ? getExternalCacheDir() : getCacheDir(); - File fileCacheDir = new File(cacheDir, "filecache"); - fileCache = new FileCache(fileCacheDir, 50 * 1024 * 1024, Chan.getInstance().getUserAgent()); - } - - @Override - public void onClick(View v) { - if (v == clearCache) { - clearCache(); - } else if (v == stats) { - stats(); - } else if (v == simpleTest) { - testDownloadAndCancel(); - } else if (v == cacheTest) { - testCache(); - } else if (v == timeoutTest) { - testTimeout(); - } - } - - public void clearCache() { - fileCache.clearCache(); - } - - public void stats() { - fileCache.logStats(); - } - - public void testDownloadAndCancel() { - // 1.9MB file of the clover Logger.i(TAG, - final String testImage = "http://a.pomf.se/ndbolc.png"; - final File cacheFile = fileCache.get(testImage); - - Logger.i(TAG, "Downloading " + testImage); - final FileCache.FileCacheDownloader downloader = fileCache.downloadFile(testImage, new FileCache.DownloadedCallback() { - @Override - public void onProgress(long downloaded, long total, boolean done) { - Logger.i(TAG, "onProgress " + downloaded + "/" + total + " " + done); - } - - @Override - public void onSuccess(File file) { - Logger.i(TAG, "onSuccess " + file.exists()); - } - - @Override - public void onFail(boolean notFound) { - Logger.i(TAG, "onFail Cachefile exists() = " + cacheFile.exists()); - } - }); - - handler.postDelayed(new Runnable() { - @Override - public void run() { - fileCache.downloadFile(testImage, new FileCache.DownloadedCallback() { - @Override - public void onProgress(long downloaded, long total, boolean done) { - Logger.i(TAG, "2nd progress " + downloaded + "/" + total); - } - - @Override - public void onSuccess(File file) { - Logger.i(TAG, "2nd onSuccess " + file.exists()); - } - - @Override - public void onFail(boolean notFound) { - Logger.i(TAG, "2nd onFail Cachefile exists() = " + cacheFile.exists()); - } - }); - } - }, 200); - - handler.postDelayed(new Runnable() { - @Override - public void run() { - Logger.i(TAG, "Cancelling download!"); - downloader.cancel(); - } - }, 500); - - handler.postDelayed(new Runnable() { - @Override - public void run() { - Logger.i(TAG, "File exists() = " + cacheFile.exists()); - } - }, 600); - - handler.postDelayed(new Runnable() { - @Override - public void run() { - final File cache404File = fileCache.get(testImage + "404"); - fileCache.downloadFile(testImage + "404", new FileCache.DownloadedCallback() { - @Override - public void onProgress(long downloaded, long total, boolean done) { - Logger.i(TAG, "404 progress " + downloaded + "/" + total + " " + done); - } - - @Override - public void onSuccess(File file) { - Logger.i(TAG, "404 onSuccess " + file.exists()); - } - - @Override - public void onFail(boolean notFound) { - Logger.i(TAG, "404 onFail " + cache404File.exists()); - } - }); - } - }, 1000); - } - - private void testCache() { -// Loadable loadable = Loadable.forCatalog(Sites.defaultSite().boards().boards.get(0)); - Loadable loadable = null; - ChanLoader loader = new ChanLoader(loadable); - loader.addListener(new ChanLoader.ChanLoaderCallback() { - @Override - public void onChanLoaderData(ChanThread result) { - for (Post post : result.posts) { - if (post.image != null) { - final String imageUrl = post.image.imageUrl.toString(); - fileCache.downloadFile(imageUrl, new FileCache.DownloadedCallback() { - @Override - public void onProgress(long downloaded, long total, boolean done) { - Logger.i(TAG, "Progress for " + imageUrl + " " + downloaded + "/" + total + " " + done); - } - - @Override - public void onSuccess(File file) { - Logger.i(TAG, "onSuccess for " + imageUrl + " exists() = " + file.exists()); - } - - @Override - public void onFail(boolean notFound) { - Logger.i(TAG, "onFail for " + imageUrl); - } - }); - } - } - } - - @Override - public void onChanLoaderError(ChanLoaderException error) { - - } - }); - loader.requestData(); - } - - private void testTimeout() { - testTimeoutInner("https://i.4cdn.org/hr/1429923649068.jpg", fileCache, 0); - handler.postDelayed(new Runnable() { - @Override - public void run() { - testTimeoutInner("https://i.4cdn.org/hr/1430058524427.jpg", fileCache, 0); - } - }, 200); - handler.postDelayed(new Runnable() { - @Override - public void run() { - testTimeoutInner("https://i.4cdn.org/hr/1430058627352.jpg", fileCache, 0); - } - }, 400); - handler.postDelayed(new Runnable() { - @Override - public void run() { - testTimeoutInner("https://i.4cdn.org/hr/1430058580015.jpg", fileCache, 0); - } - }, 600); - } - - private void testTimeoutInner(final String url, final FileCache fileCache, final int tries) { - final File cacheFile = fileCache.get(url); - Logger.i(TAG, "Downloading " + url + " try " + tries); - final FileCache.FileCacheDownloader downloader = fileCache.downloadFile(url, new FileCache.DownloadedCallback() { - @Override - public void onProgress(long downloaded, long total, boolean done) { - Logger.i(TAG, "onProgress " + url + " " + downloaded + "/" + total); - } - - @Override - public void onSuccess(File file) { - Logger.i(TAG, "onSuccess " + file.exists()); - } - - @Override - public void onFail(boolean notFound) { - Logger.i(TAG, "onFail Cachefile exists() = " + cacheFile.exists()); - } - }); - - handler.postDelayed(new Runnable() { - @Override - public void run() { - if (downloader == null) { - Logger.i(TAG, "Downloader null, cannot cancel"); - } else { - downloader.cancel(); - handler.postDelayed(new Runnable() { - @Override - public void run() { - if (tries < 10) { - testTimeoutInner(url, fileCache, tries + 1); - } else { - fileCache.logStats(); - } - } - }, 500); - } - } - }, 1000); - } -} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java b/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java index 4a0bbfd1..c440c181 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java @@ -53,7 +53,7 @@ import org.floens.chan.ui.controller.StyledToolbarNavigationController; import org.floens.chan.ui.controller.ThreadSlideController; import org.floens.chan.ui.controller.ViewThreadController; import org.floens.chan.ui.helper.ImagePickDelegate; -import org.floens.chan.ui.helper.PreviousVersionHandler; +import org.floens.chan.ui.helper.VersionHandler; import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.state.ChanState; import org.floens.chan.ui.theme.ThemeHelper; @@ -81,6 +81,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat private ImagePickDelegate imagePickDelegate; private RuntimePermissionsHelper runtimePermissionsHelper; + private VersionHandler versionHandler; @Inject DatabaseManager databaseManager; @@ -100,6 +101,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat imagePickDelegate = new ImagePickDelegate(this); runtimePermissionsHelper = new RuntimePermissionsHelper(this); + versionHandler = new VersionHandler(this, runtimePermissionsHelper); contentView = (ViewGroup) findViewById(android.R.id.content); @@ -171,8 +173,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat browseController.loadDefault(); } - PreviousVersionHandler previousVersionHandler = new PreviousVersionHandler(); - previousVersionHandler.run(this); + versionHandler.run(); } private void setupLayout() { @@ -341,6 +342,10 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat return imagePickDelegate; } + public VersionHandler getVersionHandler() { + return versionHandler; + } + public RuntimePermissionsHelper getRuntimePermissionsHelper() { return runtimePermissionsHelper; } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/adapter/FilesAdapter.java b/Clover/app/src/main/java/org/floens/chan/ui/adapter/FilesAdapter.java index fe0895ef..2d1de87e 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/adapter/FilesAdapter.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/adapter/FilesAdapter.java @@ -28,8 +28,7 @@ import android.widget.ImageView; import android.widget.TextView; import org.floens.chan.R; -import org.floens.chan.core.model.FileItem; -import org.floens.chan.core.model.FileItems; +import org.floens.chan.core.saver.FileWatcher; import static org.floens.chan.utils.AndroidUtils.getAttrColor; @@ -37,20 +36,20 @@ public class FilesAdapter extends RecyclerView.Adapter private static final int ITEM_TYPE_FOLDER = 0; private static final int ITEM_TYPE_FILE = 1; - private FileItem highlightedItem; - private FileItems fileItems; + private FileWatcher.FileItem highlightedItem; + private FileWatcher.FileItems fileItems; private Callback callback; public FilesAdapter(Callback callback) { this.callback = callback; } - public void setFiles(FileItems fileItems) { + public void setFiles(FileWatcher.FileItems fileItems) { this.fileItems = fileItems; notifyDataSetChanged(); } - public void setHighlightedItem(FileItem highlightedItem) { + public void setHighlightedItem(FileWatcher.FileItem highlightedItem) { this.highlightedItem = highlightedItem; } @@ -69,7 +68,7 @@ public class FilesAdapter extends RecyclerView.Adapter case ITEM_TYPE_FOLDER: { boolean isFile = itemViewType == ITEM_TYPE_FILE; - FileItem item = getItem(position); + FileWatcher.FileItem item = getItem(position); FileViewHolder fileViewHolder = ((FileViewHolder) holder); fileViewHolder.text.setText(item.file.getName()); @@ -104,7 +103,7 @@ public class FilesAdapter extends RecyclerView.Adapter @Override public int getItemViewType(int position) { - FileItem item = getItem(position); + FileWatcher.FileItem item = getItem(position); if (item.isFile()) { return ITEM_TYPE_FILE; } else if (item.isFolder()) { @@ -114,11 +113,11 @@ public class FilesAdapter extends RecyclerView.Adapter } } - public FileItem getItem(int position) { + public FileWatcher.FileItem getItem(int position) { return fileItems.fileItems.get(position); } - private void onItemClicked(FileItem fileItem) { + private void onItemClicked(FileWatcher.FileItem fileItem) { callback.onFileItemClicked(fileItem); } @@ -135,12 +134,12 @@ public class FilesAdapter extends RecyclerView.Adapter @Override public void onClick(View v) { - FileItem item = getItem(getAdapterPosition()); + FileWatcher.FileItem item = getItem(getAdapterPosition()); onItemClicked(item); } } public interface Callback { - void onFileItemClicked(FileItem fileItem); + void onFileItemClicked(FileWatcher.FileItem fileItem); } } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/cell/PostCell.java b/Clover/app/src/main/java/org/floens/chan/ui/cell/PostCell.java index 80de3f75..aeb63af3 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/cell/PostCell.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/cell/PostCell.java @@ -410,12 +410,8 @@ public class PostCell extends LinearLayout implements PostCellInterface { icons.apply(); CharSequence commentText; - if (post.comment.length() > COMMENT_MAX_LENGTH_BOARD && !threadMode) { - BreakIterator bi = BreakIterator.getWordInstance(); - bi.setText(post.comment.toString()); - int precedingBoundary = bi.preceding(COMMENT_MAX_LENGTH_BOARD); - // Fallback to old method in case the comment does not have any spaces/individual words - commentText = precedingBoundary > 0 ? post.comment.subSequence(0, precedingBoundary) : post.comment.subSequence(0, COMMENT_MAX_LENGTH_BOARD); + if (!threadMode && post.comment.length() > COMMENT_MAX_LENGTH_BOARD) { + commentText = truncatePostComment(post, COMMENT_MAX_LENGTH_BOARD); } else { commentText = post.comment; } @@ -492,6 +488,15 @@ public class PostCell extends LinearLayout implements PostCellInterface { } } + private CharSequence truncatePostComment(Post post, int maxCommentLength) { + BreakIterator bi = BreakIterator.getWordInstance(); + bi.setText(post.comment.toString()); + int precedingBoundary = bi.following(maxCommentLength); + // Fallback to old method in case the comment does not have any spaces/individual words + CharSequence commentText = precedingBoundary > 0 ? post.comment.subSequence(0, precedingBoundary) : post.comment.subSequence(0, maxCommentLength); + return TextUtils.concat(commentText, "\u2026"); // append ellipsis + } + private static BackgroundColorSpan BACKGROUND_SPAN = new BackgroundColorSpan(0x6633B5E5); /** diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/DeveloperSettingsController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/DeveloperSettingsController.java index 056c5d17..f2d1347c 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/controller/DeveloperSettingsController.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/DeveloperSettingsController.java @@ -58,6 +58,17 @@ public class DeveloperSettingsController extends Controller { LinearLayout wrapper = new LinearLayout(context); wrapper.setOrientation(LinearLayout.VERTICAL); + Button logsButton = new Button(context); + logsButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + navigationController.pushController(new LogsController(context)); + } + }); + logsButton.setText(R.string.settings_open_logs); + + wrapper.addView(logsButton); + Button crashButton = new Button(context); crashButton.setOnClickListener(new View.OnClickListener() { diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/LogsController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/LogsController.java new file mode 100644 index 00000000..4f602070 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/LogsController.java @@ -0,0 +1,107 @@ +/* + * 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.ui.controller; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.view.ViewGroup; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import org.floens.chan.R; +import org.floens.chan.controller.Controller; +import org.floens.chan.ui.toolbar.ToolbarMenu; +import org.floens.chan.ui.toolbar.ToolbarMenuItem; +import org.floens.chan.ui.view.FloatingMenuItem; +import org.floens.chan.utils.AndroidUtils; +import org.floens.chan.utils.IOUtils; +import org.floens.chan.utils.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import static org.floens.chan.utils.AndroidUtils.getAttrColor; + +public class LogsController extends Controller implements ToolbarMenuItem.ToolbarMenuItemCallback { + private static final String TAG = "LogsController"; + + private static final int COPY_ID = 101; + + private TextView logTextView; + + private String logText; + + public LogsController(Context context) { + super(context); + } + + @Override + public void onCreate() { + super.onCreate(); + + navigationItem.setTitle(org.floens.chan.R.string.settings_logs_screen); + + navigationItem.menu = new ToolbarMenu(context); + List items = new ArrayList<>(); + items.add(new FloatingMenuItem(COPY_ID, R.string.settings_logs_copy)); + navigationItem.createOverflow(context, this, items); + + ScrollView container = new ScrollView(context); + container.setBackgroundColor(getAttrColor(context, org.floens.chan.R.attr.backcolor)); + logTextView = new TextView(context); + container.addView(logTextView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + view = container; + + loadLogs(); + } + + @Override + public void onMenuItemClicked(ToolbarMenuItem item) { + } + + @Override + public void onSubMenuItemClicked(ToolbarMenuItem parent, FloatingMenuItem item) { + if ((int) item.getId() == COPY_ID) { + ClipboardManager clipboard = (ClipboardManager) AndroidUtils.getAppContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("Logs", logText); + clipboard.setPrimaryClip(clip); + Toast.makeText(context, R.string.settings_logs_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + } + } + + private void loadLogs() { + Process process; + try { + process = new ProcessBuilder() + .command("logcat", "-d", "-v", "tag") + .start(); + } catch (IOException e) { + Logger.e(TAG, "Error starting logcat", e); + return; + } + + InputStream outputStream = process.getInputStream(); + logText = IOUtils.readString(outputStream); + logTextView.setText(logText); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java index 318570f1..39bbddb4 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java @@ -372,6 +372,15 @@ public class MainSettingsController extends SettingsController implements Toolba } })); + if (((StartActivity) context).getVersionHandler().isUpdatingAvailable()) { + about.add(new LinkSettingView(this, R.string.settings_update_check, 0, new View.OnClickListener() { + @Override + public void onClick(View v) { + ((StartActivity) context).getVersionHandler().manualUpdateCheck(); + } + })); + } + int extraAbouts = context.getResources().getIdentifier("extra_abouts", "array", context.getPackageName()); if (extraAbouts != 0) { String[] abouts = context.getResources().getStringArray(extraAbouts); diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java index 858165b5..1fc04133 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java @@ -19,28 +19,23 @@ package org.floens.chan.ui.controller; import android.Manifest; import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.provider.Settings; import android.support.design.widget.FloatingActionButton; -import android.support.v7.app.AlertDialog; import android.view.View; import org.floens.chan.R; import org.floens.chan.controller.Controller; -import org.floens.chan.core.model.FileItem; -import org.floens.chan.core.model.FileItems; import org.floens.chan.core.saver.FileWatcher; import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.adapter.FilesAdapter; import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.layout.FilesLayout; -import org.floens.chan.utils.AndroidUtils; import java.io.File; +import static org.floens.chan.R.string.save_location_storage_permission_required; +import static org.floens.chan.R.string.save_location_storage_permission_required_title; + public class SaveLocationController extends Controller implements FileWatcher.FileWatcherCallback, FilesAdapter.Callback, FilesLayout.Callback, View.OnClickListener { private static final String TAG = "SaveLocationController"; @@ -51,7 +46,7 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi private boolean gotPermission = false; private FileWatcher fileWatcher; - private FileItems fileItems; + private FileWatcher.FileItems fileItems; public SaveLocationController(Context context) { super(context); @@ -91,7 +86,7 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi } @Override - public void onFiles(FileItems fileItems) { + public void onFiles(FileWatcher.FileItems fileItems) { this.fileItems = fileItems; filesLayout.setFiles(fileItems); } @@ -102,7 +97,7 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi } @Override - public void onFileItemClicked(FileItem fileItem) { + public void onFileItemClicked(FileWatcher.FileItem fileItem) { if (fileItem.canNavigate()) { fileWatcher.navigateTo(fileItem.file); } @@ -121,26 +116,17 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi if (gotPermission) { initialize(); } else { - new AlertDialog.Builder(context) - .setTitle(R.string.write_permission_required_title) - .setMessage(R.string.write_permission_required) - .setCancelable(false) - .setNeutralButton(R.string.write_permission_app_settings, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - requestPermission(); - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:" + context.getPackageName())); - AndroidUtils.openIntent(intent); - } - }) - .setPositiveButton(R.string.write_permission_grant, new DialogInterface.OnClickListener() { + runtimePermissionsHelper.showPermissionRequiredDialog( + context, + context.getString(save_location_storage_permission_required_title), + context.getString(save_location_storage_permission_required), + new RuntimePermissionsHelper.PermissionRequiredDialogCallback() { @Override - public void onClick(DialogInterface dialog, int which) { + public void retryPermissionRequest() { requestPermission(); } - }) - .show(); + } + ); } } }); diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/PostHelper.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/PostHelper.java index c6f8bcbc..fadc64bb 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/helper/PostHelper.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/helper/PostHelper.java @@ -86,7 +86,7 @@ public class PostHelper { } } - private static SimpleDateFormat dateFormat = new SimpleDateFormat("LL/dd/yy(EEE)kk:mm:ss", Locale.US); + private static SimpleDateFormat dateFormat = new SimpleDateFormat("LL/dd/yy(EEE)HH:mm:ss", Locale.US); private static Date tmpDate = new Date(); public static String getLocalDate(Post post) { diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java deleted file mode 100644 index 4a949e87..00000000 --- a/Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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.ui.helper; - -import android.content.Context; -import android.content.DialogInterface; -import android.support.v7.app.AlertDialog; -import android.text.Html; -import android.widget.Button; - -import org.floens.chan.R; -import org.floens.chan.core.settings.ChanSettings; -import org.floens.chan.utils.AndroidUtils; -import org.floens.chan.utils.Logger; - -import java.io.File; - -public class PreviousVersionHandler { - private static final String TAG = "PreviousVersionHandler"; - - /* - * Manifest version code, manifest version name, this version mapping: - * - * 28 = v1.1.2 - * 32 = v1.1.3 - * 36 = v1.2.0 - * 39 = v1.2.1 - * 40 = v1.2.2 - * 41 = v1.2.3 - * 42 = v1.2.4 - * 43 = v1.2.5 - * 44 = v1.2.6 - * 46 = v1.2.7 - * 47 = v1.2.8 - * 48 = v1.2.9 - * 49 = v1.2.10 - * 50 = v1.2.11 - * 51 = v2.0.0 = 1 - * 52 = v2.1.0 = 2 - * 53 = v2.1.1 = 2 - * 54 = v2.1.2 = 2 - * 55 = v2.1.3 = 2 - * 56 = v2.2.0 = 3 - */ - private static final int CURRENT_VERSION = 3; - - public void run(Context context) { - int previous = ChanSettings.previousVersion.get(); - if (previous < CURRENT_VERSION) { - if (previous < 1) { - cleanupOutdatedIonFolder(context); - } - - // Add more previous version checks here - - showMessage(context, CURRENT_VERSION); - - ChanSettings.previousVersion.set(CURRENT_VERSION); - } - } - - private void showMessage(Context context, int version) { - int resource = context.getResources().getIdentifier("previous_version_" + version, "string", context.getPackageName()); - if (resource != 0) { - CharSequence message = Html.fromHtml(context.getString(resource)); - - final AlertDialog dialog = new AlertDialog.Builder(context) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .create(); - dialog.show(); - dialog.setCanceledOnTouchOutside(false); - - final Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE); - button.setEnabled(false); - AndroidUtils.runOnUiThread(new Runnable() { - @Override - public void run() { - dialog.setCanceledOnTouchOutside(true); - button.setEnabled(true); - } - }, 1500); - } - } - - private void cleanupOutdatedIonFolder(Context context) { - Logger.i(TAG, "Cleaning up old ion folder"); - File ionCacheFolder = new File(context.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"); - } - } - } -} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java index da71210d..c665c250 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java @@ -18,10 +18,19 @@ package org.floens.chan.ui.helper; import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; import android.content.pm.PackageManager; +import android.net.Uri; +import android.provider.Settings; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; +import android.support.v7.app.AlertDialog; + +import org.floens.chan.R; +import org.floens.chan.utils.AndroidUtils; import static org.floens.chan.utils.AndroidUtils.getAppContext; @@ -71,6 +80,33 @@ public class RuntimePermissionsHelper { } } + public void showPermissionRequiredDialog(final Context context, String title, String message, final PermissionRequiredDialogCallback callback) { + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setCancelable(false) + .setNeutralButton(R.string.permission_app_settings, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + callback.retryPermissionRequest(); + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + context.getPackageName())); + AndroidUtils.openIntent(intent); + } + }) + .setPositiveButton(R.string.permission_grant, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + callback.retryPermissionRequest(); + } + }) + .show(); + } + + public interface PermissionRequiredDialogCallback { + void retryPermissionRequest(); + } + private class CallbackHolder { private Callback callback; private String permission; diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java new file mode 100644 index 00000000..4c17b1c3 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java @@ -0,0 +1,286 @@ +/* + * 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.ui.helper; + +import android.Manifest; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.support.v7.app.AlertDialog; +import android.text.Html; +import android.text.Spanned; +import android.widget.Button; + +import org.floens.chan.BuildConfig; +import org.floens.chan.R; +import org.floens.chan.core.net.UpdateApiRequest; +import org.floens.chan.core.settings.ChanSettings; +import org.floens.chan.core.update.UpdateManager; +import org.floens.chan.utils.AndroidUtils; +import org.floens.chan.utils.Logger; + +import java.io.File; + +public class VersionHandler implements UpdateManager.UpdateCallback { + private static final String TAG = "VersionHandler"; + + /* + * Manifest version code, manifest version name, this version mapping: + * + * 28 = v1.1.2 + * 32 = v1.1.3 + * 36 = v1.2.0 + * 39 = v1.2.1 + * 40 = v1.2.2 + * 41 = v1.2.3 + * 42 = v1.2.4 + * 43 = v1.2.5 + * 44 = v1.2.6 + * 46 = v1.2.7 + * 47 = v1.2.8 + * 48 = v1.2.9 + * 49 = v1.2.10 + * 50 = v1.2.11 + * 51 = v2.0.0 = 1 + * 52 = v2.1.0 = 2 + * 53 = v2.1.1 = 2 + * 54 = v2.1.2 = 2 + * 55 = v2.1.3 = 2 + * 56 = v2.2.0 = 3 + * Since v2.3.0, this has been aligned with the versionCode as defined in build.gradle + * It is of the format XXYYZZ, where XX is major, YY is minor, ZZ is patch. + * 20300 = v2.3.0 = 20300 + */ + private static final int CURRENT_VERSION = BuildConfig.VERSION_CODE; + + /** + * Context to show dialogs to. + */ + private Context context; + private RuntimePermissionsHelper runtimePermissionsHelper; + + private UpdateManager updateManager; + + private ProgressDialog updateDownloadDialog; + + public VersionHandler(Context context, RuntimePermissionsHelper runtimePermissionsHelper) { + this.context = context; + this.runtimePermissionsHelper = runtimePermissionsHelper; + + updateManager = new UpdateManager(this); + } + + /** + * Runs every time onCreate is called on the StartActivity. + */ + public void run() { + int previous = ChanSettings.previousVersion.get(); + if (previous < CURRENT_VERSION) { + if (previous < 1) { + cleanupOutdatedIonFolder(context); + } + + // Add more previous version checks here + + showMessage(CURRENT_VERSION); + + ChanSettings.previousVersion.set(CURRENT_VERSION); + + // Don't process the updater because a dialog is now already showing. + return; + } + + if (updateManager.isUpdatingAvailable()) { + updateManager.runUpdateApi(false); + } + } + + public boolean isUpdatingAvailable() { + return updateManager.isUpdatingAvailable(); + } + + public void manualUpdateCheck() { + updateManager.runUpdateApi(true); + } + + @Override + public void onManualCheckNone() { + new AlertDialog.Builder(context) + .setTitle(R.string.update_none) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onManualCheckFailed() { + new AlertDialog.Builder(context) + .setTitle(R.string.update_check_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void showUpdateAvailableDialog(final UpdateApiRequest.UpdateApiMessage message) { + Spanned text = Html.fromHtml(message.messageHtml); + + final AlertDialog dialog = new AlertDialog.Builder(context) + .setMessage(text) + .setNegativeButton(R.string.update_later, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + updatePostponed(message); + } + }) + .setPositiveButton(R.string.update_install, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + updateInstallRequested(message); + } + }) + .create(); + dialog.show(); + dialog.setCanceledOnTouchOutside(false); + } + + private void updatePostponed(UpdateApiRequest.UpdateApiMessage message) { + } + + private void updateInstallRequested(final UpdateApiRequest.UpdateApiMessage message) { + runtimePermissionsHelper.requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, new RuntimePermissionsHelper.Callback() { + @Override + public void onRuntimePermissionResult(boolean granted) { + if (granted) { + createDownloadProgressDialog(); + updateManager.doUpdate(new UpdateManager.Update(message.apkUrl)); + } else { + runtimePermissionsHelper.showPermissionRequiredDialog(context, + context.getString(R.string.update_storage_permission_required_title), + context.getString(R.string.update_storage_permission_required), + new RuntimePermissionsHelper.PermissionRequiredDialogCallback() { + @Override + public void retryPermissionRequest() { + updateInstallRequested(message); + } + }); + } + } + }); + } + + private void createDownloadProgressDialog() { + updateDownloadDialog = new ProgressDialog(context); + updateDownloadDialog.setCancelable(false); + updateDownloadDialog.setTitle(R.string.update_install_downloading); + updateDownloadDialog.setMax(10000); + updateDownloadDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + updateDownloadDialog.setProgressNumberFormat(""); + updateDownloadDialog.show(); + } + + @Override + public void onUpdateDownloadProgress(long downloaded, long total) { + updateDownloadDialog.setProgress((int) (updateDownloadDialog.getMax() * (downloaded / (double) total))); + } + + @Override + public void onUpdateDownloadSuccess() { + updateDownloadDialog.dismiss(); + updateDownloadDialog = null; + } + + @Override + public void onUpdateDownloadFailed() { + updateDownloadDialog.dismiss(); + updateDownloadDialog = null; + new AlertDialog.Builder(context) + .setTitle(R.string.update_install_download_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onUpdateDownloadMoveFailed() { + new AlertDialog.Builder(context) + .setTitle(R.string.update_install_download_move_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onUpdateOpenInstallScreen(Intent intent) { + AndroidUtils.openIntent(intent); + } + + @Override + public void openUpdateRetryDialog(final UpdateManager.Install install) { + new AlertDialog.Builder(context) + .setTitle(R.string.update_retry_title) + .setMessage(R.string.update_retry) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.update_retry_button, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + updateManager.retry(install); + } + }) + .show(); + } + + private void showMessage(int version) { + int resource = context.getResources().getIdentifier("changelog_" + version, "string", context.getPackageName()); + if (resource != 0) { + CharSequence message = Html.fromHtml(context.getString(resource)); + + final AlertDialog dialog = new AlertDialog.Builder(context) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .create(); + dialog.show(); + dialog.setCanceledOnTouchOutside(false); + + final Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + button.setEnabled(false); + AndroidUtils.runOnUiThread(new Runnable() { + @Override + public void run() { + dialog.setCanceledOnTouchOutside(true); + button.setEnabled(true); + } + }, 1500); + } + } + + private void cleanupOutdatedIonFolder(Context context) { + Logger.i(TAG, "Cleaning up old ion folder"); + File ionCacheFolder = new File(context.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"); + } + } + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/layout/FilesLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/layout/FilesLayout.java index 96dd8c28..52a39471 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/layout/FilesLayout.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/layout/FilesLayout.java @@ -30,8 +30,7 @@ import android.widget.LinearLayout; import android.widget.TextView; import org.floens.chan.R; -import org.floens.chan.core.model.FileItem; -import org.floens.chan.core.model.FileItems; +import org.floens.chan.core.saver.FileWatcher; import org.floens.chan.ui.adapter.FilesAdapter; import org.floens.chan.utils.RecyclerUtils; @@ -51,7 +50,7 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback, private Map history = new HashMap<>(); private FileItemHistory currentHistory; - private FileItems currentFileItems; + private FileWatcher.FileItems currentFileItems; private Callback callback; @@ -91,7 +90,7 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback, this.callback = callback; } - public void setFiles(FileItems fileItems) { + public void setFiles(FileWatcher.FileItems fileItems) { // Save the associated list position if (currentFileItems != null) { int[] indexTop = RecyclerUtils.getIndexAndTop(recyclerView); @@ -132,7 +131,7 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback, } @Override - public void onFileItemClicked(FileItem fileItem) { + public void onFileItemClicked(FileWatcher.FileItem fileItem) { currentHistory.clickedItem = fileItem; callback.onFileItemClicked(fileItem); } @@ -147,12 +146,12 @@ public class FilesLayout extends LinearLayout implements FilesAdapter.Callback, private class FileItemHistory { int index, top; - FileItem clickedItem; + FileWatcher.FileItem clickedItem; } public interface Callback { void onBackClicked(); - void onFileItemClicked(FileItem fileItem); + void onFileItemClicked(FileWatcher.FileItem fileItem); } } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java index 9fe104dd..ce2936a5 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/layout/ReplyLayout.java @@ -39,7 +39,6 @@ import org.floens.chan.core.model.ChanThread; import org.floens.chan.core.model.Loadable; import org.floens.chan.core.site.http.Reply; import org.floens.chan.core.presenter.ReplyPresenter; -import org.floens.chan.core.settings.ChanSettings; import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.captcha.CaptchaCallback; import org.floens.chan.ui.captcha.CaptchaLayout; @@ -65,7 +64,7 @@ import static org.floens.chan.utils.AndroidUtils.setRoundItemBackground; public class ReplyLayout extends LoadView implements View.OnClickListener, AnimationUtils.LayoutAnimationProgress, ReplyPresenter.ReplyPresenterCallback, TextWatcher, ImageDecoder.ImageDecoderCallback, SelectionListeningEditText.SelectionChangedListener { private ReplyPresenter presenter; private ReplyLayoutCallback callback; - private boolean newCaptcha = ChanSettings.postNewCaptcha.get(); + private boolean newCaptcha; private View replyInputLayout; private FrameLayout captchaContainer; @@ -251,6 +250,11 @@ public class ReplyLayout extends LoadView implements View.OnClickListener, Anima } } + @Override + public void setCaptchaVersion(boolean newCaptcha) { + this.newCaptcha = newCaptcha; + } + @Override public void initCaptcha(String baseUrl, String siteKey, CaptchaCallback callback) { authenticationLayout.initCaptcha(baseUrl, siteKey, ThemeHelper.getInstance().getTheme().isLightTheme, callback); diff --git a/Clover/app/src/main/java/org/floens/chan/ui/layout/ThreadListLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/layout/ThreadListLayout.java index 6dfb8ea7..2be1e7e1 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/layout/ThreadListLayout.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/layout/ThreadListLayout.java @@ -21,6 +21,8 @@ import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; +import android.os.Handler; +import android.os.Looper; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -77,6 +79,15 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa private int lastPostCount; private int recyclerViewTopPadding; + private Handler mainHandler = new Handler(Looper.getMainLooper()); + + private RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + onRecyclerViewScrolled(); + } + }; + public ThreadListLayout(Context context, AttributeSet attrs) { super(context, attrs); } @@ -102,24 +113,7 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa postAdapter = new PostAdapter(recyclerView, postAdapterCallback, postCellCallback, statusCellCallback); recyclerView.setAdapter(postAdapter); - recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrolled(RecyclerView recyclerView, int dx, int dy) { - // onScrolled can be called after cleanup() - if (showingThread != null) { - int[] indexTop = getIndexAndTop(); - - showingThread.loadable.setListViewIndex(indexTop[0]); - showingThread.loadable.setListViewTop(indexTop[1]); - - int last = getCompleteBottomAdapterPosition(); - if (last == postAdapter.getItemCount() - 1 && last > lastPostCount) { - lastPostCount = last; - ThreadListLayout.this.callback.onListScrolledToBottom(); - } - } - } - }); + recyclerView.addOnScrollListener(scrollListener); attachToolbarScroll(true); @@ -128,6 +122,30 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa searchStatus.getPaddingRight(), searchStatus.getPaddingBottom()); } + private void onRecyclerViewScrolled() { + // onScrolled can be called after cleanup() + if (showingThread != null) { + int[] indexTop = getIndexAndTop(); + + showingThread.loadable.setListViewIndex(indexTop[0]); + showingThread.loadable.setListViewTop(indexTop[1]); + + int last = getCompleteBottomAdapterPosition(); + if (last == postAdapter.getItemCount() - 1 && last > lastPostCount) { + lastPostCount = last; + + // As requested by the RecyclerView, make sure that the adapter isn't changed + // while in a layout pass. Postpone to the next frame. + mainHandler.post(new Runnable() { + @Override + public void run() { + ThreadListLayout.this.callback.onListScrolledToBottom(); + } + }); + } + } + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); @@ -508,7 +526,7 @@ public class ThreadListLayout extends FrameLayout implements ReplyLayout.ReplyLa RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); int top = child.getTop() + params.topMargin; int left = child.getLeft() + params.leftMargin; - c.drawBitmap(hat, left - parent.getPaddingLeft() - dp(40), top - dp(130) - parent.getPaddingTop() + toolbarHeight(), null); + c.drawBitmap(hat, left - parent.getPaddingLeft() - dp(25), top - dp(80) - parent.getPaddingTop() + toolbarHeight(), null); } } } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/theme/ThemeHelper.java b/Clover/app/src/main/java/org/floens/chan/ui/theme/ThemeHelper.java index 967898a3..b3c9eb8b 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/theme/ThemeHelper.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/theme/ThemeHelper.java @@ -56,6 +56,8 @@ public class ThemeHelper { themes.add(new Theme("Yotsuba", "yotsuba", R.style.Chan_Theme_Yotsuba, PrimaryColor.RED)); themes.add(new Theme("Yotsuba B", "yotsuba_b", R.style.Chan_Theme_YotsubaB, PrimaryColor.RED)); themes.add(new Theme("Photon", "photon", R.style.Chan_Theme_Photon, PrimaryColor.ORANGE)); + themes.add(new DarkTheme("Insomnia", "insomnia", R.style.Chan_Theme_Insomnia, PrimaryColor.DARK)); + themes.add(new DarkTheme("Gruvbox", "gruvbox", R.style.Chan_Theme_Gruvbox, PrimaryColor.DARK)); ChanSettings.ThemeColor settingTheme = ChanSettings.getThemeAndColor(); for (Theme theme : themes) { diff --git a/Clover/app/src/main/java/org/floens/chan/ui/view/FloatingMenu.java b/Clover/app/src/main/java/org/floens/chan/ui/view/FloatingMenu.java index 8fd9c36a..c9dd68fc 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/view/FloatingMenu.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/view/FloatingMenu.java @@ -94,9 +94,6 @@ public class FloatingMenu { public void setItems(List items) { this.items = items; - if (popupWindow != null) { - popupWindow.dismiss(); - } } public void setSelectedItem(FloatingMenuItem item) { @@ -105,6 +102,9 @@ public class FloatingMenu { public void setAdapter(ListAdapter adapter) { this.adapter = adapter; + if (popupWindow != null) { + popupWindow.setAdapter(adapter); + } } public void setCallback(FloatingMenuCallback callback) { diff --git a/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java b/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java index 70d014a6..9a338839 100644 --- a/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java +++ b/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java @@ -20,7 +20,12 @@ package org.floens.chan.utils; import android.content.Context; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -95,4 +100,24 @@ public class IOUtils { output.write(buffer, 0, read); } } + + /** + * Copies the {@link File} specified by {@code in} to {@code out}. + * Both streams are always closed. + * + * @param in input file + * @param out output file + * @throws IOException thrown on copy exceptions. + */ + public static void copyFile(File in, File out) throws IOException { + InputStream is = null; + OutputStream os = null; + try { + copy(is = new BufferedInputStream(new FileInputStream(in)), + os = new BufferedOutputStream(new FileOutputStream(out))); + } finally { + IOUtils.closeQuietly(is); + IOUtils.closeQuietly(os); + } + } } diff --git a/Clover/app/src/main/res/drawable-mdpi/partyhat.png b/Clover/app/src/main/res/drawable-hdpi/partyhat.png similarity index 100% rename from Clover/app/src/main/res/drawable-mdpi/partyhat.png rename to Clover/app/src/main/res/drawable-hdpi/partyhat.png diff --git a/Clover/app/src/main/res/values/strings.xml b/Clover/app/src/main/res/values/strings.xml index 4c09207d..77340251 100644 --- a/Clover/app/src/main/res/values/strings.xml +++ b/Clover/app/src/main/res/values/strings.xml @@ -19,73 +19,18 @@ along with this program. If not, see . - + Clover was updated - This is the beta of Clover v2, redesigned with the Google Material Design guidelines in mind.
-
- Biggest changes:
- - New design.
- - New inline reply layout.
- - Support for thread hiding.
- - Support for filtering threads, making posts either highlighted, hidden or completely removed.
- - Support for history.
- - More themes were added.
- - Album downloads have been improved.
- - Catalog mode now uses cards.
- - More advanced options added.
- - Minor features and bug fixes.
-
- Please provide feedback on GitHub or by writing an email, links are at the bottom of the settings screen. - ]]> -
- - - Clover was updated - - This is the release of Clover v2, redesigned with the Material design guidelines in mind.
-
- Biggest changes are:
- - New design.
- - New inline reply layout.
- - Added thread hiding.
- - Added filtering threads, making posts either highlighted, hidden or removed.
- - Added history.
- - Added more themes.
- - Better album download screen.
- - Catalog mode now uses cards.
- - More advanced options added.
- - Minor features and bug fixes.
-
- Please provide feedback on GitHub or by writing an email, links are at the bottom of the settings screen. - ]]> -
- - - Clover was updated - - Clover was updated to v2.2.0
+ Clover was updated to v2.3.1

New in this version:
- Add sliding of threads back.
- Rewrote thread watching, it\'s more stable and works correctly with doze now.
- Optimized parsing with multithreading.
- Added album view.
- Added bookmark clearing.
-
- Added inline reporting.
- All boards are added by default now.
- Thread positions are retained across restarts.
- Allow setting of multiple types on filters simultaneously.
- Made single frame gifs zoomable.
- Add saving of images into their own board folder.
- More advanced settings.
- Many bug fixes.
-
- Many thanks to the contributors on GitHub! + Better link detection.
+ More themes.
+ Fix image loading taking a long time if the cache is full.
+ Fix crashing bug related to the status view.
+ Bug fixes. ]]>
@@ -100,6 +45,26 @@ along with this program. If not, see . Undo Save + App settings + Grant + + Later + Clover is up to date + Failed to check for updates. + Install + Downloading update + Download failed + Failed to move downloaded file to the Download directory. + Retry update + Clover was not updated yet. Click retry to retry the install. + retry + + Storage permission required + +"Permission to access storage is required for installing the update. + +Re-enable this permission in the app settings if you permanently disabled it." + %d minute %d minutes @@ -356,15 +321,6 @@ along with this program. If not, see . Filters - Save location - Storage permission required - -"Permission to access storage is required for browsing files. - -Re-enable this permission in the app settings if you permanently disabled it." - App settings - Grant - Select images (%1$d / %2$d) Please select images to download %1$s will be downloaded to the folder %2$s @@ -428,19 +384,30 @@ Re-enable this permission in the app settings if you permanently disabled it."Pin thread on post
About + Check for updates Released under the GNU GPLv3 license Tap to see license Open Source Licenses Legal information about licenses Developer settings + View logs + Logs + Copy + Copied to clipboard Advanced settings Advanced settings + Save location + Storage permission required + +"Permission to access storage is required for browsing files. + +Re-enable this permission in the app settings if you permanently disabled it." File save folder Error creating save folder Choose Use the new captcha - Enable to use the newer recaptcha. + Enable to use the newer recaptcha for thread replies. Save original filename Save images in a board folder Create a folder for each board to store images in diff --git a/Clover/app/src/main/res/values/styles.xml b/Clover/app/src/main/res/values/styles.xml index 3ffa230e..238e7970 100644 --- a/Clover/app/src/main/res/values/styles.xml +++ b/Clover/app/src/main/res/values/styles.xml @@ -161,6 +161,79 @@ along with this program. If not, see . #ffb5bd68 + + + +