diff --git a/Kuroba/app/build.gradle b/Kuroba/app/build.gradle index 2f08874a58..269993ec9c 100644 --- a/Kuroba/app/build.gradle +++ b/Kuroba/app/build.gradle @@ -1,4 +1,7 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' android { // !!! IMPORTANT !!! @@ -72,6 +75,10 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + freeCompilerArgs = ["-Xallow-result-return-type"] + } + lintOptions { abortOnError false } @@ -215,6 +222,10 @@ dependencies { implementation 'com.vdurmont:emoji-java:5.1.0' implementation 'io.reactivex.rxjava2:rxjava:2.2.12' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.github.K1rakishou:Fuck-Storage-Access-Framework:v1.0-alpha' + + testImplementation 'junit:junit:4.12' } //======================================================== diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/StartActivity.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/StartActivity.java index 9513a052d7..8a2e70c86e 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/StartActivity.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/StartActivity.java @@ -61,6 +61,10 @@ import com.github.adamantcheese.chan.ui.theme.ThemeHelper; import com.github.adamantcheese.chan.utils.AndroidUtils; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileChooser; +import com.github.k1rakishou.fsaf.callback.FSAFActivityCallbacks; + +import org.jetbrains.annotations.NotNull; import java.io.PrintWriter; import java.io.StringWriter; @@ -73,7 +77,10 @@ import static com.github.adamantcheese.chan.Chan.inject; import static com.github.adamantcheese.chan.utils.AndroidUtils.getApplicationLabel; -public class StartActivity extends AppCompatActivity implements NfcAdapter.CreateNdefMessageCallback { +public class StartActivity + extends AppCompatActivity + implements NfcAdapter.CreateNdefMessageCallback, + FSAFActivityCallbacks { private static final String TAG = "StartActivity"; private static final String STATE_KEY = "chan_state"; @@ -94,15 +101,14 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat @Inject DatabaseManager databaseManager; - @Inject WatchManager watchManager; - @Inject SiteResolver siteResolver; - @Inject SiteService siteService; + @Inject + FileChooser fileChooser; @Override protected void onCreate(Bundle savedInstanceState) { @@ -115,6 +121,8 @@ protected void onCreate(Bundle savedInstanceState) { Chan.injector().instance(ThemeHelper.class).setupContext(this); + fileChooser.setCallbacks(this); + imagePickDelegate = new ImagePickDelegate(this); runtimePermissionsHelper = new RuntimePermissionsHelper(this); updateManager = new UpdateManager(this); @@ -534,6 +542,8 @@ protected void onDestroy() { return; } + fileChooser.removeCallbacks(); + // TODO: clear whole stack? stackTop().onHide(); stackTop().onDestroy(); @@ -544,7 +554,13 @@ protected void onDestroy() { protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - imagePickDelegate.onActivityResult(requestCode, resultCode, data); + if (fileChooser.onActivityResult(requestCode, resultCode, data)) { + return; + } + + if (imagePickDelegate.onActivityResult(requestCode, resultCode, data)) { + return; + } } private Controller stackTop() { @@ -581,4 +597,9 @@ public void restartApp() { Runtime.getRuntime().exit(0); } + + @Override + public void fsafStartActivityForResult(@NotNull Intent intent, int requestCode) { + startActivityForResult(intent, requestCode); + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/CacheHandler.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/CacheHandler.java index fc80875547..786e7e004c 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/CacheHandler.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/CacheHandler.java @@ -24,8 +24,13 @@ import com.github.adamantcheese.chan.core.settings.ChanSettings; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.FileSegment; +import com.github.k1rakishou.fsaf.file.RawFile; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -38,10 +43,11 @@ public class CacheHandler { private static final String TAG = "CacheHandler"; //1GB for prefetching, so that entire threads can be loaded at once more easily, otherwise 100MB is plenty private static final long FILE_CACHE_DISK_SIZE = (ChanSettings.autoLoadThreadImages.get() ? 1000 : 100) * 1024 * 1024; + private static final String CACHE_EXTENSION = "cache"; private final ExecutorService pool = Executors.newSingleThreadExecutor(); - - private final File directory; + private final FileManager fileManager; + private final RawFile cacheDirFile; /** * An estimation of the current size of the directory. Used to check if trim must be run @@ -50,8 +56,9 @@ public class CacheHandler { private AtomicLong size = new AtomicLong(); private AtomicBoolean trimRunning = new AtomicBoolean(false); - public CacheHandler(File directory) { - this.directory = directory; + public CacheHandler(FileManager fileManager, RawFile cacheDirFile) { + this.fileManager = fileManager; + this.cacheDirFile = cacheDirFile; createDirectories(); backgroundRecalculateSize(); @@ -59,13 +66,39 @@ public CacheHandler(File directory) { @MainThread public boolean exists(String key) { - return get(key).exists(); + return fileManager.exists(get(key)); } @MainThread - public File get(String key) { + public RawFile get(String key) { createDirectories(); - return new File(directory, String.valueOf(key.hashCode())); + + String fileName = String.format( + "%s.%s", + // We need extension here because AbstractFile expects all file names to have + // extensions + String.valueOf(key.hashCode()), CACHE_EXTENSION); + + return (RawFile) cacheDirFile + .clone(new FileSegment(fileName)); + } + + public File randomCacheFile() throws IOException { + createDirectories(); + + File cacheDir = new File(cacheDirFile.getFullPath()); + File newFile = new File(cacheDir, String.valueOf(System.nanoTime())); + + while (newFile.exists()) { + newFile = new File(cacheDir, String.valueOf(System.nanoTime())); + } + + if (!newFile.createNewFile()) { + throw new IOException("Could not create new file in cache directory, newFile = " + + newFile.getAbsolutePath()); + } + + return newFile; } @MainThread @@ -94,11 +127,11 @@ protected void fileWasAdded(long fileLen) { public void clearCache() { Logger.d(TAG, "Clearing cache"); - if (directory.exists() && directory.isDirectory()) { - for (File file : directory.listFiles()) { - if (!file.delete()) { + if (fileManager.exists(cacheDirFile) && fileManager.isDirectory(cacheDirFile)) { + for (AbstractFile file : fileManager.listFiles(cacheDirFile)) { + if (!fileManager.delete(file)) { Logger.d(TAG, "Could not delete cache file while clearing cache " + - file.getName()); + fileManager.getName(file)); } } } @@ -108,11 +141,8 @@ public void clearCache() { @MainThread public void createDirectories() { - if (!directory.exists()) { - if (!directory.mkdirs()) { - Logger.e(TAG, "Unable to create file cache dir " + - directory.getAbsolutePath()); - } + if (!fileManager.exists(cacheDirFile) && fileManager.create(cacheDirFile) == null) { + throw new RuntimeException("Unable to create file cache dir " + cacheDirFile.getFullPath()); } } @@ -125,11 +155,9 @@ private void backgroundRecalculateSize() { private void recalculateSize() { long calculatedSize = 0; - File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - calculatedSize += file.length(); - } + List files = fileManager.listFiles(cacheDirFile); + for (AbstractFile file : files) { + calculatedSize += fileManager.getLength(file); } size.set(calculatedSize); @@ -137,34 +165,34 @@ private void recalculateSize() { @WorkerThread private void trim() { - File[] directoryFiles = directory.listFiles(); + List directoryFiles = fileManager.listFiles(cacheDirFile); // Don't try to trim empty directories or just one file in it. - if (directoryFiles == null || directoryFiles.length <= 1) { + if (directoryFiles.size() <= 1) { return; } // Get all files with their last modified times. - List> files = new ArrayList<>(directoryFiles.length); - for (File file : directoryFiles) { - files.add(new Pair<>(file, file.lastModified())); + List> files = new ArrayList<>(directoryFiles.size()); + for (AbstractFile file : directoryFiles) { + files.add(new Pair<>(file, fileManager.lastModified(file))); } // Sort by oldest first. Collections.sort(files, (o1, o2) -> Long.signum(o1.second - o2.second)); //Pre-trim based on time, trash anything older than 6 hours - List> removed = new ArrayList<>(); - for (Pair fileLongPair : files) { + List> removed = new ArrayList<>(); + for (Pair fileLongPair : files) { if (fileLongPair.second + 6 * 60 * 60 * 1000 < System.currentTimeMillis()) { - Logger.d(TAG, "Delete for trim " + fileLongPair.first.getAbsolutePath()); - if (!fileLongPair.first.delete()) { + Logger.d(TAG, "Delete for trim " + fileLongPair.first.getFullPath()); + if (!fileManager.delete(fileLongPair.first)) { Logger.e(TAG, "Failed to delete cache file for trim"); } removed.add(fileLongPair); } else break; //only because we sorted earlier } - for (Pair deleted : removed) { + for (Pair deleted : removed) { files.remove(deleted); } recalculateSize(); @@ -172,15 +200,16 @@ private void trim() { // Trim as long as the directory size exceeds the threshold (note that oldest is still first) long workingSize = size.get(); for (int i = 0; workingSize >= FILE_CACHE_DISK_SIZE; i++) { - File file = files.get(i).first; + AbstractFile file = files.get(i).first; - Logger.d(TAG, "Delete for trim " + file.getAbsolutePath()); - workingSize -= file.length(); + Logger.d(TAG, "Delete for trim " + file.getFullPath()); + workingSize -= fileManager.getLength(file); - if (!file.delete()) { + if (!fileManager.delete(file)) { Logger.e(TAG, "Failed to delete cache file for trim"); } } + recalculateSize(); } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCache.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCache.java index 0fda1ee4f6..0ec0edd2da 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCache.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCache.java @@ -22,10 +22,16 @@ import com.github.adamantcheese.chan.core.manager.ThreadSaveManager; import com.github.adamantcheese.chan.core.model.PostImage; import com.github.adamantcheese.chan.core.model.orm.Loadable; -import com.github.adamantcheese.chan.core.settings.ChanSettings; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.FileSegment; +import com.github.k1rakishou.fsaf.file.RawFile; +import com.github.k1rakishou.fsaf.file.Segment; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; @@ -33,15 +39,21 @@ public class FileCache implements FileCacheDownloader.Callback { private static final String TAG = "FileCache"; + private static final String FILE_CACHE_DIR = "filecache"; private final ExecutorService downloadPool = Executors.newCachedThreadPool(); - private final CacheHandler cacheHandler; + private final FileManager fileManager; private List downloaders = new ArrayList<>(); - public FileCache(File directory) { - cacheHandler = new CacheHandler(directory); + public FileCache(File cacheDir, FileManager fileManager) { + this.fileManager = fileManager; + + RawFile cacheDirFile = fileManager.fromRawFile( + new File(cacheDir, FILE_CACHE_DIR)); + + cacheHandler = new CacheHandler(fileManager, cacheDirFile); } public void clearCache() { @@ -59,19 +71,36 @@ public FileCacheDownloader downloadFile( FileCacheListener listener) { if (loadable.isLocal()) { String filename = ThreadSaveManager.formatOriginalImageName( - postImage.serverFilename, postImage.extension); + postImage.serverFilename, + postImage.extension); + + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + Logger.e(TAG, "Base local threads directory does not exist"); + return null; + } + + AbstractFile baseDirFile = fileManager.newBaseDirectoryFile( + LocalThreadsBaseDirectory.class + ); + + if (baseDirFile == null) { + Logger.e(TAG, "downloadFile() fileManager.newLocalThreadFile() returned null"); + return null; + } - String imageDir = ThreadSaveManager.getImagesSubDir(loadable); - File fullImagePath = new File(ChanSettings.saveLocation.get(), imageDir); - File imageOnDiskFile = new File(fullImagePath, filename); + // TODO: double check, may not work as expected + List segments = new ArrayList<>(ThreadSaveManager.getImagesSubDir(loadable)); + segments.add(new FileSegment(filename)); - if (imageOnDiskFile.exists() - && imageOnDiskFile.isFile() - && imageOnDiskFile.canRead()) { + AbstractFile imageOnDiskFile = baseDirFile.clone(segments); + + if (fileManager.exists(imageOnDiskFile) + && fileManager.isFile(imageOnDiskFile) + && fileManager.canRead(imageOnDiskFile)) { handleFileImmediatelyAvailable(listener, imageOnDiskFile); } else { Logger.e(TAG, "Cannot load saved image from the disk, path: " - + imageOnDiskFile.getAbsolutePath()); + + imageOnDiskFile.getFullPath()); if (listener != null) { listener.onFail(true); @@ -106,12 +135,12 @@ public FileCacheDownloader downloadFile(@NonNull String url, FileCacheListener l return runningDownloaderForKey; } - File file = get(url); - if (file.exists()) { + RawFile file = get(url); + if (fileManager.exists(file)) { handleFileImmediatelyAvailable(listener, file); return null; } else { - return handleStartDownload(listener, file, url); + return handleStartDownload(fileManager, listener, file, url); } } @@ -138,7 +167,7 @@ public boolean exists(String key) { return cacheHandler.exists(key); } - public File get(String key) { + public RawFile get(String key) { return cacheHandler.get(key); } @@ -146,20 +175,42 @@ public long getFileCacheSize() { return cacheHandler.getSize().get(); } - private void handleFileImmediatelyAvailable(FileCacheListener listener, File file) { + private void handleFileImmediatelyAvailable(FileCacheListener listener, AbstractFile file) { // TODO: setLastModified doesn't seem to work on Android... - if (!file.setLastModified(System.currentTimeMillis())) { - Logger.e(TAG, "Could not set last modified time on file"); - } +// if (!file.setLastModified(System.currentTimeMillis())) { +// Logger.e(TAG, "Could not set last modified time on file"); +// } + if (listener != null) { - listener.onSuccess(file); + if (file instanceof RawFile) { + listener.onSuccess((RawFile) file); + } else { + try { + RawFile resultFile = fileManager.fromRawFile(cacheHandler.randomCacheFile()); + if (!fileManager.copyFileContents(file, resultFile)) { + throw new IOException("Could not copy external SAF file into internal " + + "cache file, externalFile = " + file.getFullPath() + + ", resultFile = " + resultFile.getFullPath()); + } + + listener.onSuccess(resultFile); + } catch (IOException e) { + Logger.e(TAG, "Error while trying to create a new random cache file", e); + listener.onFail(false); + } + } + listener.onEnd(); } } private FileCacheDownloader handleStartDownload( - FileCacheListener listener, File file, String url) { - FileCacheDownloader downloader = new FileCacheDownloader(this, url, file); + FileManager fileManager, + FileCacheListener listener, + RawFile file, + String url + ) { + FileCacheDownloader downloader = new FileCacheDownloader(fileManager, this, url, file); if (listener != null) { downloader.addListener(listener); } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheDownloader.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheDownloader.java index a76c234cc0..a7ce60a679 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheDownloader.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheDownloader.java @@ -26,10 +26,12 @@ import com.github.adamantcheese.chan.Chan; import com.github.adamantcheese.chan.core.di.NetModule; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.RawFile; import java.io.Closeable; -import java.io.File; import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -51,10 +53,11 @@ public class FileCacheDownloader implements Runnable { private static final long NOTIFY_SIZE = BUFFER_SIZE * 8; private final String url; - private final File output; + private final RawFile output; private final Handler handler; // Main thread only. + private final FileManager fileManager; private final Callback callback; private final List listeners = new ArrayList<>(); @@ -66,7 +69,8 @@ public class FileCacheDownloader implements Runnable { private Call call; private ResponseBody body; - public FileCacheDownloader(Callback callback, String url, File output) { + public FileCacheDownloader(FileManager fileManager, Callback callback, String url, RawFile output) { + this.fileManager = fileManager; this.callback = callback; this.url = url; this.output = output; @@ -123,6 +127,7 @@ public void run() { private void execute() { Closeable sourceCloseable = null; Closeable sinkCloseable = null; + OutputStream outputFileOutputStream = null; try { checkCancel(); @@ -132,18 +137,26 @@ private void execute() { Source source = body.source(); sourceCloseable = source; - BufferedSink sink = Okio.buffer(Okio.sink(output)); + if (!fileManager.exists(output) && fileManager.create(output) == null) { + throw new IOException("Couldn't create output file, output = " + + output.getFullPath()); + } + + outputFileOutputStream = fileManager.getOutputStream(output); + if (outputFileOutputStream == null) { + throw new IOException("Couldn't get output file's OutputStream"); + } + + BufferedSink sink = Okio.buffer(Okio.sink(outputFileOutputStream)); sinkCloseable = sink; checkCancel(); log("got input stream"); - pipeBody(source, sink); - log("done"); - long fileLen = output.length(); + long fileLen = fileManager.getLength(output); handler.post(() -> { if (callback != null) { @@ -188,13 +201,18 @@ private void execute() { } }); } finally { - if(sourceCloseable != null) { + if (sourceCloseable != null) { Util.closeQuietly(sourceCloseable); } - if(sinkCloseable != null) { + + if (sinkCloseable != null) { Util.closeQuietly(sinkCloseable); } + if (outputFileOutputStream != null) { + Util.closeQuietly(outputFileOutputStream); + } + if (call != null) { call.cancel(); } @@ -273,8 +291,8 @@ private void checkCancel() throws IOException { @WorkerThread private void purgeOutput() { - if (output.exists()) { - final boolean deleteResult = output.delete(); + if (fileManager.exists(output)) { + final boolean deleteResult = fileManager.delete(output); if (!deleteResult) { log("could not delete the file in purgeOutput"); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheListener.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheListener.java index ccf2f04a60..d53a362a6f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheListener.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/cache/FileCacheListener.java @@ -16,7 +16,7 @@ */ package com.github.adamantcheese.chan.core.cache; -import java.io.File; +import com.github.k1rakishou.fsaf.file.RawFile; public abstract class FileCacheListener { public void onProgress(long downloaded, long total) { @@ -25,7 +25,7 @@ public void onProgress(long downloaded, long total) { /** * Called when the file download was completed. */ - public void onSuccess(File file) { + public void onSuccess(RawFile file) { } /** diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/database/DatabaseSavedThreadManager.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/database/DatabaseSavedThreadManager.java index 7fcca6129f..309407fb53 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/database/DatabaseSavedThreadManager.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/database/DatabaseSavedThreadManager.java @@ -3,11 +3,12 @@ import com.github.adamantcheese.chan.core.manager.ThreadSaveManager; import com.github.adamantcheese.chan.core.model.orm.Loadable; import com.github.adamantcheese.chan.core.model.orm.SavedThread; -import com.github.adamantcheese.chan.core.settings.ChanSettings; -import com.github.adamantcheese.chan.utils.IOUtils; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; +import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; import com.j256.ormlite.stmt.DeleteBuilder; -import java.io.File; import java.util.List; import java.util.concurrent.Callable; @@ -16,13 +17,28 @@ import static com.github.adamantcheese.chan.Chan.inject; public class DatabaseSavedThreadManager { + private static final String TAG = "DatabaseSavedThreadManager"; + @Inject DatabaseHelper helper; + @Inject + FileManager fileManager; public DatabaseSavedThreadManager() { inject(this); } + public Callable countDownloadingThreads() { + return () -> { + return helper.savedThreadDao.queryBuilder() + .where() + .eq(SavedThread.IS_STOPPED, false) + .and() + .eq(SavedThread.IS_FULLY_DOWNLOADED, false) + .countOf(); + }; + } + public Callable> getSavedThreads() { return () -> { // We don't need fully downloaded threads here @@ -33,6 +49,16 @@ public Callable> getSavedThreads() { }; } + public Callable hasSavedThreads() { + return () -> { + SavedThread savedThread = helper.savedThreadDao + .queryBuilder() + .queryForFirst(); + + return savedThread != null; + }; + } + public Callable startSavingThread(final SavedThread savedThread) { return () -> { SavedThread prevSavedThread = getSavedThreadByLoadableId(savedThread.loadableId).call(); @@ -114,6 +140,7 @@ public Callable updateThreadFullyDownloadedByLoadableId(int loadableId) return true; } + savedThread.isStopped = true; savedThread.isFullyDownloaded = true; helper.savedThreadDao.update(savedThread); @@ -153,18 +180,38 @@ public Callable deleteSavedThread(Loadable loadable) { db.where().eq(SavedThread.LOADABLE_ID, loadable.id); db.delete(); - String threadSubDir = ThreadSaveManager.getThreadSubDir(loadable); - File threadSaveDir = new File(ChanSettings.saveLocation.get(), threadSubDir); - - if (!threadSaveDir.exists() || !threadSaveDir.isDirectory()) { - return null; - } - - IOUtils.deleteDirWithContents(threadSaveDir); + deleteThreadFromDisk(loadable); return null; }; } + // TODO: may not work, but in theory it should + public void deleteThreadFromDisk(Loadable loadable) { + AbstractFile localThreadsDir = fileManager.newBaseDirectoryFile( + LocalThreadsBaseDirectory.class + ); + + if (localThreadsDir == null + || !fileManager.exists(localThreadsDir) + || !fileManager.isDirectory(localThreadsDir)) { + // Probably already deleted + return; + } + + AbstractFile threadDir = localThreadsDir + .clone(ThreadSaveManager.getThreadSubDir(loadable)); + + if (!fileManager.exists(threadDir) || !fileManager.isDirectory(threadDir)) { + // Probably already deleted + return; + } + + if (!fileManager.delete(threadDir)) { + Logger.d(TAG, "deleteThreadFromDisk() Could not delete SAF directory " + + threadDir.getFullPath()); + } + } + public Callable deleteSavedThreads(List loadableList) { return () -> { for (Loadable loadable : loadableList) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/AppModule.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/AppModule.java index 00880aa0b5..268022343f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/AppModule.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/AppModule.java @@ -25,8 +25,13 @@ import com.github.adamantcheese.chan.core.net.BitmapLruImageCache; import com.github.adamantcheese.chan.core.saver.ImageSaver; import com.github.adamantcheese.chan.ui.captcha.CaptchaHolder; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; +import com.github.adamantcheese.chan.ui.settings.base_directory.SavedFilesBaseDirectory; import com.github.adamantcheese.chan.ui.theme.ThemeHelper; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileChooser; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.manager.base_directory.DirectoryManager; import org.codejargon.feather.Provides; @@ -51,14 +56,19 @@ public Context provideApplicationContext() { @Provides @Singleton - public ImageLoaderV2 provideImageLoaderV2(RequestQueue requestQueue) { + public ImageLoaderV2 provideImageLoaderV2( + RequestQueue requestQueue, + Context applicationContext, + ThemeHelper themeHelper, + FileManager fileManager + ) { final int runtimeMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); final int lruImageCacheSize = runtimeMemory / 8; ImageLoader imageLoader = new ImageLoader( requestQueue, new BitmapLruImageCache(lruImageCacheSize)); Logger.d(DI_TAG, "Image loader v2"); - return new ImageLoaderV2(imageLoader); + return new ImageLoaderV2(imageLoader, fileManager); } @Provides @@ -77,9 +87,9 @@ public ThemeHelper provideThemeHelper() { @Provides @Singleton - public ImageSaver provideImageSaver() { + public ImageSaver provideImageSaver(FileManager fileManager) { Logger.d(DI_TAG, "Image saver"); - return new ImageSaver(); + return new ImageSaver(fileManager); } @Provides @@ -88,4 +98,36 @@ public CaptchaHolder provideCaptchaHolder() { Logger.d(DI_TAG, "Captcha holder"); return new CaptchaHolder(); } + + @Provides + @Singleton + public FileManager provideFileManager() { + DirectoryManager directoryManager = new DirectoryManager(); + + // Add new base directories here + LocalThreadsBaseDirectory localThreadsBaseDirectory = new LocalThreadsBaseDirectory(); + SavedFilesBaseDirectory savedFilesBaseDirectory = new SavedFilesBaseDirectory(); + + FileManager fileManager = new FileManager( + applicationContext, + directoryManager + ); + + fileManager.registerBaseDir( + LocalThreadsBaseDirectory.class, + localThreadsBaseDirectory + ); + fileManager.registerBaseDir( + SavedFilesBaseDirectory.class, + savedFilesBaseDirectory + ); + + return fileManager; + } + + @Provides + @Singleton + public FileChooser provideFileChooser() { + return new FileChooser(applicationContext); + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/ManagerModule.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/ManagerModule.java index 1ad386a855..62d73c0fb4 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/ManagerModule.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/ManagerModule.java @@ -37,6 +37,7 @@ import com.github.adamantcheese.chan.core.site.Site; import com.github.adamantcheese.chan.core.site.sites.chan4.Chan4; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; import org.codejargon.feather.Provides; @@ -136,18 +137,23 @@ public ArchivesManager provideArchivesManager() throws Exception { @Singleton public ThreadSaveManager provideSaveThreadManager( DatabaseManager databaseManager, - SavedThreadLoaderRepository savedThreadLoaderRepository) { + SavedThreadLoaderRepository savedThreadLoaderRepository, + FileManager fileManager) { Logger.d(AppModule.DI_TAG, "Thread save manager"); return new ThreadSaveManager( databaseManager, - savedThreadLoaderRepository); + savedThreadLoaderRepository, + fileManager); } @Provides @Singleton public SavedThreadLoaderManager provideSavedThreadLoaderManager( - SavedThreadLoaderRepository savedThreadLoaderRepository) { + SavedThreadLoaderRepository savedThreadLoaderRepository, + FileManager fileManager) { Logger.d(AppModule.DI_TAG, "Saved thread loader manager"); - return new SavedThreadLoaderManager(savedThreadLoaderRepository); + return new SavedThreadLoaderManager( + savedThreadLoaderRepository, + fileManager); } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/NetModule.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/NetModule.java index 909b78af91..4b6cade215 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/NetModule.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/NetModule.java @@ -24,6 +24,7 @@ import com.github.adamantcheese.chan.core.settings.ChanSettings; import com.github.adamantcheese.chan.core.site.http.HttpCallManager; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; import org.codejargon.feather.Provides; @@ -48,9 +49,9 @@ public RequestQueue provideRequestQueue() { @Provides @Singleton - public FileCache provideFileCache() { + public FileCache provideFileCache(FileManager fileManager) { Logger.d(AppModule.DI_TAG, "File cache"); - return new FileCache(new File(getCacheDir(), "filecache")); + return new FileCache(getCacheDir(), fileManager); } private File getCacheDir() { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/RepositoryModule.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/RepositoryModule.java index 0aeeca3901..11f47458c0 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/RepositoryModule.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/di/RepositoryModule.java @@ -24,6 +24,7 @@ import com.github.adamantcheese.chan.core.repository.SavedThreadLoaderRepository; import com.github.adamantcheese.chan.core.repository.SiteRepository; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; import com.google.gson.Gson; import org.codejargon.feather.Provides; @@ -37,10 +38,11 @@ public class RepositoryModule { public ImportExportRepository provideImportExportRepository( DatabaseManager databaseManager, DatabaseHelper databaseHelper, - Gson gson + Gson gson, + FileManager fileManager ) { Logger.d(AppModule.DI_TAG, "Import export repository"); - return new ImportExportRepository(databaseManager, databaseHelper, gson); + return new ImportExportRepository(databaseManager, databaseHelper, gson, fileManager); } @Provides @@ -71,8 +73,11 @@ public LastReplyRepository provideLastReplyRepository() { @Provides @Singleton - public SavedThreadLoaderRepository provideSavedThreadLoaderRepository(Gson gson) { + public SavedThreadLoaderRepository provideSavedThreadLoaderRepository( + Gson gson, + FileManager fileManager + ) { Logger.d(AppModule.DI_TAG, "Saved thread loader repository"); - return new SavedThreadLoaderRepository(gson); + return new SavedThreadLoaderRepository(gson, fileManager); } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/image/ImageLoaderV2.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/image/ImageLoaderV2.java index c30a743215..4f72e59ebc 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/image/ImageLoaderV2.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/image/ImageLoaderV2.java @@ -7,19 +7,24 @@ import com.android.volley.VolleyError; import com.android.volley.toolbox.ImageLoader; -import com.android.volley.toolbox.ImageLoader.ImageContainer; -import com.android.volley.toolbox.ImageLoader.ImageListener; import com.github.adamantcheese.chan.core.manager.ThreadSaveManager; import com.github.adamantcheese.chan.core.model.PostImage; import com.github.adamantcheese.chan.core.model.orm.Loadable; -import com.github.adamantcheese.chan.core.settings.ChanSettings; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; import com.github.adamantcheese.chan.utils.BackgroundUtils; import com.github.adamantcheese.chan.utils.Logger; import com.github.adamantcheese.chan.utils.StringUtils; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.FileSegment; +import com.github.k1rakishou.fsaf.file.Segment; -import java.io.File; +import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -27,20 +32,24 @@ public class ImageLoaderV2 { private static final String TAG = "ImageLoaderV2"; private ImageLoader imageLoader; + private FileManager fileManager; + private Executor diskLoaderExecutor = Executors.newSingleThreadExecutor(); private Handler mainThreadHandler = new Handler(Looper.getMainLooper()); - public ImageLoaderV2(ImageLoader imageLoader) { + public ImageLoaderV2(ImageLoader imageLoader, FileManager fileManager) { this.imageLoader = imageLoader; + this.fileManager = fileManager; } - public ImageContainer getImage( + public ImageLoader.ImageContainer getImage( boolean isThumbnail, Loadable loadable, PostImage postImage, int width, int height, - ImageListener imageListener) { + ImageLoader.ImageListener imageListener + ) { if (!BackgroundUtils.isMainThread()) { throw new RuntimeException("Must be executed on the main thread!"); } @@ -84,108 +93,153 @@ public ImageContainer getImage( postImage.spoiler, imageListener, width, - height); + height + ); } else { return imageLoader.get( postImage.getThumbnailUrl().toString(), imageListener, width, - height); + height + ); } } - public ImageContainer getFromDisk( + public ImageLoader.ImageContainer getFromDisk( Loadable loadable, String filename, boolean isSpoiler, - ImageListener imageListener, + ImageLoader.ImageListener imageListener, int width, - int height) { + int height + ) { if (!BackgroundUtils.isMainThread()) { throw new RuntimeException("Must be executed on the main thread!"); } - ImageContainer container = null; + ImageLoader.ImageContainer container = null; + try { @SuppressWarnings("JavaReflectionMemberAccess") - Constructor c = ImageContainer.class.getConstructor(ImageLoader.class, Bitmap.class, String.class, String.class, ImageListener.class); + Constructor c = ImageLoader.ImageContainer.class.getConstructor( + ImageLoader.class, + Bitmap.class, + String.class, + String.class, + ImageLoader.ImageListener.class + ); + c.setAccessible(true); - container = (ImageContainer) c.newInstance(imageLoader, null, null, null, imageListener); + container = (ImageLoader.ImageContainer) c.newInstance( + imageLoader, + null, + null, + null, + imageListener + ); + } catch (Exception failedSomething) { return container; } - ImageContainer finalContainer = container; + final ImageLoader.ImageContainer finalContainer = container; diskLoaderExecutor.execute(() -> { - String imageDir; - if (isSpoiler) { - imageDir = ThreadSaveManager.getBoardSubDir(loadable); - } else { - imageDir = ThreadSaveManager.getImagesSubDir(loadable); - } + try { + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + throw new IOException("Base local threads directory does not exist"); + } - File fullImagePath = new File(ChanSettings.saveLocation.get(), imageDir); - File imageOnDiskFile = new File(fullImagePath, filename); - String imageOnDisk = imageOnDiskFile.getAbsolutePath(); - - if (!imageOnDiskFile.exists() || !imageOnDiskFile.isFile() || !imageOnDiskFile.canRead()) { - String errorMessage = "Could not load image from the disk: " + - "(path = " + imageOnDiskFile.getAbsolutePath() + - ", exists = " + imageOnDiskFile.exists() + - ", isFile = " + imageOnDiskFile.isFile() + - ", canRead = " + imageOnDiskFile.canRead() + ")"; - Logger.e(TAG, errorMessage); - - mainThreadHandler.post(() -> { - if (imageListener != null) { - imageListener.onErrorResponse(new VolleyError(errorMessage)); - } - }); - return; - } + AbstractFile baseDirFile = fileManager.newBaseDirectoryFile( + LocalThreadsBaseDirectory.class + ); - // Image exists on the disk - try to load it and put in the cache - BitmapFactory.Options bitmapOptions = new BitmapFactory.Options(); - bitmapOptions.outWidth = width; - bitmapOptions.outHeight = height; + if (baseDirFile == null) { + throw new IOException("getFromDisk() " + + "fileManager.newLocalThreadFile() returned null"); + } - Bitmap bitmap = BitmapFactory.decodeFile(imageOnDisk, bitmapOptions); - if (bitmap == null) { - Logger.e(TAG, "Could not decode bitmap"); + List segments = new ArrayList<>(); - mainThreadHandler.post(() -> { - if (imageListener != null) { - imageListener.onErrorResponse(new VolleyError("Could not decode bitmap")); - } - }); - return; - } + if (isSpoiler) { + segments.addAll(ThreadSaveManager.getBoardSubDir(loadable)); + } else { + segments.addAll(ThreadSaveManager.getImagesSubDir(loadable)); + } - mainThreadHandler.post(() -> { - try { - Field bitmapField = finalContainer.getClass().getDeclaredField("mBitmap"); - Field urlField = finalContainer.getClass().getDeclaredField("mRequestUrl"); - bitmapField.setAccessible(true); - urlField.setAccessible(true); - bitmapField.set(finalContainer, bitmap); - urlField.set(finalContainer, imageOnDisk); - - if (imageListener != null) { - imageListener.onResponse(finalContainer, true); - } - } catch (Exception e) { - if (imageListener != null) { - imageListener.onErrorResponse(new VolleyError("Couldn't set fields")); + segments.add(new FileSegment(filename)); + AbstractFile imageOnDiskFile = baseDirFile.clone(segments); + + boolean exists = fileManager.exists(imageOnDiskFile); + boolean isFile = fileManager.isFile(imageOnDiskFile); + boolean canRead = fileManager.canRead(imageOnDiskFile); + + if (!exists || !isFile || !canRead) { + String errorMessage = "Could not load image from the disk: " + + "(path = " + imageOnDiskFile.getFullPath() + + ", exists = " + exists + + ", isFile = " + isFile + + ", canRead = " + canRead + ")"; + + Logger.e(TAG, errorMessage); + postError(imageListener, errorMessage); + return; + } + + try (InputStream inputStream = fileManager.getInputStream(imageOnDiskFile)) { + // Image exists on the disk - try to load it and put in the cache + BitmapFactory.Options bitmapOptions = new BitmapFactory.Options(); + bitmapOptions.outWidth = width; + bitmapOptions.outHeight = height; + + Bitmap bitmap = BitmapFactory.decodeStream( + inputStream, + null, + bitmapOptions); + + if (bitmap == null) { + Logger.e(TAG, "Could not decode bitmap"); + postError(imageListener, "Could not decode bitmap"); + return; } + + mainThreadHandler.post(() -> { + try { + Field bitmapField = finalContainer.getClass().getDeclaredField("mBitmap"); + Field urlField = finalContainer.getClass().getDeclaredField("mRequestUrl"); + bitmapField.setAccessible(true); + urlField.setAccessible(true); + bitmapField.set(finalContainer, bitmap); + urlField.set(finalContainer, imageOnDiskFile.getFullPath()); + + if (imageListener != null) { + imageListener.onResponse(finalContainer, true); + } + } catch (Exception e) { + postError(imageListener, "Couldn't set fields"); + } + }); } - }); + } catch (Exception e) { + String message = "Could not get an image from the disk, error message = " + + e.getMessage(); + + postError(imageListener, message); + } }); return container; } - public void cancelRequest(ImageContainer container) { + private void postError(ImageLoader.ImageListener imageListener, String message) { + mainThreadHandler.post(() -> { + if (imageListener != null) { + imageListener.onErrorResponse(new VolleyError(message)); + } + }); + } + + public void cancelRequest(ImageLoader.ImageContainer container) { if (!BackgroundUtils.isMainThread()) { throw new RuntimeException("Must be executed on the main thread!"); } @@ -193,9 +247,10 @@ public void cancelRequest(ImageContainer container) { container.cancelRequest(); } - public ImageContainer get( + public ImageLoader.ImageContainer get( String requestUrl, - ImageListener listener) { + ImageLoader.ImageListener listener + ) { if (!BackgroundUtils.isMainThread()) { throw new RuntimeException("Must be executed on the main thread!"); } @@ -203,11 +258,12 @@ public ImageContainer get( return imageLoader.get(requestUrl, listener); } - public ImageContainer get( + public ImageLoader.ImageContainer get( String requestUrl, - ImageListener listener, + ImageLoader.ImageListener listener, int width, - int height) { + int height + ) { if (!BackgroundUtils.isMainThread()) { throw new RuntimeException("Must be executed on the main thread!"); } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/SavedThreadLoaderManager.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/SavedThreadLoaderManager.java deleted file mode 100644 index e635b9ecc0..0000000000 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/SavedThreadLoaderManager.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.github.adamantcheese.chan.core.manager; - -import androidx.annotation.Nullable; - -import com.github.adamantcheese.chan.core.mapper.ThreadMapper; -import com.github.adamantcheese.chan.core.model.ChanThread; -import com.github.adamantcheese.chan.core.model.orm.Loadable; -import com.github.adamantcheese.chan.core.model.save.SerializableThread; -import com.github.adamantcheese.chan.core.repository.SavedThreadLoaderRepository; -import com.github.adamantcheese.chan.core.settings.ChanSettings; -import com.github.adamantcheese.chan.utils.BackgroundUtils; -import com.github.adamantcheese.chan.utils.Logger; - -import java.io.File; -import java.io.IOException; - -import javax.inject.Inject; - -public class SavedThreadLoaderManager { - private final static String TAG = "SavedThreadLoaderManager"; - - private SavedThreadLoaderRepository savedThreadLoaderRepository; - - @Inject - public SavedThreadLoaderManager(SavedThreadLoaderRepository savedThreadLoaderRepository) { - this.savedThreadLoaderRepository = savedThreadLoaderRepository; - } - - @Nullable - public ChanThread loadSavedThread(Loadable loadable) { - if (BackgroundUtils.isMainThread()) { - throw new RuntimeException("Cannot be executed on the main thread!"); - } - - String threadSubDir = ThreadSaveManager.getThreadSubDir(loadable); - File threadSaveDir = new File(ChanSettings.saveLocation.get(), threadSubDir); - - if (!threadSaveDir.exists() || !threadSaveDir.isDirectory()) { - Logger.e(TAG, "threadSaveDir does not exist or is not a directory: " - + "(path = " + threadSaveDir.getAbsolutePath() - + ", exists = " + threadSaveDir.exists() - + ", isDir = " + threadSaveDir.isDirectory() + ")"); - return null; - } - - File threadFile = new File(threadSaveDir, SavedThreadLoaderRepository.THREAD_FILE_NAME); - if (!threadFile.exists() || !threadFile.isFile() || !threadFile.canRead()) { - Logger.e(TAG, "threadFile does not exist or not a file or cannot be read: " + - "(path = " + threadFile.getAbsolutePath() - + ", exists = " + threadFile.exists() - + ", isFile = " + threadFile.isFile() - + ", canRead = " + threadFile.canRead() + ")"); - return null; - } - - File threadSaveDirImages = new File(threadSaveDir, "images"); - if (!threadSaveDirImages.exists() || !threadSaveDirImages.isDirectory()) { - Logger.e(TAG, "threadSaveDirImages does not exist or is not a directory: " - + "(path = " + threadSaveDirImages.getAbsolutePath() - + ", exists = " + threadSaveDirImages.exists() - + ", isDir = " + threadSaveDirImages.isDirectory() + ")"); - return null; - } - - try { - SerializableThread serializableThread = savedThreadLoaderRepository - .loadOldThreadFromJsonFile(threadSaveDir); - if (serializableThread == null) { - Logger.e(TAG, "Could not load thread from json"); - return null; - } - - return ThreadMapper.fromSerializedThread(loadable, serializableThread); - } catch (IOException | SavedThreadLoaderRepository.OldThreadTakesTooMuchSpace e) { - Logger.e(TAG, "Could not load saved thread", e); - return null; - } - } - -} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/SavedThreadLoaderManager.kt b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/SavedThreadLoaderManager.kt new file mode 100644 index 0000000000..a813a39d70 --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/SavedThreadLoaderManager.kt @@ -0,0 +1,95 @@ +package com.github.adamantcheese.chan.core.manager + +import com.github.adamantcheese.chan.core.mapper.ThreadMapper +import com.github.adamantcheese.chan.core.model.ChanThread +import com.github.adamantcheese.chan.core.model.orm.Loadable +import com.github.adamantcheese.chan.core.repository.SavedThreadLoaderRepository +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory +import com.github.adamantcheese.chan.utils.BackgroundUtils +import com.github.adamantcheese.chan.utils.Logger +import com.github.k1rakishou.fsaf.FileManager +import com.github.k1rakishou.fsaf.file.DirectorySegment +import com.github.k1rakishou.fsaf.file.FileSegment +import java.io.IOException +import javax.inject.Inject + +class SavedThreadLoaderManager @Inject constructor( + private val savedThreadLoaderRepository: SavedThreadLoaderRepository, + private val fileManager: FileManager +) { + + fun loadSavedThread(loadable: Loadable): ChanThread? { + if (BackgroundUtils.isMainThread()) { + throw RuntimeException("Cannot be executed on the main thread!") + } + + val baseDir = fileManager.newBaseDirectoryFile() + if (baseDir == null) { + Logger.e(TAG, "loadSavedThread() fileManager.newLocalThreadFile() returned null") + return null + } + + val threadSaveDir = baseDir + .clone(ThreadSaveManager.getThreadSubDir(loadable)) + + val threadSaveDirExists = fileManager.exists(threadSaveDir) + val threadSaveDirIsDirectory = fileManager.isDirectory(threadSaveDir) + + if (!threadSaveDirExists || !threadSaveDirIsDirectory) { + Logger.e(TAG, "threadSaveDir does not exist or is not a directory: " + + "(path = " + threadSaveDir.getFullPath() + + ", exists = " + threadSaveDirExists + + ", isDir = " + threadSaveDirIsDirectory + ")") + return null + } + + val threadFile = threadSaveDir + .clone(FileSegment(SavedThreadLoaderRepository.THREAD_FILE_NAME)) + + val threadFileExists = fileManager.exists(threadFile) + val threadFileIsFile = fileManager.isFile(threadFile) + val threadFileCanRead = fileManager.canRead(threadFile) + + if (!threadFileExists || !threadFileIsFile || !threadFileCanRead) { + Logger.e(TAG, "threadFile does not exist or not a file or cannot be read: " + + "(path = " + threadFile.getFullPath() + + ", exists = " + threadFileExists + + ", isFile = " + threadFileIsFile + + ", canRead = " + threadFileCanRead + ")") + return null + } + + val threadSaveDirImages = threadSaveDir + .clone(DirectorySegment("images")) + + val imagesDirExists = fileManager.exists(threadSaveDirImages) + val imagesDirIsDir = fileManager.isDirectory(threadSaveDirImages) + + if (!imagesDirExists || !imagesDirIsDir) { + Logger.e(TAG, "threadSaveDirImages does not exist or is not a directory: " + + "(path = " + threadSaveDirImages.getFullPath() + + ", exists = " + imagesDirExists + + ", isDir = " + imagesDirIsDir + ")") + return null + } + + try { + val serializableThread = savedThreadLoaderRepository + .loadOldThreadFromJsonFile(threadSaveDir) + if (serializableThread == null) { + Logger.e(TAG, "Could not load thread from json") + return null + } + + return ThreadMapper.fromSerializedThread(loadable, serializableThread) + } catch (e: IOException) { + Logger.e(TAG, "Could not load saved thread", e) + return null + } + } + + companion object { + private const val TAG = "SavedThreadLoaderManager" + } + +} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/ThreadSaveManager.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/ThreadSaveManager.java index c36aa7fda7..40e9fc55ea 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/ThreadSaveManager.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/ThreadSaveManager.java @@ -14,17 +14,22 @@ import com.github.adamantcheese.chan.core.model.save.SerializableThread; import com.github.adamantcheese.chan.core.repository.SavedThreadLoaderRepository; import com.github.adamantcheese.chan.core.settings.ChanSettings; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; import com.github.adamantcheese.chan.utils.BackgroundUtils; import com.github.adamantcheese.chan.utils.IOUtils; import com.github.adamantcheese.chan.utils.Logger; import com.github.adamantcheese.chan.utils.StringUtils; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.DirectorySegment; +import com.github.k1rakishou.fsaf.file.FileSegment; +import com.github.k1rakishou.fsaf.file.Segment; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -54,6 +59,7 @@ public class ThreadSaveManager { private static final String TAG = "ThreadSaveManager"; private static final int OKHTTP_TIMEOUT_SECONDS = 30; + private static final int REQUEST_BUFFERING_TIME_SECONDS = 30; private static final int MAX_RETRY_ATTEMPTS = 3; private static final boolean VERBOSE_LOG = false; @@ -64,9 +70,10 @@ public class ThreadSaveManager { public static final String ORIGINAL_FILE_NAME = "original"; public static final String NO_MEDIA_FILE_NAME = ".nomedia"; - private DatabaseManager databaseManager; - private DatabaseSavedThreadManager databaseSavedThreadManager; - private SavedThreadLoaderRepository savedThreadLoaderRepository; + private final DatabaseManager databaseManager; + private final DatabaseSavedThreadManager databaseSavedThreadManager; + private final SavedThreadLoaderRepository savedThreadLoaderRepository; + private final FileManager fileManager; @GuardedBy("itself") private final Map activeDownloads = new HashMap<>(); @@ -99,10 +106,12 @@ private static int getThreadsCountForDownloaderExecutor() { @Inject public ThreadSaveManager( DatabaseManager databaseManager, - SavedThreadLoaderRepository savedThreadLoaderRepository) { + SavedThreadLoaderRepository savedThreadLoaderRepository, + FileManager fileManager) { this.databaseManager = databaseManager; this.savedThreadLoaderRepository = savedThreadLoaderRepository; this.databaseSavedThreadManager = databaseManager.getDatabaseSavedThreadManager(); + this.fileManager = fileManager; initRxWorkerQueue(); } @@ -118,71 +127,128 @@ private void initRxWorkerQueue() { // Just buffer everything in the internal queue when the consumers are slow (and they are // always slow because they have to download images, but we check whether a download request // is already enqueued so it's okay for us to rely on the buffering) - workerQueue.onBackpressureBuffer().concatMapSingle((loadable) -> { - SaveThreadParameters parameters; - List postsToSave = new ArrayList<>(); - - synchronized (activeDownloads) { - Logger.d(TAG, "New downloading request started " + loadableToString(loadable) - + ", activeDownloads count = " + activeDownloads.size()); - parameters = activeDownloads.get(loadable); - - if (parameters != null) { - // Use a copy of the list to avoid ConcurrentModificationExceptions - postsToSave.addAll(parameters.postsToSave); - } - } + workerQueue + .onBackpressureBuffer() + // Collect all the request over some time + .buffer(REQUEST_BUFFERING_TIME_SECONDS, TimeUnit.SECONDS) + .concatMap(this::processCollectedRequests) + .subscribe( + (res) -> { + // OK + }, + (error) -> Logger.e(TAG, + "Uncaught exception!!! workerQueue is in error state now!!! " + + "This should not happen!!!", error), + () -> Logger.e(TAG, + "workerQueue stream has completed!!! This should not happen!!!")); + } - if (parameters == null) { - Logger.e(TAG, "Could not find download parameters for loadable " - + loadableToString(loadable)); - return Single.just(false); - } + private Flowable processCollectedRequests(List loadableList) { + if (loadableList.isEmpty()) { + return Flowable.just(true); + } + + Logger.d(TAG, "Collected " + loadableList.size() + " local thread download requests"); - return saveThreadInternal(loadable, postsToSave) - // Use the executor's thread to process the queue elements. Everything above - // will executed on this executor's threads. - .subscribeOn(Schedulers.from(executorService)) - // Everything below will be executed on the main thread - .observeOn(AndroidSchedulers.mainThread()) - // Handle errors - .doOnError((error) -> onDownloadingError(error, loadable)) - // Handle results - .doOnSuccess((result) -> onDownloadingCompleted(result, loadable)) - .doOnEvent((result, error) -> { - synchronized (activeDownloads) { - Logger.d(TAG, "Downloading request has completed for loadable " - + loadableToString(loadable) + - ", activeDownloads count = " - + activeDownloads.size()); + AbstractFile baseLocalThreadsDirectory = fileManager.newBaseDirectoryFile( + LocalThreadsBaseDirectory.class + ); + + if (baseLocalThreadsDirectory == null) { + Logger.e(TAG, "LocalThreadsBaseDirectory is not registered!"); + return Flowable.just(false); + } + + /** + * Create an in-memory snapshot of a directory with files and sub directories with their + * files. This will SIGNIFICANTLY improve the files operations speed until this snapshot is + * released. For this reason we collect the request so that we can create a snapshot process + * all of the collected request in one big batch and then release the snapshot. + * */ + Logger.d(TAG, "Snapshot created"); + fileManager.createSnapshot(baseLocalThreadsDirectory, true); + + return Flowable.fromIterable(loadableList) + .concatMap((loadable) -> { + SaveThreadParameters parameters; + List postsToSave = new ArrayList<>(); + + synchronized (activeDownloads) { + Logger.d(TAG, "New downloading request started " + loadableToString(loadable) + + ", activeDownloads count = " + activeDownloads.size()); + parameters = activeDownloads.get(loadable); + + if (parameters != null) { + // Use a copy of the list to avoid ConcurrentModificationExceptions + postsToSave.addAll(parameters.postsToSave); } - }) - // Suppress all of the exceptions so that the stream does not complete - .onErrorReturnItem(false); - }).subscribe( - (res) -> { - }, - (error) -> Logger.e(TAG, "Uncaught exception!!! workerQueue is in error state now!!! This should not happen!!!", error), - () -> Logger.e(TAG, "workerQueue stream has completed!!! This should not happen!!!")); + } + + if (parameters == null) { + Logger.e(TAG, "Could not find download parameters for loadable " + + loadableToString(loadable)); + return Flowable.just(false); + } + + return saveThreadInternal(loadable, postsToSave) + // Use the executor's thread to process the queue elements. Everything above + // will executed on this executor's threads. + .subscribeOn(Schedulers.from(executorService)) + // Everything below will be executed on the main thread + .observeOn(AndroidSchedulers.mainThread()) + // Handle errors + .doOnError((error) -> onDownloadingError(error, loadable)) + // Handle results + .doOnSuccess((result) -> onDownloadingCompleted(result, loadable)) + .doOnEvent((result, error) -> { + synchronized (activeDownloads) { + Logger.d(TAG, "Downloading request has completed for loadable " + + loadableToString(loadable) + + ", activeDownloads count = " + + activeDownloads.size()); + } + }) + // Suppress all of the exceptions so that the stream does not complete + .onErrorReturnItem(false) + .toFlowable(); + }) + .doOnTerminate(() -> { + /** + * Release the snapshot. It is important to do this. Otherwise the cached files + * will stay in memory and if one of the files will get deleted from the disk + * by the user the snapshot will become dirty meaning that it doesn't contain + * the actual information of the directory. And this may lead to unexpected bugs. + * + * !!! Always release snapshots when you have executed all of your file operations. !!! + * */ + Logger.d(TAG, "Snapshot released"); + fileManager.releaseSnapshot(baseLocalThreadsDirectory); + }); } /** * Enqueues a thread's posts with all the images/webm/etc to be saved to the disk. */ - public void enqueueThreadToSave( + public boolean enqueueThreadToSave( Loadable loadable, List postsToSave) { if (!BackgroundUtils.isMainThread()) { throw new RuntimeException("Must be executed on the main thread"); } + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + Logger.e(TAG, "Base local threads directory does not exist, can't start downloading"); + return false; + } + synchronized (activeDownloads) { // Check if a thread is already being downloaded if (activeDownloads.containsKey(loadable)) { if (VERBOSE_LOG) { Logger.d(TAG, "Downloader is already running for " + loadableToString(loadable)); } - return; + + return true; } } @@ -199,8 +265,21 @@ public void enqueueThreadToSave( } } + Logger.d(TAG, "Enqueued new download request for loadable " + loadableToString(loadable)); + // Enqueue the download workerQueue.onNext(loadable); + return true; + } + + public boolean isThereAtLeastOneActiveDownload() { + boolean hasActiveDownloads = false; + + synchronized (activeDownloads) { + hasActiveDownloads = !activeDownloads.isEmpty(); + } + + return hasActiveDownloads; } /** @@ -303,13 +382,19 @@ private void onDownloadingError(Throwable error, Loadable loadable) { * @param loadable is a unique identifier of a thread we are saving. * @param postsToSave posts of a thread to be saved. */ - @SuppressLint("CheckResult") private Single saveThreadInternal(@NonNull Loadable loadable, List postsToSave) { return Single.fromCallable(() -> { if (BackgroundUtils.isMainThread()) { throw new RuntimeException("Cannot be executed on the main thread!"); } + if (ChanSettings.localThreadLocation.get().isEmpty() + && ChanSettings.localThreadsLocationUri.get().isEmpty()) { + // wtf??? + throw new IllegalStateException("Both localThreadLocation and " + + "localThreadLocationUri are empty!"); + } + if (!isCurrentDownloadRunning(loadable)) { // This download was cancelled or stopped while waiting in the queue. Logger.d(TAG, "Download for loadable " + loadableToString(loadable) + @@ -320,33 +405,26 @@ private Single saveThreadInternal(@NonNull Loadable loadable, List newPosts = filterAndSortPosts(threadSaveDirImages, loadable, postsToSave); if (newPosts.isEmpty()) { @@ -360,13 +438,6 @@ private Single saveThreadInternal(@NonNull Loadable loadable, List saveThreadInternal(@NonNull Loadable loadable, List downloadSpoilerImage( + downloadInternal( loadable, + threadSaveDirImages, boardSaveDir, - spoilerImageUrl) - ) - .flatMap((res) -> { - // For each post create a new inner rx stream (so they can be processed in parallel) - return Flowable.fromIterable(newPosts) - // Here we create a separate reactive stream for each image request. - // But we use an executor service with limited threads amount, so there - // will be only this much at a time. - // | - // / | \ - // / | \ - // / | \ - // V V V // Separate streams. - // | | | - // o o o // Download images in parallel. - // | | | - // V V V // Combine them back to a single stream. - // \ | / - // \ | / - // \ | / - // | - .flatMap((post) -> downloadImages( - loadable, - threadSaveDirImages, - post, - currentImageDownloadIndex, - postsWithImages, - imageDownloadsWithIoError, - maxImageIoErrors)) - .toList() - .doOnSuccess((list) -> Logger.d(TAG, "PostImage download result list = " + list)); - }) - .flatMap((res) -> Single.defer(() -> { - if (!isCurrentDownloadRunning(loadable)) { - if (isCurrentDownloadStopped(loadable)) { - Logger.d(TAG, "Thread downloading has been stopped " - + loadableToString(loadable)); - } else { - Logger.d(TAG, "Thread downloading has been canceled " - + loadableToString(loadable)); - } - - return Single.just(false); - } - - updateLastSavedPostNo(loadable, newPosts); - - Logger.d(TAG, "Successfully updated a thread " + loadableToString(loadable)); - return Single.just(true); - })) - // Have to use blockingGet here. This is a place where all of the exception will come - // out from - .blockingGet(); + newPosts, + postsWithImages, + maxImageIoErrors, + spoilerImageUrl, + currentImageDownloadIndex, + imageDownloadsWithIoError); } finally { if (shouldDeleteDownloadedFiles(loadable)) { if (isCurrentDownloadStopped(loadable)) { @@ -461,6 +486,129 @@ private Single saveThreadInternal(@NonNull Loadable loadable, List newPosts, + int postsWithImages, + int maxImageIoErrors, + HttpUrl spoilerImageUrl, + AtomicInteger currentImageDownloadIndex, + AtomicInteger imageDownloadsWithIoError + ) { + Single.fromCallable(() -> downloadSpoilerImage( + loadable, + boardSaveDir, + spoilerImageUrl) + ) + .flatMap((res) -> { + // For each post create a new inner rx stream (so they can be processed in parallel) + return Flowable.fromIterable(newPosts) + // Here we create a separate reactive stream for each image request. + // But we use an executor service with limited threads amount, so there + // will be only this much at a time. + // | + // / | \ + // / | \ + // / | \ + // V V V // Separate streams. + // | | | + // o o o // Download images in parallel. + // | | | + // V V V // Combine them back to a single stream. + // \ | / + // \ | / + // \ | / + // | + .flatMap((post) -> downloadImages( + loadable, + threadSaveDirImages, + post, + currentImageDownloadIndex, + postsWithImages, + imageDownloadsWithIoError, + maxImageIoErrors)) + + .toList() + .doOnSuccess((list) -> Logger.d(TAG, "PostImage download result list = " + list)); + }) + .flatMap((res) -> { + return Single.defer(() -> { + return tryUpdateLastSavedPostNo(loadable, newPosts); + }); + }) + // Have to use blockingGet here. This is a place where all of the exception will come + // out from + .blockingGet(); + } + + private Single tryUpdateLastSavedPostNo(@NonNull Loadable loadable, List newPosts) { + if (!isCurrentDownloadRunning(loadable)) { + if (isCurrentDownloadStopped(loadable)) { + Logger.d(TAG, "Thread downloading has been stopped " + + loadableToString(loadable)); + } else { + Logger.d(TAG, "Thread downloading has been canceled " + + loadableToString(loadable)); + } + + return Single.just(false); + } + + updateLastSavedPostNo(loadable, newPosts); + + Logger.d(TAG, "Successfully updated a thread " + loadableToString(loadable)); + return Single.just(true); + } + + private AbstractFile getBoardSaveDir(Loadable loadable) throws IOException { + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + throw new IOException("getBoardSaveDir() Base local threads directory does not exist"); + } + + AbstractFile baseDir = fileManager.newBaseDirectoryFile(LocalThreadsBaseDirectory.class); + if (baseDir == null) { + throw new IOException("getBoardSaveDir() fileManager.newLocalThreadFile() returned null"); + } + + return baseDir + .clone(getBoardSubDir(loadable)); + } + + private AbstractFile getThreadSaveDir(Loadable loadable) throws IOException { + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + throw new IOException("getThreadSaveDir() Base local threads directory does not exist"); + } + + AbstractFile baseDir = fileManager.newBaseDirectoryFile(LocalThreadsBaseDirectory.class); + if (baseDir == null) { + throw new IOException("getThreadSaveDir() fileManager.newLocalThreadFile() returned null"); + } + + return baseDir + .clone(getThreadSubDir(loadable)); + } + + private void dealWithMediaScanner(AbstractFile threadSaveDirImages) throws CouldNotCreateNoMediaFile { + AbstractFile noMediaFile = threadSaveDirImages + .clone(new FileSegment(NO_MEDIA_FILE_NAME)); + + if (!ChanSettings.allowMediaScannerToScanLocalThreads.get()) { + // .nomedia file being in the images directory "should" prevent media scanner from + // scanning this directory + if (!fileManager.exists(noMediaFile) && fileManager.create(noMediaFile) == null) { + throw new CouldNotCreateNoMediaFile(threadSaveDirImages); + } + } else { + if (fileManager.exists(noMediaFile) && !fileManager.delete(noMediaFile)) { + Logger.e(TAG, "Could not delete .nomedia file from directory " + + threadSaveDirImages.getFullPath()); + } + } + } + private int calculateMaxImageIoErrors(int postsWithImages) { int maxIoErrors = (int) (((float) postsWithImages / 100f) * 5f); if (maxIoErrors == 0) { @@ -476,7 +624,9 @@ private int calculateMaxImageIoErrors(int postsWithImages) { private void updateLastSavedPostNo(Loadable loadable, List newPosts) { // Update the latests saved post id in the database int lastPostNo = newPosts.get(newPosts.size() - 1).no; - databaseManager.runTask(databaseSavedThreadManager.updateLastSavedPostNo(loadable.id, lastPostNo)); + databaseManager.runTask(databaseSavedThreadManager.updateLastSavedPostNo( + loadable.id, + lastPostNo)); } /** @@ -499,70 +649,98 @@ private int calculateAmountOfPostsWithImages(List newPosts) { * If a post has at least one image that has not been downloaded yet it will be * redownloaded again */ - private List filterAndSortPosts(File threadSaveDirImages, Loadable loadable, List inputPosts) { - // Filter out already saved posts (by lastSavedPostNo) - int lastSavedPostNo = databaseManager.runTask(databaseSavedThreadManager.getLastSavedPostNo(loadable.id)); - - // Use HashSet to avoid duplicates - Set filteredPosts = new HashSet<>(inputPosts.size() / 2); - - // lastSavedPostNo == 0 means that we don't have this thread downloaded yet - if (lastSavedPostNo > 0) { - for (Post post : inputPosts) { - if (!checkWhetherAllPostImagesAreAlreadySaved(threadSaveDirImages, post)) { - // Some of the post's images could not be downloaded during the previous download - // so we need to download them now - if (VERBOSE_LOG) { - Logger.d(TAG, "Found not downloaded yet images for a post " + post.no + - ", for loadable " + loadableToString(loadable)); + private List filterAndSortPosts( + AbstractFile threadSaveDirImages, + Loadable loadable, + List inputPosts + ) { + long start = System.currentTimeMillis(); + + try { + // Filter out already saved posts (by lastSavedPostNo) + int lastSavedPostNo = databaseManager.runTask( + databaseSavedThreadManager.getLastSavedPostNo(loadable.id)); + + // Use HashSet to avoid duplicates + Set filteredPosts = new HashSet<>(inputPosts.size() / 2); + + // lastSavedPostNo == 0 means that we don't have this thread downloaded yet + if (lastSavedPostNo > 0) { + for (Post post : inputPosts) { + if (!checkWhetherAllPostImagesAreAlreadySaved(threadSaveDirImages, post)) { + // Some of the post's images could not be downloaded during the previous download + // so we need to download them now + if (VERBOSE_LOG) { + Logger.d(TAG, "Found not downloaded yet images for a post " + post.no + + ", for loadable " + loadableToString(loadable)); + } + + filteredPosts.add(post); + continue; } - filteredPosts.add(post); - continue; + if (post.no > lastSavedPostNo) { + filteredPosts.add(post); + } } + } else { + filteredPosts.addAll(inputPosts); + } - if (post.no > lastSavedPostNo) { - filteredPosts.add(post); - } + if (filteredPosts.isEmpty()) { + return Collections.emptyList(); } - } else { - filteredPosts.addAll(inputPosts); - } - if (filteredPosts.isEmpty()) { - return Collections.emptyList(); - } + // And sort them + List posts = new ArrayList<>(filteredPosts); + Collections.sort(posts, postComparator); - // And sort them - List posts = new ArrayList<>(filteredPosts); - Collections.sort(posts, postComparator); + return posts; + } finally { + long delta = System.currentTimeMillis() - start; + String loadableString = loadableToString(loadable); - return posts; + Logger.d(TAG, "filterAndSortPosts() completed in " + + delta + "ms for loadable " + loadableString + + " with " + inputPosts.size() + " posts"); + } } - private boolean checkWhetherAllPostImagesAreAlreadySaved(File threadSaveDirImages, Post post) { + private boolean checkWhetherAllPostImagesAreAlreadySaved( + AbstractFile threadSaveDirImages, + Post post + ) { for (PostImage postImage : post.images) { { String originalImageFilename = postImage.serverFilename + "_" + ORIGINAL_FILE_NAME + "." + postImage.extension; - File originalImage = new File(threadSaveDirImages, originalImageFilename); - if (!originalImage.exists()) { + AbstractFile originalImage = threadSaveDirImages + .clone(new FileSegment(originalImageFilename)); + + if (!fileManager.exists(originalImage)) { return false; } - if (!originalImage.canRead()) { - if (!originalImage.delete()) { + if (!fileManager.canRead(originalImage)) { + if (!fileManager.delete(originalImage)) { Logger.e(TAG, "Could not delete originalImage with path " - + originalImage.getAbsolutePath()); + + originalImage.getFullPath()); } return false; } - if (originalImage.length() == 0L) { - if (!originalImage.delete()) { + long length = fileManager.getLength(originalImage); + if (length == -1L) { + Logger.e(TAG, "originalImage.getLength() returned -1, " + + "originalImagePath = " + originalImage.getFullPath()); + return false; + } + + if (length == 0L) { + if (!fileManager.delete(originalImage)) { Logger.e(TAG, "Could not delete originalImage with path " - + originalImage.getAbsolutePath()); + + originalImage.getFullPath()); } return false; } @@ -574,23 +752,32 @@ private boolean checkWhetherAllPostImagesAreAlreadySaved(File threadSaveDirImage String thumbnailImageFilename = postImage.serverFilename + "_" + THUMBNAIL_FILE_NAME + "." + thumbnailExtension; - File thumbnailImage = new File(threadSaveDirImages, thumbnailImageFilename); - if (!thumbnailImage.exists()) { + AbstractFile thumbnailImage = threadSaveDirImages + .clone(new FileSegment(thumbnailImageFilename)); + + if (!fileManager.exists(thumbnailImage)) { return false; } - if (!thumbnailImage.canRead()) { - if (!thumbnailImage.delete()) { + if (!fileManager.canRead(thumbnailImage)) { + if (!fileManager.delete(thumbnailImage)) { Logger.e(TAG, "Could not delete thumbnailImage with path " - + thumbnailImage.getAbsolutePath()); + + thumbnailImage.getFullPath()); } return false; } - if (thumbnailImage.length() == 0L) { - if (!thumbnailImage.delete()) { + long length = fileManager.getLength(thumbnailImage); + if (length == -1L) { + Logger.e(TAG, "thumbnailImage.getLength() returned -1, " + + "thumbnailImagePath = " + thumbnailImage.getFullPath()); + return false; + } + + if (length == 0L) { + if (!fileManager.delete(thumbnailImage)) { Logger.e(TAG, "Could not delete thumbnailImage with path " - + thumbnailImage.getAbsolutePath()); + + thumbnailImage.getFullPath()); } return false; } @@ -602,8 +789,9 @@ private boolean checkWhetherAllPostImagesAreAlreadySaved(File threadSaveDirImage private boolean downloadSpoilerImage( Loadable loadable, - File threadSaveDirImages, - HttpUrl spoilerImageUrl) throws IOException { + AbstractFile threadSaveDirImages, + HttpUrl spoilerImageUrl + ) throws IOException { // If the board uses spoiler image - download it if (loadable.board.spoilers && spoilerImageUrl != null) { String spoilerImageExtension = StringUtils.extractFileExtensionFromImageUrl( @@ -615,9 +803,10 @@ private boolean downloadSpoilerImage( } String spoilerImageName = SPOILER_FILE_NAME + "." + spoilerImageExtension; - File spoilerImageFullPath = new File(threadSaveDirImages, spoilerImageName); - if (spoilerImageFullPath.exists()) { + AbstractFile spoilerImageFullPath = threadSaveDirImages + .clone(new FileSegment(spoilerImageName)); + if (fileManager.exists(spoilerImageFullPath)) { // Do nothing if already downloaded return false; } @@ -652,12 +841,13 @@ private HttpUrl getSpoilerImageUrl(List posts) { private Flowable downloadImages( Loadable loadable, - File threadSaveDirImages, + AbstractFile threadSaveDirImages, Post post, AtomicInteger currentImageDownloadIndex, int postsWithImagesCount, AtomicInteger imageDownloadsWithIoError, - int maxImageIoErrors) { + int maxImageIoErrors + ) { if (post.images.isEmpty()) { if (VERBOSE_LOG) { Logger.d(TAG, "Post " + post.no + " contains no images"); @@ -781,7 +971,8 @@ private void addImageToAlreadyDeletedImage(Loadable loadable, String originalNam private void logThreadDownloadingProgress( Loadable loadable, AtomicInteger currentImageDownloadIndex, - int postsWithImagesCount) { + int postsWithImagesCount + ) { // postsWithImagesCount may be 0 so we need to avoid division by zero int count = postsWithImagesCount == 0 ? 1 : postsWithImagesCount; int index = currentImageDownloadIndex.incrementAndGet(); @@ -789,30 +980,30 @@ private void logThreadDownloadingProgress( Logger.d(TAG, "Downloading is in progress for an image with loadable " + loadableToString(loadable) + - ", percent = " + percent + - ", total = " + count + - ", current = " + index); + ", " + index + "/" + count + " (" + percent + "%)"); } private void deleteImageCompletely( - File threadSaveDirImages, + AbstractFile threadSaveDirImages, String filename, String extension) { Logger.d(TAG, "Deleting a file with name " + filename); boolean error = false; - File originalFile = new File(threadSaveDirImages, - filename + "_" + ORIGINAL_FILE_NAME + "." + extension); - if (originalFile.exists()) { - if (!originalFile.delete()) { + AbstractFile originalFile = threadSaveDirImages + .clone(new FileSegment(filename + "_" + ORIGINAL_FILE_NAME + "." + extension)); + + if (fileManager.exists(originalFile)) { + if (!fileManager.delete(originalFile)) { error = true; } } - File thumbnailFile = new File(threadSaveDirImages, - filename + "_" + THUMBNAIL_FILE_NAME + "." + extension); - if (thumbnailFile.exists()) { - if (!thumbnailFile.delete()) { + AbstractFile thumbnailFile = threadSaveDirImages + .clone(new FileSegment(filename + "_" + THUMBNAIL_FILE_NAME + "." + extension)); + + if (fileManager.exists(thumbnailFile)) { + if (!fileManager.delete(thumbnailFile)) { error = true; } } @@ -826,13 +1017,14 @@ private void deleteImageCompletely( * Downloads an image with it's thumbnail and stores them to the disk */ private void downloadImageIntoFile( - File threadSaveDirImages, + AbstractFile threadSaveDirImages, String filename, String originalExtension, String thumbnailExtension, HttpUrl imageUrl, HttpUrl thumbnailUrl, - Loadable loadable) throws IOException, ImageWasAlreadyDeletedException { + Loadable loadable + ) throws IOException, ImageWasAlreadyDeletedException { if (isImageAlreadyDeletedFromTheServer(loadable, filename)) { // We have already tried to download this image and got 404, so it was probably deleted // from the server so there is no point in trying to download it again @@ -871,7 +1063,7 @@ private boolean shouldDownloadImages() { */ private void downloadImage( Loadable loadable, - File threadSaveDirImages, + AbstractFile threadSaveDirImages, String filename, HttpUrl imageUrl) throws IOException, ImageWasAlreadyDeletedException { if (!shouldDownloadImages()) { @@ -881,17 +1073,10 @@ private void downloadImage( return; } - if (!isCurrentDownloadRunning(loadable)) { - if (isCurrentDownloadStopped(loadable)) { - Logger.d(TAG, "File downloading with name " + filename + " has been stopped"); - } else { - Logger.d(TAG, "File downloading with name " + filename + " has been canceled"); - } - return; - } + AbstractFile imageFile = threadSaveDirImages + .clone(new FileSegment(filename)); - File imageFile = new File(threadSaveDirImages, filename); - if (!imageFile.exists()) { + if (!fileManager.exists(imageFile)) { Request request = new Request.Builder().url(imageUrl).build(); try (Response response = okHttpClient.newCall(request).execute()) { @@ -960,16 +1145,16 @@ private boolean isCurrentDownloadRunning(Loadable loadable) { * Writes image's bytes to a file */ private void storeImageToFile( - File imageFile, + AbstractFile imageFile, Response response) throws IOException { - if (!imageFile.createNewFile()) { + if (fileManager.create(imageFile) == null) { throw new IOException("Could not create a file to save an image to (path: " - + imageFile.getAbsolutePath() + ")"); + + imageFile.getFullPath() + ")"); } try (ResponseBody body = response.body()) { if (body == null) { - throw new NullPointerException("Response body is null"); + throw new IOException("Response body is null"); } if (body.contentLength() <= 0) { @@ -977,7 +1162,12 @@ private void storeImageToFile( } try (InputStream is = body.byteStream()) { - try (OutputStream os = new FileOutputStream(imageFile)) { + try (OutputStream os = fileManager.getOutputStream(imageFile)) { + if (os == null) { + throw new IOException("Could not get OutputStream from imageFile, " + + "imageFilePath = " + imageFile.getFullPath()); + } + IOUtils.copy(is, os); } } @@ -997,14 +1187,20 @@ private boolean isFatalException(Throwable error) { * When user cancels a download we need to delete the thread from the disk as well */ private void deleteThreadFilesFromDisk(Loadable loadable) { - String subDirs = getThreadSubDir(loadable); + AbstractFile baseDirectory = fileManager.newBaseDirectoryFile( + LocalThreadsBaseDirectory.class + ); + + if (baseDirectory == null) { + throw new IllegalStateException("LocalThreadsBaseDirectory is not registered!"); + } - File threadSaveDir = new File(ChanSettings.saveLocation.get(), subDirs); - if (!threadSaveDir.exists()) { + AbstractFile threadSaveDir = baseDirectory.clone(getThreadSubDir(loadable)); + if (!fileManager.exists(threadSaveDir)) { return; } - IOUtils.deleteDirWithContents(threadSaveDir); + fileManager.delete(threadSaveDir); } /** @@ -1027,43 +1223,38 @@ public static String formatSpoilerImageName(String extension) { return SPOILER_FILE_NAME + "." + extension; } - public static String getThreadSubDir(Loadable loadable) { - // saved_threads/4chan/g/11223344 - - return SAVED_THREADS_DIR_NAME + - File.separator + - loadable.site.name() + - File.separator + - loadable.boardCode + - File.separator + loadable.no; + public static List getThreadSubDir(Loadable loadable) { + // 4chan/g/11223344 + return Arrays.asList( + new DirectorySegment(loadable.site.name()), + new DirectorySegment(loadable.boardCode), + new DirectorySegment(String.valueOf(loadable.no))); } - public static String getImagesSubDir(Loadable loadable) { - // saved_threads/4chan/g/11223344/images - - return SAVED_THREADS_DIR_NAME + - File.separator + - loadable.site.name() + - File.separator + - loadable.boardCode + - File.separator + loadable.no + - File.separator + IMAGES_DIR_NAME; + public static List getImagesSubDir(Loadable loadable) { + // 4chan/g/11223344/images + return Arrays.asList( + new DirectorySegment(loadable.site.name()), + new DirectorySegment(loadable.boardCode), + new DirectorySegment(String.valueOf(loadable.no)), + new DirectorySegment(IMAGES_DIR_NAME) + ); } - public static String getBoardSubDir(Loadable loadable) { - // saved_threads/4chan/g - - return SAVED_THREADS_DIR_NAME + - File.separator + - loadable.site.name() + - File.separator + - loadable.boardCode; + public static List getBoardSubDir(Loadable loadable) { + // 4chan/g + return Arrays.asList( + new DirectorySegment(loadable.site.name()), + new DirectorySegment(loadable.boardCode) + ); } /** * The main difference between AdditionalThreadParameters and SaveThreadParameters is that * SaveThreadParameters is getting deleted after each thread download attempt while - * AdditionalThreadParameters stay until app restart. + * AdditionalThreadParameters stay until app restart. We use them to not download 404ed images + * on each attempt (because it may block the downloading process for up to + * OKHTTP_TIMEOUT_SECONDS seconds) */ public static class AdditionalThreadParameters { private Set deletedImages; @@ -1123,43 +1314,54 @@ public void cancel() { } } - class ImageWasAlreadyDeletedException extends Exception { + static class ImageWasAlreadyDeletedException extends Exception { public ImageWasAlreadyDeletedException(String fileName) { super("Image " + fileName + " was already deleted"); } } - class NoNewPostsToSaveException extends Exception { + static class NoNewPostsToSaveException extends Exception { public NoNewPostsToSaveException() { super("No new posts left to save after filtering"); } } - class CouldNotCreateThreadDirectoryException extends Exception { - public CouldNotCreateThreadDirectoryException(File threadSaveDir) { + static class CouldNotCreateThreadDirectoryException extends Exception { + public CouldNotCreateThreadDirectoryException(@Nullable AbstractFile threadSaveDir) { super("Could not create a directory to save the thread " + - "to (full path: " + threadSaveDir.getAbsolutePath() + ")"); + "to (full path: " + getPathOrNull(threadSaveDir) + ")"); + } + + @NonNull + private static String getPathOrNull(@Nullable AbstractFile boardSaveDir) { + return boardSaveDir == null ? "null" : boardSaveDir.getFullPath(); } } - class CouldNotCreateNoMediaFile extends Exception { - public CouldNotCreateNoMediaFile(File threadSaveDirImages) { - super("Could not create .nomedia file in directory " + threadSaveDirImages.getAbsolutePath()); + static class CouldNotCreateNoMediaFile extends Exception { + public CouldNotCreateNoMediaFile(AbstractFile threadSaveDirImages) { + super("Could not create .nomedia file in directory " + threadSaveDirImages.getFullPath()); } } - class CouldNotCreateImagesDirectoryException extends Exception { - public CouldNotCreateImagesDirectoryException(File threadSaveDirImages) { + static class CouldNotCreateImagesDirectoryException extends Exception { + public CouldNotCreateImagesDirectoryException(AbstractFile threadSaveDirImages) { super("Could not create a directory to save the thread images" + - "to (full path: " + threadSaveDirImages.getAbsolutePath() + ")"); + "to (full path: " + threadSaveDirImages.getFullPath() + ")"); } } - class CouldNotCreateSpoilerImageDirectoryException extends Exception { - public CouldNotCreateSpoilerImageDirectoryException(File boardSaveDir) { + static class CouldNotCreateSpoilerImageDirectoryException extends Exception { + public CouldNotCreateSpoilerImageDirectoryException(@Nullable AbstractFile boardSaveDir) { super("Could not create a directory to save the spoiler image " + - "to (full path: " + boardSaveDir.getAbsolutePath() + ")"); + "to (full path: " + getPathOrNull(boardSaveDir) + ")"); + } + + @NonNull + private static String getPathOrNull(@Nullable AbstractFile boardSaveDir) { + return boardSaveDir == null ? "null" : boardSaveDir.getFullPath(); } + } public enum DownloadRequestState { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/UpdateManager.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/UpdateManager.java index 79b689b8d4..b2ef699166 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/UpdateManager.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/UpdateManager.java @@ -45,6 +45,7 @@ import com.github.adamantcheese.chan.utils.AndroidUtils; import com.github.adamantcheese.chan.utils.IOUtils; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.file.RawFile; import java.io.File; import java.io.IOException; @@ -180,14 +181,14 @@ public void onProgress(long downloaded, long total) { } @Override - public void onSuccess(File file) { + public void onSuccess(RawFile file) { updateDownloadDialog.dismiss(); updateDownloadDialog = null; File copy = new File(Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS), getApplicationLabel() + ".apk"); try { - IOUtils.copyFile(file, copy); + IOUtils.copyFile(new File(file.getFullPath()), copy); } catch (IOException e) { Logger.e(TAG, "requestApkInstall", e); new AlertDialog.Builder(context) diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/WatchManager.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/WatchManager.java index aacde45dc6..144063ea63 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/WatchManager.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/manager/WatchManager.java @@ -231,14 +231,14 @@ public boolean createPin(Pin pin, boolean sendBroadcast) { return true; } - public void startSavingThread( + public boolean startSavingThread( Loadable loadable, List postsToSave) { if (!startSavingThread(loadable)) { - return; + return false; } - threadSaveManager.enqueueThreadToSave(loadable, postsToSave); + return threadSaveManager.enqueueThreadToSave(loadable, postsToSave); } public boolean startSavingThread(Loadable loadable) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/export/ExportedAppSettings.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/export/ExportedAppSettings.java index 9e155efed1..9079840b7f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/export/ExportedAppSettings.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/export/ExportedAppSettings.java @@ -19,13 +19,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.github.adamantcheese.chan.core.repository.ImportExportRepository; import com.google.gson.annotations.SerializedName; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import static com.github.adamantcheese.chan.core.repository.ImportExportRepository.CURRENT_EXPORT_SETTINGS_VERSION; public class ExportedAppSettings { @SerializedName("exported_sites") @@ -50,8 +50,7 @@ public ExportedAppSettings( List exportedFilters, List exportedPostHides, List exportedSavedThreads, - @NonNull - String settings + @NonNull String settings ) { this.exportedSites = exportedSites; this.exportedBoards = exportedBoards; @@ -77,7 +76,9 @@ public static ExportedAppSettings empty() { * (probably only settings) */ public boolean isEmpty() { - return exportedSites.isEmpty() && exportedBoards.isEmpty() && (settings == null || settings.isEmpty()); + return exportedSites.isEmpty() + && exportedBoards.isEmpty() + && (settings == null || settings.isEmpty()); } public List getExportedSites() { @@ -101,7 +102,7 @@ public List getExportedSavedThreads() { } public int getVersion() { - return CURRENT_EXPORT_SETTINGS_VERSION; + return ImportExportRepository.CURRENT_EXPORT_SETTINGS_VERSION; } @Nullable @@ -130,6 +131,7 @@ public void setExportedSavedThreads(List exportedSavedThrea } public void setSettings(String settings) { - throw new UnsupportedOperationException("Settings are only allowed to be set with the constructor, and must be from ChanSettings.serializeToString()."); + throw new UnsupportedOperationException("Settings are only allowed to be set with the " + + "constructor, and must be from ChanSettings.serializeToString()."); } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/save/SerializableThread.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/save/SerializableThread.java index 37f4ab25ad..8e94e3b5d6 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/save/SerializableThread.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/model/save/SerializableThread.java @@ -2,6 +2,7 @@ import com.github.adamantcheese.chan.core.mapper.PostMapper; import com.github.adamantcheese.chan.core.model.Post; +import com.github.adamantcheese.chan.utils.BackgroundUtils; import com.google.gson.annotations.SerializedName; import java.util.ArrayList; @@ -27,6 +28,10 @@ public List getPostList() { * Merge old posts with new posts avoiding duplicates and then sort merged list */ public SerializableThread merge(List posts) { + if (BackgroundUtils.isMainThread()) { + throw new RuntimeException("Cannot be executed on the main thread!"); + } + Set postsSet = new HashSet<>(posts.size() + postList.size()); postsSet.addAll(postList); @@ -36,6 +41,7 @@ public SerializableThread merge(List posts) { List filteredPosts = new ArrayList<>(postsSet.size()); filteredPosts.addAll(postsSet); + postsSet.clear(); Collections.sort(filteredPosts, postComparator); @@ -44,5 +50,6 @@ public SerializableThread merge(List posts) { return this; } - private static final Comparator postComparator = (o1, o2) -> Integer.compare(o1.getNo(), o2.getNo()); + private static final Comparator postComparator + = (o1, o2) -> Integer.compare(o1.getNo(), o2.getNo()); } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImageViewerPresenter.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImageViewerPresenter.java index 67a6fb4361..78f375df2a 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImageViewerPresenter.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImageViewerPresenter.java @@ -389,7 +389,8 @@ private boolean imageAutoLoad(Loadable loadable, PostImage postImage) { } // Auto load the image when it is cached - return fileCache.exists(postImage.imageUrl.toString()) || shouldLoadForNetworkType(ChanSettings.imageAutoLoadNetwork.get()); + return fileCache.exists(postImage.imageUrl.toString()) + || shouldLoadForNetworkType(ChanSettings.imageAutoLoadNetwork.get()); } private boolean videoAutoLoad(Loadable loadable, PostImage postImage) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImportExportSettingsPresenter.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImportExportSettingsPresenter.java index ccfc24d566..a9358757bc 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImportExportSettingsPresenter.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ImportExportSettingsPresenter.java @@ -20,8 +20,7 @@ import androidx.annotation.Nullable; import com.github.adamantcheese.chan.core.repository.ImportExportRepository; - -import java.io.File; +import com.github.k1rakishou.fsaf.file.ExternalFile; import javax.inject.Inject; @@ -45,8 +44,8 @@ public void onDestroy() { this.callbacks = null; } - public void doExport(File settingsFile) { - importExportRepository.exportTo(settingsFile, new ImportExportRepository.ImportExportCallbacks() { + public void doExport(ExternalFile settingsFile, boolean isNewFile) { + importExportRepository.exportTo(settingsFile, isNewFile, new ImportExportRepository.ImportExportCallbacks() { @Override public void onSuccess(ImportExportRepository.ImportExport importExport) { //called on background thread @@ -76,7 +75,7 @@ public void onError(Throwable error, ImportExportRepository.ImportExport importE }); } - public void doImport(File settingsFile) { + public void doImport(ExternalFile settingsFile) { importExportRepository.importFrom(settingsFile, new ImportExportRepository.ImportExportCallbacks() { @Override public void onSuccess(ImportExportRepository.ImportExport importExport) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/MediaSettingsControllerPresenter.kt b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/MediaSettingsControllerPresenter.kt new file mode 100644 index 0000000000..af5a95246a --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/MediaSettingsControllerPresenter.kt @@ -0,0 +1,310 @@ +package com.github.adamantcheese.chan.core.presenter + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import com.github.adamantcheese.chan.R +import com.github.adamantcheese.chan.core.settings.ChanSettings +import com.github.adamantcheese.chan.ui.controller.MediaSettingsControllerCallbacks +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory +import com.github.adamantcheese.chan.ui.settings.base_directory.SavedFilesBaseDirectory +import com.github.adamantcheese.chan.utils.AndroidUtils.runOnUiThread +import com.github.adamantcheese.chan.utils.Logger +import com.github.k1rakishou.fsaf.FileChooser +import com.github.k1rakishou.fsaf.FileManager +import com.github.k1rakishou.fsaf.callback.DirectoryChooserCallback +import com.github.k1rakishou.fsaf.file.AbstractFile +import java.util.concurrent.Executors + +class MediaSettingsControllerPresenter( + private val appContext: Context, + private val fileManager: FileManager, + private val fileChooser: FileChooser, + private var callbacks: MediaSettingsControllerCallbacks? +) { + private val fileCopyingExecutor = Executors.newSingleThreadExecutor() + + fun onDestroy() { + callbacks = null + fileCopyingExecutor.shutdown() + } + + /** + * Select a directory where local threads will be stored via the SAF + */ + fun onLocalThreadsLocationUseSAFClicked() { + fileChooser.openChooseDirectoryDialog(object : DirectoryChooserCallback() { + override fun onResult(uri: Uri) { + val oldLocalThreadsDirectory = + fileManager.newBaseDirectoryFile() + + if (oldLocalThreadsDirectory == null) { + withCallbacks { + showToast(appContext.getString(R.string.media_settings_old_threads_base_dir_not_registered)) + } + + return + } + + Logger.d(TAG, "onLocalThreadsLocationUseSAFClicked dir = $uri") + ChanSettings.localThreadsLocationUri.set(uri.toString()) + ChanSettings.localThreadLocation.setNoUpdate( + ChanSettings.getDefaultLocalThreadsLocation() + ) + + withCallbacks { + updateLocalThreadsLocation(uri.toString()) + } + + val newLocalThreadsDirectory = + fileManager.newBaseDirectoryFile() + + if (newLocalThreadsDirectory == null) { + withCallbacks { + showToast(appContext.getString(R.string.media_settings_new_threads_base_dir_not_registered)) + } + + return + } + + withCallbacks { + askUserIfTheyWantToMoveOldThreadsToTheNewDirectory( + oldLocalThreadsDirectory, + newLocalThreadsDirectory + ) + } + } + + override fun onCancel(reason: String) { + withCallbacks { + showToast(reason, Toast.LENGTH_LONG) + } + } + }) + } + + fun onLocalThreadsLocationChosen(dirPath: String) { + val oldLocalThreadsDirectory = + fileManager.newBaseDirectoryFile() + + if (oldLocalThreadsDirectory == null) { + withCallbacks { + showToast(appContext.getString(R.string.media_settings_old_threads_base_dir_not_registered)) + } + + return + } + + Logger.d(TAG, "onLocalThreadsLocationChosen dir = $dirPath") + ChanSettings.localThreadLocation.setSyncNoCheck(dirPath) + + val newLocalThreadsDirectory = + fileManager.newBaseDirectoryFile() + + if (newLocalThreadsDirectory == null) { + withCallbacks { + showToast(appContext.getString(R.string.media_settings_new_threads_base_dir_not_registered)) + } + + return + } + + withCallbacks { + askUserIfTheyWantToMoveOldThreadsToTheNewDirectory( + oldLocalThreadsDirectory, + newLocalThreadsDirectory + ) + } + } + + /** + * Select a directory where saved images will be stored via the SAF + */ + fun onSaveLocationUseSAFClicked() { + fileChooser.openChooseDirectoryDialog(object : DirectoryChooserCallback() { + override fun onResult(uri: Uri) { + val oldSavedFileBaseDirectory = + fileManager.newBaseDirectoryFile() + + if (oldSavedFileBaseDirectory == null) { + withCallbacks { + showToast(appContext.getString( + R.string.media_settings_old_saved_files_base_dir_not_registered)) + } + + return + } + + Logger.d(TAG, "onSaveLocationUseSAFClicked dir = $uri") + ChanSettings.saveLocationUri.set(uri.toString()) + ChanSettings.saveLocation.setNoUpdate(ChanSettings.getDefaultSaveLocationDir()) + + withCallbacks { + updateSaveLocationViewText(uri.toString()) + } + + val newSavedFilesBaseDirectory = + fileManager.newBaseDirectoryFile() + + if (newSavedFilesBaseDirectory == null) { + withCallbacks { + showToast(appContext.getString( + R.string.media_settings_new_saved_files_base_dir_not_registered)) + } + + return + } + + withCallbacks { + askUserIfTheyWantToMoveOldSavedFilesToTheNewDirectory( + oldSavedFileBaseDirectory, + newSavedFilesBaseDirectory + ) + } + } + + override fun onCancel(reason: String) { + withCallbacks { + showToast(reason, Toast.LENGTH_LONG) + } + } + }) + } + + fun onSaveLocationChosen(dirPath: String) { + val oldSaveFilesDirectory = fileManager.newBaseDirectoryFile() + + if (oldSaveFilesDirectory == null) { + withCallbacks { + showToast(appContext.getString( + R.string.media_settings_old_saved_files_base_dir_not_registered)) + } + + return + } + + Logger.d(TAG, "onSaveLocationChosen dir = $dirPath") + ChanSettings.saveLocation.setSyncNoCheck(dirPath) + + val newSaveFilesDirectory = fileManager.newBaseDirectoryFile() + + if (newSaveFilesDirectory == null) { + withCallbacks { + showToast(appContext.getString( + R.string.media_settings_new_saved_files_base_dir_not_registered)) + } + + return + } + + withCallbacks { + askUserIfTheyWantToMoveOldSavedFilesToTheNewDirectory( + oldSaveFilesDirectory, + newSaveFilesDirectory + ) + } + } + + fun moveOldFilesToTheNewDirectory( + oldBaseDirectory: AbstractFile?, + newBaseDirectory: AbstractFile? + ) { + if (oldBaseDirectory == null || newBaseDirectory == null) { + Logger.e(TAG, "One of the directories is null, cannot copy " + + "(oldBaseDirectory is null == " + (oldBaseDirectory == null) + ")" + + ", newLocalThreadsDirectory is null == " + (newBaseDirectory == null) + ")") + return + } + + // FIXME: this does not work when oldBaseDirectory was backed by the Java File and the new + // one by SAF the paths will be different. I should probably remove the base dir prefixes + // from both files split them into segments and compare segments. + if (oldBaseDirectory.getFullPath() == newBaseDirectory.getFullPath()) { + val message = appContext.getString( + R.string.media_settings_you_are_trying_to_move_files_in_the_same_directory) + + withCallbacks { + showToast(message, Toast.LENGTH_LONG) + } + + return + } + + Logger.d(TAG, + "oldLocalThreadsDirectory = " + oldBaseDirectory.getFullPath() + + ", newLocalThreadsDirectory = " + newBaseDirectory.getFullPath() + ) + + var filesCount = 0 + + fileManager.traverseDirectory( + oldBaseDirectory, + true, + FileManager.TraverseMode.OnlyFiles + ) { + ++filesCount + } + + if (filesCount == 0) { + withCallbacks { + showToast(appContext.getString(R.string.media_settings_no_files_to_copy)) + } + + return + } + + withCallbacks { + showCopyFilesDialog(filesCount, oldBaseDirectory, newBaseDirectory) + } + } + + fun moveFilesInternal( + oldBaseDirectory: AbstractFile, + newBaseDirectory: AbstractFile + ) { + fileCopyingExecutor.execute { + val result = fileManager.copyDirectoryWithContent( + oldBaseDirectory, + newBaseDirectory, + true + ) { fileIndex, totalFilesCount -> + if (callbacks == null) { + // User left the MediaSettings screen, we need to cancel the file copying + return@copyDirectoryWithContent true + } + + withCallbacks { + val text = appContext.getString( + R.string.media_settings_copying_file, + fileIndex, + totalFilesCount + ) + + updateLoadingViewText(text) + } + + return@copyDirectoryWithContent false + } + + withCallbacks { + onCopyDirectoryEnded( + oldBaseDirectory, + newBaseDirectory, + result + ) + } + } + } + + private fun withCallbacks(func: MediaSettingsControllerCallbacks.() -> Unit) { + callbacks?.let { + runOnUiThread { + func(it) + } + } + } + + companion object { + private const val TAG = "MediaSettingsControllerPresenter" + } +} \ No newline at end of file diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ReplyPresenter.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ReplyPresenter.java index 77319a3b29..ca807861cb 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ReplyPresenter.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ReplyPresenter.java @@ -626,7 +626,12 @@ private void showPreview(String name, File file) { if (file.length() > maxSize && maxSize != -1) { String fileSize = getReadableFileSize(file.length(), false); String maxSizeString = getReadableFileSize(maxSize, false); - String text = getRes().getString(probablyWebm ? R.string.reply_webm_too_big : R.string.reply_file_too_big, fileSize, maxSizeString); + + int stringResId = probablyWebm + ? R.string.reply_webm_too_big + : R.string.reply_file_too_big; + + String text = getRes().getString(stringResId, fileSize, maxSizeString); callback.openPreviewMessage(true, text); } else { callback.openPreviewMessage(false, null); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ThreadPresenter.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ThreadPresenter.java index ccffc0909e..dc5fb3ec24 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ThreadPresenter.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/presenter/ThreadPresenter.java @@ -63,11 +63,13 @@ import com.github.adamantcheese.chan.ui.helper.PostHelper; import com.github.adamantcheese.chan.ui.layout.ArchivesLayout; import com.github.adamantcheese.chan.ui.layout.ThreadListLayout; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; import com.github.adamantcheese.chan.ui.view.FloatingMenuItem; import com.github.adamantcheese.chan.ui.view.ThumbnailView; import com.github.adamantcheese.chan.utils.AndroidUtils; import com.github.adamantcheese.chan.utils.Logger; import com.github.adamantcheese.chan.utils.PostUtils; +import com.github.k1rakishou.fsaf.FileManager; import org.greenrobot.eventbus.EventBus; @@ -108,13 +110,14 @@ public class ThreadPresenter implements ChanThreadLoader.ChanLoaderCallback, private static final int POST_OPTION_EXTRA = 15; private static final int POST_OPTION_REMOVE = 16; - private ThreadPresenterCallback threadPresenterCallback; - private WatchManager watchManager; - private DatabaseManager databaseManager; - private ChanLoaderFactory chanLoaderFactory; - private PageRequestManager pageRequestManager; - private ThreadSaveManager threadSaveManager; + private final WatchManager watchManager; + private final DatabaseManager databaseManager; + private final ChanLoaderFactory chanLoaderFactory; + private final PageRequestManager pageRequestManager; + private final ThreadSaveManager threadSaveManager; + private final FileManager fileManager; + private ThreadPresenterCallback threadPresenterCallback; private Loadable loadable; private ChanThreadLoader chanLoader; private boolean searchOpen; @@ -130,12 +133,15 @@ public ThreadPresenter(WatchManager watchManager, DatabaseManager databaseManager, ChanLoaderFactory chanLoaderFactory, PageRequestManager pageRequestManager, - ThreadSaveManager threadSaveManager) { + ThreadSaveManager threadSaveManager, + FileManager fileManager + ) { this.watchManager = watchManager; this.databaseManager = databaseManager; this.chanLoaderFactory = chanLoaderFactory; this.pageRequestManager = pageRequestManager; this.threadSaveManager = threadSaveManager; + this.fileManager = fileManager; } public void create(ThreadPresenterCallback threadPresenterCallback) { @@ -248,6 +254,11 @@ private void startSavingThreadIfItIsNotBeingSaved(Loadable loadable) { return; } + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + // Base directory for local threads does not exist or was deleted + return; + } + watchManager.startSavingThread(loadable); EventBus.getDefault().post(new WatchManager.PinMessages.PinChangedMessage(pin)); } @@ -318,15 +329,25 @@ public boolean pin() { public boolean save() { Pin pin = watchManager.findPinByLoadableId(loadable.id); if (pin == null || !PinType.hasDownloadFlag(pin.pinType)) { - return saveInternal(); + boolean startedSaving = saveInternal(); + if (!startedSaving) { + watchManager.stopSavingThread(loadable); + } + + return startedSaving; } - pin.pinType = PinType.removeDownloadNewPostsFlag(pin.pinType); - if (PinType.hasNoFlags(pin.pinType)) { + if (!PinType.hasWatchNewPostsFlag(pin.pinType)) { + pin.pinType = PinType.removeDownloadNewPostsFlag(pin.pinType); watchManager.deletePin(pin); } else { - watchManager.updatePin(pin); watchManager.stopSavingThread(pin.loadable); + + // Remove the flag after stopping thread saving, otherwise we just won't find the thread + // because the pin won't have the download flag which we check somewhere deep inside the + // stopSavingThread() method + pin.pinType = PinType.removeDownloadNewPostsFlag(pin.pinType); + watchManager.updatePin(pin); } loadable.loadableDownloadingState = Loadable.LoadableDownloadingState.NotDownloading; @@ -335,6 +356,7 @@ public boolean save() { private boolean saveInternal() { if (chanLoader.getThread() == null) { + Logger.e(TAG, "chanLoader.getThread() == null"); return false; } @@ -351,9 +373,12 @@ private boolean saveInternal() { } oldPin.pinType = PinType.addDownloadNewPostsFlag(oldPin.pinType); - watchManager.updatePin(oldPin); - startSavingThreadInternal(loadable, postsToSave, oldPin); + + if (!startSavingThreadInternal(loadable, postsToSave, oldPin)) { + return false; + } + EventBus.getDefault().post(new WatchManager.PinMessages.PinChangedMessage(oldPin)); } else { // Save button is clicked and bookmark button is not yet pressed @@ -370,7 +395,10 @@ private boolean saveInternal() { throw new IllegalStateException("Could not find freshly created pin by loadable " + loadable); } - startSavingThreadInternal(loadable, postsToSave, newPin); + if (!startSavingThreadInternal(loadable, postsToSave, newPin)) { + return false; + } + EventBus.getDefault().post(new WatchManager.PinMessages.PinAddedMessage(newPin)); } @@ -381,7 +409,7 @@ private boolean saveInternal() { return true; } - private void startSavingThreadInternal( + private boolean startSavingThreadInternal( Loadable loadable, List postsToSave, Pin newPin) { @@ -389,7 +417,7 @@ private void startSavingThreadInternal( throw new IllegalStateException("newPin does not have DownloadFlag: " + newPin.pinType); } - watchManager.startSavingThread(loadable, postsToSave); + return watchManager.startSavingThread(loadable, postsToSave); } public boolean isPinned() { @@ -599,7 +627,11 @@ private void storeNewPostsIfThreadIsBeingDownloaded(Loadable loadable, List. - */ -package com.github.adamantcheese.chan.core.repository; - -import android.annotation.SuppressLint; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.github.adamantcheese.chan.core.database.DatabaseHelper; -import com.github.adamantcheese.chan.core.database.DatabaseManager; -import com.github.adamantcheese.chan.core.model.export.ExportedAppSettings; -import com.github.adamantcheese.chan.core.model.export.ExportedBoard; -import com.github.adamantcheese.chan.core.model.export.ExportedFilter; -import com.github.adamantcheese.chan.core.model.export.ExportedLoadable; -import com.github.adamantcheese.chan.core.model.export.ExportedPin; -import com.github.adamantcheese.chan.core.model.export.ExportedPostHide; -import com.github.adamantcheese.chan.core.model.export.ExportedSavedThread; -import com.github.adamantcheese.chan.core.model.export.ExportedSite; -import com.github.adamantcheese.chan.core.model.json.site.SiteConfig; -import com.github.adamantcheese.chan.core.model.orm.Board; -import com.github.adamantcheese.chan.core.model.orm.Filter; -import com.github.adamantcheese.chan.core.model.orm.Loadable; -import com.github.adamantcheese.chan.core.model.orm.Pin; -import com.github.adamantcheese.chan.core.model.orm.PostHide; -import com.github.adamantcheese.chan.core.model.orm.SavedThread; -import com.github.adamantcheese.chan.core.model.orm.SiteModel; -import com.github.adamantcheese.chan.core.settings.ChanSettings; -import com.github.adamantcheese.chan.utils.Logger; -import com.google.gson.Gson; -import com.j256.ormlite.support.ConnectionSource; - -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import javax.inject.Inject; - -public class ImportExportRepository { - private static final String TAG = "ImportExportRepository"; - - // Don't forget to change this when changing any of the Export models. - // Also, don't forget to handle the change in the onUpgrade or onDowngrade methods - public static final int CURRENT_EXPORT_SETTINGS_VERSION = 5; - - private DatabaseManager databaseManager; - private DatabaseHelper databaseHelper; - private Gson gson; - - @Inject - public ImportExportRepository( - DatabaseManager databaseManager, - DatabaseHelper databaseHelper, - Gson gson - ) { - this.databaseManager = databaseManager; - this.databaseHelper = databaseHelper; - this.gson = gson; - } - - public void exportTo(File settingsFile, ImportExportCallbacks callbacks) { - databaseManager.runTask(() -> { - try { - ExportedAppSettings appSettings = readSettingsFromDatabase(); - if (appSettings.isEmpty()) { - callbacks.onNothingToImportExport(ImportExport.Export); - return null; - } - - String json = gson.toJson(appSettings); - - if (settingsFile.exists()) { - if (!settingsFile.isFile()) { - throw new IOException( - "Settings file is not a file (???) " + settingsFile.getAbsolutePath() - ); - } - - if (!settingsFile.delete()) { - Logger.w(TAG, "Could not delete export file before exporting " + settingsFile.getAbsolutePath()); - } - } - - if (!settingsFile.createNewFile()) { - throw new IOException( - "Could not create a file for exporting " + settingsFile.getAbsolutePath() - ); - } - - if (!settingsFile.exists() || !settingsFile.canWrite()) { - throw new IOException( - "Something wrong with export file (Can't write or it doesn't exist) " - + settingsFile.getAbsolutePath() - ); - } - - try (FileWriter writer = new FileWriter(settingsFile)) { - writer.write(json); - } - - Logger.i(TAG, "Exporting done!"); - callbacks.onSuccess(ImportExport.Export); - } catch (Throwable error) { - Logger.e(TAG, "Error while trying to export settings", error); - - deleteExportFile(settingsFile); - callbacks.onError(error, ImportExport.Export); - } - - return null; - }); - } - - public void importFrom(File settingsFile, ImportExportCallbacks callbacks) { - databaseManager.runTask(() -> { - try { - if (!settingsFile.exists()) { - Logger.i(TAG, "There is nothing to import, importFile does not exist " + settingsFile.getAbsolutePath()); - callbacks.onNothingToImportExport(ImportExport.Import); - return null; - } - - if (!settingsFile.canRead()) { - throw new IOException( - "Something wrong with import file (Can't read or it doesn't exist) " - + settingsFile.getAbsolutePath() - ); - } - - ExportedAppSettings appSettings; - - try (FileReader reader = new FileReader(settingsFile)) { - appSettings = gson.fromJson(reader, ExportedAppSettings.class); - } - - if (appSettings.isEmpty()) { - Logger.i(TAG, "There is nothing to import, appSettings is empty"); - callbacks.onNothingToImportExport(ImportExport.Import); - return null; - } - - writeSettingsToDatabase(appSettings); - - Logger.i(TAG, "Importing done!"); - callbacks.onSuccess(ImportExport.Import); - } catch (Throwable error) { - Logger.e(TAG, "Error while trying to import settings", error); - callbacks.onError(error, ImportExport.Import); - } - - return null; - }); - } - - private void deleteExportFile(File exportFile) { - if (exportFile != null) { - if (!exportFile.delete()) { - Logger.w(TAG, "Could not delete export file " + exportFile.getAbsolutePath()); - } - } - } - - private void writeSettingsToDatabase(@NonNull ExportedAppSettings appSettings) - throws SQLException, IOException, DowngradeNotSupportedException { - - if (appSettings.getVersion() < CURRENT_EXPORT_SETTINGS_VERSION) { - onUpgrade(appSettings.getVersion(), appSettings); - } else if (appSettings.getVersion() > CURRENT_EXPORT_SETTINGS_VERSION) { - // we don't support settings downgrade so just notify the user about it - throw new DowngradeNotSupportedException("You are attempting to import settings with " + - "version higher than the current app's settings version (downgrade). " + - "This is not supported so nothing will be imported." - ); - } - - // recreate tables from scratch, because we need to reset database IDs as well - try (ConnectionSource cs = databaseHelper.getConnectionSource()) { - databaseHelper.dropTables(cs); - databaseHelper.createTables(cs); - } - - for (ExportedBoard exportedBoard : appSettings.getExportedBoards()) { - assert exportedBoard.getDescription() != null; - databaseHelper.boardsDao.createIfNotExists(new Board( - exportedBoard.getSiteId(), - exportedBoard.isSaved(), - exportedBoard.getOrder(), - exportedBoard.getName(), - exportedBoard.getCode(), - exportedBoard.isWorkSafe(), - exportedBoard.getPerPage(), - exportedBoard.getPages(), - exportedBoard.getMaxFileSize(), - exportedBoard.getMaxWebmSize(), - exportedBoard.getMaxCommentChars(), - exportedBoard.getBumpLimit(), - exportedBoard.getImageLimit(), - exportedBoard.getCooldownThreads(), - exportedBoard.getCooldownReplies(), - exportedBoard.getCooldownImages(), - exportedBoard.isSpoilers(), - exportedBoard.getCustomSpoilers(), - exportedBoard.isUserIds(), - exportedBoard.isCodeTags(), - exportedBoard.isPreuploadCaptcha(), - exportedBoard.isCountryFlags(), - exportedBoard.isMathTags(), - exportedBoard.getDescription(), - exportedBoard.isArchive() - )); - } - - for (ExportedSite exportedSite : appSettings.getExportedSites()) { - SiteModel inserted = databaseHelper.siteDao.createIfNotExists(new SiteModel( - exportedSite.getSiteId(), - exportedSite.getConfiguration(), - exportedSite.getUserSettings(), - exportedSite.getOrder() - )); - - List exportedSavedThreads = appSettings.getExportedSavedThreads(); - - for (ExportedPin exportedPin : exportedSite.getExportedPins()) { - ExportedLoadable exportedLoadable = exportedPin.getExportedLoadable(); - if (exportedLoadable == null) continue; - - Loadable loadable = Loadable.importLoadable( - inserted.id, - exportedLoadable.getMode(), - exportedLoadable.getBoardCode(), - exportedLoadable.getNo(), - exportedLoadable.getTitle(), - exportedLoadable.getListViewIndex(), - exportedLoadable.getListViewTop(), - exportedLoadable.getLastViewed(), - exportedLoadable.getLastLoaded() - ); - - Loadable insertedLoadable = databaseHelper.loadableDao.createIfNotExists(loadable); - ExportedSavedThread exportedSavedThread = findSavedThreadByOldLoadableId( - exportedSavedThreads, - (int) exportedLoadable.getLoadableId()); - - // ExportedSavedThreads may have their loadable ids noncontiguous. Like 1,3,4,5,21,152. - // SQLite does not like it and will be returning to us contiguous ids ignoring our ids. - // This will create a situation where savedThread.loadableId may not have a loadable. - // So we need to fix this by finding a saved thread by old loadable id and updating - // it's loadable id with the newly inserted id. - if (exportedSavedThread != null) { - exportedSavedThread.loadableId = insertedLoadable.id; - - databaseHelper.savedThreadDao.createIfNotExists(new SavedThread( - exportedSavedThread.isFullyDownloaded, - exportedSavedThread.isStopped, - exportedSavedThread.lastSavedPostNo, - exportedSavedThread.loadableId - )); - } - - Pin pin = new Pin( - insertedLoadable, - exportedPin.isWatching(), - exportedPin.getWatchLastCount(), - exportedPin.getWatchNewCount(), - exportedPin.getQuoteLastCount(), - exportedPin.getQuoteNewCount(), - exportedPin.isError(), - exportedPin.getThumbnailUrl(), - exportedPin.getOrder(), - exportedPin.isArchived(), - exportedPin.getPinType() - ); - databaseHelper.pinDao.createIfNotExists(pin); - } - } - - for (ExportedFilter exportedFilter : appSettings.getExportedFilters()) { - databaseHelper.filterDao.createIfNotExists(new Filter( - exportedFilter.isEnabled(), - exportedFilter.getType(), - exportedFilter.getPattern(), - exportedFilter.isAllBoards(), - exportedFilter.getBoards(), - exportedFilter.getAction(), - exportedFilter.getColor(), - exportedFilter.getApplyToReplies(), - exportedFilter.getOrder(), - exportedFilter.getOnlyOnOP(), - exportedFilter.getApplyToSaved() - )); - } - - for (ExportedPostHide exportedPostHide : appSettings.getExportedPostHides()) { - databaseHelper.postHideDao.createIfNotExists(new PostHide( - exportedPostHide.getSite(), - exportedPostHide.getBoard(), - exportedPostHide.getNo() - )); - } - - ChanSettings.deserializeFromString(appSettings.getSettings()); - } - - @Nullable - private ExportedSavedThread findSavedThreadByOldLoadableId( - List exportedSavedThreads, - int oldLoadableId) { - for (ExportedSavedThread exportedSavedThread : exportedSavedThreads) { - if (exportedSavedThread.loadableId == oldLoadableId) { - return exportedSavedThread; - } - } - - return null; - } - - private ExportedAppSettings onUpgrade(int version, ExportedAppSettings appSettings) { - if (version < 2) { - //clear the post hides for version 1, threadNo field was added - appSettings.setExportedPostHides(new ArrayList<>()); - } - - if (version < 3) { - //clear the site model usersettings to be an empty JSON map for version 2, as they won't parse correctly otherwise - for (ExportedSite site : appSettings.getExportedSites()) { - site.setUserSettings("{}"); - } - } - - if (version < 4) { - //55chan and 8chan were removed for this version - ExportedSite chan8 = null; - ExportedSite chan55 = null; - for (ExportedSite site : appSettings.getExportedSites()) { - SiteConfig config = gson.fromJson(site.getConfiguration(), SiteConfig.class); - if (config.classId == 1 && chan8 == null) chan8 = site; - if (config.classId == 7 && chan55 == null) chan55 = site; - } - - if (chan55 != null) { - deleteExportedSite(chan55, appSettings); - } - - if (chan8 != null) { - deleteExportedSite(chan8, appSettings); - } - } - - if (version < 5) { - List toRemove = new ArrayList<>(); - for (ExportedSite site : appSettings.getExportedSites()) { - if (site.getSiteId() == 3) { - for (ExportedBoard board : appSettings.getExportedBoards()) { - if (board.getSiteId() == site.getSiteId()) { - if (board.getCode().equals("cyb") - || board.getCode().equals("feels") - || board.getCode().equals("x") - || board.getCode().equals("z")) { - toRemove.add(board); - continue; - } - if(board.getCode().equals("art")) { - board.setName("art and creative"); - } - if(board.getCode().equals("tech")) { - board.setName("technology"); - } - if(board.getCode().equals("Δ")) { - board.setName("shape your world"); - } - if(board.getCode().equals("ru")) { - board.setName("Киберпанк"); - } - } - } - } - } - for(ExportedBoard board : toRemove) { - deleteExportedBoard(board, appSettings); - } - } - return appSettings; - } - - @NonNull - private ExportedAppSettings readSettingsFromDatabase() throws java.sql.SQLException, IOException { - @SuppressLint("UseSparseArrays") - Map sitesMap = new HashMap<>(); - { - List sites = databaseHelper.siteDao.queryForAll(); - - for (SiteModel site : sites) { - sitesMap.put(site.id, site); - } - } - - @SuppressLint("UseSparseArrays") - Map loadableMap = new HashMap<>(); - { - List loadables = databaseHelper.loadableDao.queryForAll(); - - for (Loadable loadable : loadables) { - loadableMap.put(loadable.id, loadable); - } - } - - Set pins = new HashSet<>(databaseHelper.pinDao.queryForAll()); - Map> toExportMap = new HashMap<>(); - - for (SiteModel siteModel : sitesMap.values()) { - toExportMap.put(siteModel, new ArrayList<>()); - } - - for (Pin pin : pins) { - Loadable loadable = loadableMap.get(pin.loadable.id); - if (loadable == null) { - throw new NullPointerException("Could not find Loadable by pin.loadable.id " + pin.loadable.id); - } - - SiteModel siteModel = sitesMap.get(loadable.siteId); - if (siteModel == null) { - throw new NullPointerException("Could not find siteModel by loadable.siteId " + loadable.siteId); - } - - ExportedLoadable exportedLoadable = new ExportedLoadable( - loadable.boardCode, - loadable.id, - loadable.lastLoaded, - loadable.lastViewed, - loadable.listViewIndex, - loadable.listViewTop, - loadable.mode, - loadable.no, - loadable.siteId, - loadable.title - ); - - ExportedPin exportedPin = new ExportedPin( - pin.archived, - pin.id, - pin.isError, - loadable.id, - pin.order, - pin.quoteLastCount, - pin.quoteNewCount, - pin.thumbnailUrl, - pin.watchLastCount, - pin.watchNewCount, - pin.watching, - exportedLoadable, - pin.pinType - ); - - toExportMap.get(siteModel).add(exportedPin); - } - - List exportedSites = new ArrayList<>(); - - for (Map.Entry> entry : toExportMap.entrySet()) { - ExportedSite exportedSite = new ExportedSite( - entry.getKey().id, - entry.getKey().configuration, - entry.getKey().order, - entry.getKey().userSettings, - entry.getValue() - ); - - exportedSites.add(exportedSite); - } - - List exportedBoards = new ArrayList<>(); - - for (Board board : databaseHelper.boardsDao.queryForAll()) { - exportedBoards.add(new ExportedBoard( - board.siteId, - board.saved, - board.order, - board.name, - board.code, - board.workSafe, - board.perPage, - board.pages, - board.maxFileSize, - board.maxWebmSize, - board.maxCommentChars, - board.bumpLimit, - board.imageLimit, - board.cooldownThreads, - board.cooldownReplies, - board.cooldownImages, - board.spoilers, - board.customSpoilers, - board.userIds, - board.codeTags, - board.preuploadCaptcha, - board.countryFlags, - board.mathTags, - board.description, - board.archive - )); - } - - List exportedFilters = new ArrayList<>(); - - for (Filter filter : databaseHelper.filterDao.queryForAll()) { - exportedFilters.add(new ExportedFilter( - filter.enabled, - filter.type, - filter.pattern, - filter.allBoards, - filter.boards, - filter.action, - filter.color, - filter.applyToReplies, - filter.order, - filter.onlyOnOP, - filter.applyToSaved - )); - } - - List exportedPostHides = new ArrayList<>(); - - for (PostHide threadHide : databaseHelper.postHideDao.queryForAll()) { - exportedPostHides.add(new ExportedPostHide( - threadHide.site, - threadHide.board, - threadHide.no, - threadHide.wholeThread, - threadHide.hide, - threadHide.hideRepliesToThisPost, - threadHide.threadNo - )); - } - - List exportedSavedThreads = new ArrayList<>(); - - for (SavedThread savedThread : databaseHelper.savedThreadDao.queryForAll()) { - exportedSavedThreads.add(new ExportedSavedThread( - savedThread.loadableId, - savedThread.lastSavedPostNo, - savedThread.isFullyDownloaded, - savedThread.isStopped - )); - } - - String settings = ChanSettings.serializeToString(); - - return new ExportedAppSettings( - exportedSites, - exportedBoards, - exportedFilters, - exportedPostHides, - exportedSavedThreads, - settings - ); - } - - public enum ImportExport { - Import, - Export - } - - public interface ImportExportCallbacks { - void onSuccess(ImportExport importExport); - - void onNothingToImportExport(ImportExport importExport); - - void onError(Throwable error, ImportExport importExport); - } - - public static class DowngradeNotSupportedException extends Exception { - public DowngradeNotSupportedException(String message) { - super(message); - } - } - - private void deleteExportedSite(ExportedSite site, ExportedAppSettings appSettings) { - //filters - List filtersToDelete = new ArrayList<>(); - for (ExportedFilter filter : appSettings.getExportedFilters()) { - if (filter.isAllBoards() || TextUtils.isEmpty(filter.getBoards())) { - continue; - } - - for (String uniqueId : filter.getBoards().split(",")) { - String[] split = uniqueId.split(":"); - if (split.length == 2 && Integer.parseInt(split[0]) == site.getSiteId()) { - filtersToDelete.add(filter); - break; - } - } - } - appSettings.getExportedFilters().removeAll(filtersToDelete); - - //boards - List boardsToDelete = new ArrayList<>(); - for (ExportedBoard board : appSettings.getExportedBoards()) { - if (board.getSiteId() == site.getSiteId()) { - boardsToDelete.add(board); - } - } - appSettings.getExportedBoards().removeAll(boardsToDelete); - - //loadables for saved threads - List loadables = new ArrayList<>(); - for (ExportedPin pin : site.getExportedPins()) { - if (pin.getExportedLoadable().getSiteId() == site.getSiteId()) { - loadables.add(pin.getExportedLoadable()); - } - } - - if (!loadables.isEmpty()) { - List savedThreadToDelete = new ArrayList<>(); - for (ExportedLoadable loadable : loadables) { - //saved threads - for (ExportedSavedThread savedThread : appSettings.getExportedSavedThreads()) { - if (loadable.getLoadableId() == savedThread.getLoadableId()) { - savedThreadToDelete.add(savedThread); - } - } - } - appSettings.getExportedSavedThreads().removeAll(savedThreadToDelete); - } - - //post hides - List hidesToDelete = new ArrayList<>(); - for (ExportedPostHide hide : appSettings.getExportedPostHides()) { - if (hide.getSite() == site.getSiteId()) { - hidesToDelete.add(hide); - } - } - appSettings.getExportedPostHides().removeAll(hidesToDelete); - - //site (also removes pins and loadables) - appSettings.getExportedSites().remove(site); - } - - private void deleteExportedBoard(ExportedBoard board, ExportedAppSettings appSettings) { - ExportedSite exportedSite = null; - for(ExportedSite site : appSettings.getExportedSites()) { - if(site.getSiteId() == board.getSiteId()) { - exportedSite = site; - break; - } - } - if(exportedSite == null) return; - - //filters - for (ExportedFilter filter : appSettings.getExportedFilters()) { - if (filter.isAllBoards() || TextUtils.isEmpty(filter.getBoards())) { - continue; - } - - List keep = new ArrayList<>(); - for (String uniqueId : filter.getBoards().split(",")) { - String[] split = uniqueId.split(":"); - if (!(split.length == 2 && Integer.parseInt(split[0]) == board.getSiteId() - && split[1].equals(board.getCode()))) { - keep.add(uniqueId); - } - } - filter.setBoards(TextUtils.join(",", keep)); - //disable, but don't delete filters in case they're still wanted - if (TextUtils.isEmpty(filter.getBoards())) { - filter.setEnabled(true); - } - } - - //loadables for saved threads - List loadables = new ArrayList<>(); - for (ExportedPin pin : exportedSite.getExportedPins()) { - if (pin.getExportedLoadable().getBoardCode().equals(board.getCode())) { - loadables.add(pin.getExportedLoadable()); - } - } - - if (!loadables.isEmpty()) { - List savedThreadToDelete = new ArrayList<>(); - for (ExportedLoadable loadable : loadables) { - //saved threads - for (ExportedSavedThread savedThread : appSettings.getExportedSavedThreads()) { - if (loadable.getLoadableId() == savedThread.getLoadableId()) { - savedThreadToDelete.add(savedThread); - } - } - } - appSettings.getExportedSavedThreads().removeAll(savedThreadToDelete); - } - - //post hides - List hidesToDelete = new ArrayList<>(); - for (ExportedPostHide hide : appSettings.getExportedPostHides()) { - if (hide.getBoard().equals(board.getCode())) { - hidesToDelete.add(hide); - } - } - appSettings.getExportedPostHides().removeAll(hidesToDelete); - - //board itself - appSettings.getExportedBoards().remove(board); - } -} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/ImportExportRepository.kt b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/ImportExportRepository.kt new file mode 100644 index 0000000000..ac9a48cbf7 --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/ImportExportRepository.kt @@ -0,0 +1,612 @@ +/* + * Kuroba - *chan browser https://github.com/Adamantcheese/Kuroba/ + * + * 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 com.github.adamantcheese.chan.core.repository + +import android.annotation.SuppressLint +import android.text.TextUtils +import com.github.adamantcheese.chan.core.database.DatabaseHelper +import com.github.adamantcheese.chan.core.database.DatabaseManager +import com.github.adamantcheese.chan.core.model.export.* +import com.github.adamantcheese.chan.core.model.json.site.SiteConfig +import com.github.adamantcheese.chan.core.model.orm.* +import com.github.adamantcheese.chan.core.settings.ChanSettings +import com.github.adamantcheese.chan.utils.Logger +import com.github.k1rakishou.fsaf.FileManager +import com.github.k1rakishou.fsaf.file.AbstractFile +import com.github.k1rakishou.fsaf.file.ExternalFile +import com.github.k1rakishou.fsaf.file.FileDescriptorMode +import com.google.gson.Gson +import java.io.FileReader +import java.io.FileWriter +import java.io.IOException +import java.sql.SQLException +import java.util.* +import javax.inject.Inject + +class ImportExportRepository @Inject +constructor( + private val databaseManager: DatabaseManager, + private val databaseHelper: DatabaseHelper, + private val gson: Gson, + private val fileManager: FileManager +) { + + fun exportTo(settingsFile: ExternalFile, isNewFile: Boolean, callbacks: ImportExportCallbacks) { + databaseManager.runTask { + try { + val appSettings = readSettingsFromDatabase() + if (appSettings.isEmpty) { + callbacks.onNothingToImportExport(ImportExport.Export) + return@runTask + } + + val json = gson.toJson(appSettings) + + if (!fileManager.exists(settingsFile) || !fileManager.canWrite(settingsFile)) { + throw IOException( + "Something wrong with export file (Can't write or it doesn't exist) " + + settingsFile.getFullPath() + ) + } + + // If the user has opened an old settings file we need to use WriteTruncate mode + // so that there no leftovers of the old file after writing the settings. + // Otherwise use Write mode + var fdm = FileDescriptorMode.WriteTruncate + if (isNewFile) { + fdm = FileDescriptorMode.Write + } + + fileManager.withFileDescriptor(settingsFile, fdm) { fileDescriptor -> + FileWriter(fileDescriptor).use { writer -> + writer.write(json) + writer.flush() + } + + Logger.d(TAG, "Exporting done!") + callbacks.onSuccess(ImportExport.Export) + } + + } catch (error: Throwable) { + Logger.e(TAG, "Error while trying to export settings", error) + + deleteExportFile(settingsFile) + callbacks.onError(error, ImportExport.Export) + } + } + } + + fun importFrom(settingsFile: ExternalFile, callbacks: ImportExportCallbacks) { + databaseManager.runTask { + try { + if (!fileManager.exists(settingsFile)) { + Logger.i(TAG, "There is nothing to import, importFile does not exist " + + settingsFile.getFullPath()) + callbacks.onNothingToImportExport(ImportExport.Import) + return@runTask + } + + if (!fileManager.canRead(settingsFile)) { + throw IOException( + "Something wrong with import file (Can't read or it doesn't exist) " + + settingsFile.getFullPath() + ) + } + + fileManager.withFileDescriptor( + settingsFile, + FileDescriptorMode.Read + ) { fileDescriptor -> + FileReader(fileDescriptor).use { reader -> + val appSettings = gson.fromJson(reader, ExportedAppSettings::class.java) + + if (appSettings.isEmpty) { + Logger.i(TAG, "There is nothing to import, appSettings is empty") + callbacks.onNothingToImportExport(ImportExport.Import) + return@use + } + + writeSettingsToDatabase(appSettings) + + Logger.d(TAG, "Importing done!") + callbacks.onSuccess(ImportExport.Import) + } + } + + } catch (error: Throwable) { + Logger.e(TAG, "Error while trying to import settings", error) + callbacks.onError(error, ImportExport.Import) + } + } + } + + private fun deleteExportFile(exportFile: AbstractFile) { + if (!fileManager.delete(exportFile)) { + Logger.w(TAG, "Could not delete export file " + exportFile.getFullPath()) + } + } + + @Throws(SQLException::class, IOException::class, DowngradeNotSupportedException::class) + private fun writeSettingsToDatabase(appSettingsParam: ExportedAppSettings) { + var appSettings = appSettingsParam + + if (appSettings.version < CURRENT_EXPORT_SETTINGS_VERSION) { + appSettings = onUpgrade(appSettings.version, appSettings) + } else if (appSettings.version > CURRENT_EXPORT_SETTINGS_VERSION) { + // we don't support settings downgrade so just notify the user about it + throw DowngradeNotSupportedException("You are attempting to import settings with " + + "version higher than the current app's settings version (downgrade). " + + "This is not supported so nothing will be imported." + ) + } + + // recreate tables from scratch, because we need to reset database IDs as well + databaseHelper.connectionSource.use { cs -> + databaseHelper.dropTables(cs) + databaseHelper.createTables(cs) + } + + for (exportedBoard in appSettings.exportedBoards) { + assert(exportedBoard.description != null) + databaseHelper.boardsDao.createIfNotExists(Board( + exportedBoard.siteId, + exportedBoard.isSaved, + exportedBoard.order, + exportedBoard.name, + exportedBoard.code, + exportedBoard.isWorkSafe, + exportedBoard.perPage, + exportedBoard.pages, + exportedBoard.maxFileSize, + exportedBoard.maxWebmSize, + exportedBoard.maxCommentChars, + exportedBoard.bumpLimit, + exportedBoard.imageLimit, + exportedBoard.cooldownThreads, + exportedBoard.cooldownReplies, + exportedBoard.cooldownImages, + exportedBoard.isSpoilers, + exportedBoard.customSpoilers, + exportedBoard.isUserIds, + exportedBoard.isCodeTags, + exportedBoard.isPreuploadCaptcha, + exportedBoard.isCountryFlags, + exportedBoard.isMathTags, + exportedBoard.description ?: "", + exportedBoard.isArchive + )) + } + + for (exportedSite in appSettings.exportedSites) { + val inserted = databaseHelper.siteDao.createIfNotExists(SiteModel( + exportedSite.siteId, + exportedSite.configuration, + exportedSite.userSettings, + exportedSite.order + )) + + val exportedSavedThreads = appSettings.exportedSavedThreads + + for (exportedPin in exportedSite.exportedPins) { + val exportedLoadable = exportedPin.exportedLoadable + if (exportedLoadable == null) { + continue + } + + val loadable = Loadable.importLoadable( + inserted.id, + exportedLoadable.mode, + exportedLoadable.boardCode, + exportedLoadable.no, + exportedLoadable.title, + exportedLoadable.listViewIndex, + exportedLoadable.listViewTop, + exportedLoadable.lastViewed, + exportedLoadable.lastLoaded + ) + + val insertedLoadable = databaseHelper.loadableDao.createIfNotExists(loadable) + val exportedSavedThread = findSavedThreadByOldLoadableId( + exportedSavedThreads, + exportedLoadable.loadableId.toInt()) + + // ExportedSavedThreads may have their loadable ids noncontiguous. Like 1,3,4,5,21,152. + // SQLite does not like it and will be returning to us contiguous ids ignoring our ids. + // This will create a situation where savedThread.loadableId may not have a loadable. + // So we need to fix this by finding a saved thread by old loadable id and updating + // it's loadable id with the newly inserted id. + if (exportedSavedThread != null) { + exportedSavedThread.loadableId = insertedLoadable.id + + databaseHelper.savedThreadDao.createIfNotExists(SavedThread( + exportedSavedThread.isFullyDownloaded, + exportedSavedThread.isStopped, + exportedSavedThread.lastSavedPostNo, + exportedSavedThread.loadableId + )) + } + + val pin = Pin( + insertedLoadable, + exportedPin.isWatching, + exportedPin.watchLastCount, + exportedPin.watchNewCount, + exportedPin.quoteLastCount, + exportedPin.quoteNewCount, + exportedPin.isError, + exportedPin.thumbnailUrl, + exportedPin.order, + exportedPin.isArchived, + exportedPin.pinType + ) + databaseHelper.pinDao.createIfNotExists(pin) + } + } + + for (exportedFilter in appSettings.exportedFilters) { + databaseHelper.filterDao.createIfNotExists(Filter( + exportedFilter.isEnabled, + exportedFilter.type, + exportedFilter.pattern, + exportedFilter.isAllBoards, + exportedFilter.boards, + exportedFilter.action, + exportedFilter.color, + exportedFilter.applyToReplies, + exportedFilter.order, + exportedFilter.onlyOnOP, + exportedFilter.applyToSaved + )) + } + + for (exportedPostHide in appSettings.exportedPostHides) { + databaseHelper.postHideDao.createIfNotExists(PostHide( + exportedPostHide.site, + exportedPostHide.board, + exportedPostHide.no)) + } + + ChanSettings.deserializeFromString(appSettingsParam.settings) + } + + private fun findSavedThreadByOldLoadableId( + exportedSavedThreads: List, + oldLoadableId: Int): ExportedSavedThread? { + for (exportedSavedThread in exportedSavedThreads) { + if (exportedSavedThread.loadableId == oldLoadableId) { + return exportedSavedThread + } + } + + return null + } + + private fun onUpgrade(version: Int, appSettings: ExportedAppSettings): ExportedAppSettings { + if (version < 2) { + //clear the post hides for version 1, threadNo field was added + appSettings.exportedPostHides = ArrayList() + } + + if (version < 3) { + //clear the site model usersettings to be an empty JSON map for version 2, + // as they won't parse correctly otherwise + for (site in appSettings.exportedSites) { + site.userSettings = "{}" + } + } + + if (version < 4) { + //55chan and 8chan were removed for this version + var chan8: ExportedSite? = null + var chan55: ExportedSite? = null + + for (site in appSettings.exportedSites) { + val config = gson.fromJson(site.configuration, SiteConfig::class.java) + + if (config.classId == 1 && chan8 == null) { + chan8 = site + } + + if (config.classId == 7 && chan55 == null) { + chan55 = site + } + } + + if (chan55 != null) { + deleteExportedSite(chan55, appSettings) + } + + if (chan8 != null) { + deleteExportedSite(chan8, appSettings) + } + } + + return appSettings + } + + @Throws(java.sql.SQLException::class, IOException::class) + private fun readSettingsFromDatabase(): ExportedAppSettings { + @SuppressLint("UseSparseArrays") + val sitesMap = fillSitesMap() + + @SuppressLint("UseSparseArrays") + val loadableMap = fillLoadablesMap() + + val pins = HashSet(databaseHelper.pinDao.queryForAll()) + val toExportMap = HashMap>() + + for (siteModel in sitesMap.values) { + toExportMap[siteModel] = ArrayList() + } + + for (pin in pins) { + val loadable = loadableMap[pin.loadable.id] + ?: throw NullPointerException("Could not find Loadable by pin.loadable.id " + + pin.loadable.id) + + val siteModel = sitesMap[loadable.siteId] + ?: throw NullPointerException("Could not find siteModel by loadable.siteId " + + loadable.siteId) + + val exportedLoadable = ExportedLoadable( + loadable.boardCode, + loadable.id.toLong(), + loadable.lastLoaded, + loadable.lastViewed, + loadable.listViewIndex, + loadable.listViewTop, + loadable.mode, + loadable.no, + loadable.siteId, + loadable.title + ) + + val exportedPin = ExportedPin( + pin.archived, + pin.id, + pin.isError, + loadable.id, + pin.order, + pin.quoteLastCount, + pin.quoteNewCount, + pin.thumbnailUrl, + pin.watchLastCount, + pin.watchNewCount, + pin.watching, + exportedLoadable, + pin.pinType + ) + + toExportMap[siteModel]!!.add(exportedPin) + } + + val exportedSites = ArrayList() + + for ((key, value) in toExportMap) { + val exportedSite = ExportedSite( + key.id, + key.configuration, + key.order, + key.userSettings, + value + ) + + exportedSites.add(exportedSite) + } + + val exportedBoards = ArrayList() + + for (board in databaseHelper.boardsDao.queryForAll()) { + exportedBoards.add(ExportedBoard( + board.siteId, + board.saved, + board.order, + board.name, + board.code, + board.workSafe, + board.perPage, + board.pages, + board.maxFileSize, + board.maxWebmSize, + board.maxCommentChars, + board.bumpLimit, + board.imageLimit, + board.cooldownThreads, + board.cooldownReplies, + board.cooldownImages, + board.spoilers, + board.customSpoilers, + board.userIds, + board.codeTags, + board.preuploadCaptcha, + board.countryFlags, + board.mathTags, + board.description, + board.archive + )) + } + + val exportedFilters = ArrayList() + + for (filter in databaseHelper.filterDao.queryForAll()) { + exportedFilters.add(ExportedFilter( + filter.enabled, + filter.type, + filter.pattern, + filter.allBoards, + filter.boards, + filter.action, + filter.color, + filter.applyToReplies, + filter.order, + filter.onlyOnOP, + filter.applyToSaved + )) + } + + val exportedPostHides = ArrayList() + + for (threadHide in databaseHelper.postHideDao.queryForAll()) { + exportedPostHides.add(ExportedPostHide( + threadHide.site, + threadHide.board, + threadHide.no, + threadHide.wholeThread, + threadHide.hide, + threadHide.hideRepliesToThisPost, + threadHide.threadNo + )) + } + + val exportedSavedThreads = ArrayList() + + for (savedThread in databaseHelper.savedThreadDao.queryForAll()) { + exportedSavedThreads.add(ExportedSavedThread( + savedThread.loadableId, + savedThread.lastSavedPostNo, + savedThread.isFullyDownloaded, + savedThread.isStopped + )) + } + + val settings = ChanSettings.serializeToString() + + return ExportedAppSettings( + exportedSites, + exportedBoards, + exportedFilters, + exportedPostHides, + exportedSavedThreads, + settings + ) + } + + private fun fillLoadablesMap(): Map { + val map = hashMapOf() + val loadables = databaseHelper.loadableDao.queryForAll() + + for (loadable in loadables) { + map[loadable.id] = loadable + } + + return map + } + + private fun fillSitesMap(): Map { + val map = hashMapOf() + val sites = databaseHelper.siteDao.queryForAll() + + for (site in sites) { + map[site.id] = site + } + + return map + } + + private fun deleteExportedSite(site: ExportedSite, appSettings: ExportedAppSettings) { + //filters + val filtersToDelete = ArrayList() + for (filter in appSettings.exportedFilters) { + if (filter.isAllBoards || TextUtils.isEmpty(filter.boards)) { + continue + } + + val boards = checkNotNull(filter.boards) + val splitBoards = boards.split(",".toRegex()).dropLastWhile { it.isEmpty() } + + for (uniqueId in splitBoards) { + val split = uniqueId + .split(":".toRegex()) + .dropLastWhile { it.isEmpty() } + + if (split.size == 2 && Integer.parseInt(split[0]) == site.siteId) { + filtersToDelete.add(filter) + break + } + } + } + + appSettings.exportedFilters.removeAll(filtersToDelete) + + //boards + val boardsToDelete = ArrayList() + for (board in appSettings.exportedBoards) { + if (board.siteId == site.siteId) { + boardsToDelete.add(board) + } + } + appSettings.exportedBoards.removeAll(boardsToDelete) + + //loadables for saved threads + val loadables = ArrayList() + for (pin in site.exportedPins) { + val loadable = pin.exportedLoadable + ?: continue + + if (loadable.siteId == site.siteId) { + loadables.add(loadable) + } + } + + if (loadables.isNotEmpty()) { + val savedThreadToDelete = ArrayList() + for (loadable in loadables) { + //saved threads + for (savedThread in appSettings.exportedSavedThreads) { + if (loadable.loadableId == savedThread.getLoadableId().toLong()) { + savedThreadToDelete.add(savedThread) + } + } + } + appSettings.exportedSavedThreads.removeAll(savedThreadToDelete) + } + + //post hides + val hidesToDelete = ArrayList() + for (hide in appSettings.exportedPostHides) { + if (hide.site == site.siteId) { + hidesToDelete.add(hide) + } + } + + appSettings.exportedPostHides.removeAll(hidesToDelete) + + //site (also removes pins and loadables) + appSettings.exportedSites.remove(site) + } + + enum class ImportExport { + Import, + Export + } + + interface ImportExportCallbacks { + fun onSuccess(importExport: ImportExport) + fun onNothingToImportExport(importExport: ImportExport) + fun onError(error: Throwable, importExport: ImportExport) + } + + class DowngradeNotSupportedException(message: String) : Exception(message) + + companion object { + private const val TAG = "ImportExportRepository" + + // Don't forget to change this when changing any of the Export models. + // Also, don't forget to handle the change in the onUpgrade or onDowngrade methods + const val CURRENT_EXPORT_SETTINGS_VERSION = 5 + } +} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/SavedThreadLoaderRepository.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/SavedThreadLoaderRepository.java deleted file mode 100644 index a91eab2335..0000000000 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/SavedThreadLoaderRepository.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.github.adamantcheese.chan.core.repository; - -import androidx.annotation.Nullable; - -import com.github.adamantcheese.chan.core.mapper.ThreadMapper; -import com.github.adamantcheese.chan.core.model.Post; -import com.github.adamantcheese.chan.core.model.save.SerializableThread; -import com.google.gson.Gson; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; -import java.util.List; - -import javax.inject.Inject; - -public class SavedThreadLoaderRepository { - public static final String THREAD_FILE_NAME = "thread.json"; - private static final int MAX_THREAD_SIZE_BYTES = 50 * 1024 * 1024; // 50mb - - private Gson gson; - - /** - * It would probably be a better idea to save posts in the database but then users won't be - * able to backup them and they would be deleted after every app uninstall. This implementation - * is slower than the DB one, but at least users will have their threads even after app - * uninstall/app data clearing. - */ - @Inject - public SavedThreadLoaderRepository(Gson gson) { - this.gson = gson; - } - - @Nullable - public SerializableThread loadOldThreadFromJsonFile( - File threadSaveDir) throws IOException, OldThreadTakesTooMuchSpace { - File threadFile = new File(threadSaveDir, THREAD_FILE_NAME); - if (!threadFile.exists()) { - return null; - } - - String json; - - try (RandomAccessFile raf = new RandomAccessFile(threadFile, "rw")) { - int size = raf.readInt(); - if (size <= 0 || size > MAX_THREAD_SIZE_BYTES) { - throw new OldThreadTakesTooMuchSpace(size); - } - - byte[] bytes = new byte[size]; - raf.read(bytes); - - json = new String(bytes, StandardCharsets.UTF_8); - } - - return gson.fromJson(json, SerializableThread.class); - } - - public void savePostsToJsonFile( - @Nullable SerializableThread oldSerializableThread, - List posts, - File threadSaveDir) throws IOException, CouldNotCreateThreadFile { - SerializableThread serializableThread; - - if (oldSerializableThread != null) { - // Merge with old posts if there are any - serializableThread = oldSerializableThread.merge(posts); - } else { - // Use only the new posts - serializableThread = ThreadMapper.toSerializableThread(posts); - } - - String threadJson = gson.toJson(serializableThread); - - File threadFile = new File(threadSaveDir, THREAD_FILE_NAME); - if (!threadFile.exists() && !threadFile.createNewFile()) { - throw new CouldNotCreateThreadFile(threadFile); - } - - // Update the thread file - try (RandomAccessFile raf = new RandomAccessFile(threadFile, "rw")) { - byte[] bytes = threadJson.getBytes(StandardCharsets.UTF_8); - raf.writeInt(bytes.length); - raf.write(bytes); - } - } - - public class OldThreadTakesTooMuchSpace extends Exception { - public OldThreadTakesTooMuchSpace(int size) { - super("Old serialized thread takes way too much space: " + size + - " bytes. You are not trying to save an infinite or sticky thread, right? " + - "It's not supported."); - } - } - - public class CouldNotCreateThreadFile extends Exception { - public CouldNotCreateThreadFile(File threadFile) { - super("Could not create the thread file " + - "(path: " + threadFile.getAbsolutePath() + ")"); - } - } -} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/SavedThreadLoaderRepository.kt b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/SavedThreadLoaderRepository.kt new file mode 100644 index 0000000000..df8e529831 --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/repository/SavedThreadLoaderRepository.kt @@ -0,0 +1,115 @@ +package com.github.adamantcheese.chan.core.repository + +import com.github.adamantcheese.chan.core.mapper.ThreadMapper +import com.github.adamantcheese.chan.core.model.Post +import com.github.adamantcheese.chan.core.model.save.SerializableThread +import com.github.adamantcheese.chan.utils.BackgroundUtils +import com.github.adamantcheese.chan.utils.Logger +import com.github.k1rakishou.fsaf.FileManager +import com.github.k1rakishou.fsaf.file.AbstractFile +import com.github.k1rakishou.fsaf.file.ExternalFile +import com.github.k1rakishou.fsaf.file.FileSegment +import com.google.gson.Gson +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.nio.charset.StandardCharsets +import javax.inject.Inject + +class SavedThreadLoaderRepository +/** + * It would probably be a better idea to save posts in the database but then users won't be + * able to backup them and they would be deleted after every app uninstall. This implementation + * is slower than the DB one, but at least users will have their threads even after app + * uninstall/app data clearing. + */ +@Inject +constructor( + private val gson: Gson, + private val fileManager: FileManager +) { + + @Throws(IOException::class) + fun loadOldThreadFromJsonFile( + threadSaveDir: AbstractFile + ): SerializableThread? { + if (BackgroundUtils.isMainThread()) { + throw RuntimeException("Cannot be executed on the main thread!") + } + + val threadFile = threadSaveDir + .clone(FileSegment(THREAD_FILE_NAME)) + + if (!fileManager.exists(threadFile)) { + Logger.d(TAG, "threadFile does not exist, threadFilePath = " + threadFile.getFullPath()) + return null + } + + return fileManager.getInputStream(threadFile)?.use { inputStream -> + return@use DataInputStream(inputStream).use { dis -> + val json = String(dis.readBytes(), StandardCharsets.UTF_8) + + return@use gson.fromJson( + json, + SerializableThread::class.java) + } + } + } + + @Throws(IOException::class, + CouldNotCreateThreadFile::class, + CouldNotGetParcelFileDescriptor::class + ) + fun savePostsToJsonFile( + oldSerializableThread: SerializableThread?, + posts: List, + threadSaveDir: AbstractFile + ) { + if (BackgroundUtils.isMainThread()) { + throw RuntimeException("Cannot be executed on the main thread!") + } + + val threadFile = threadSaveDir + .clone(FileSegment(THREAD_FILE_NAME)) + + val createdThreadFile = fileManager.create(threadFile) + + if (!fileManager.exists(threadFile) || createdThreadFile == null) { + throw CouldNotCreateThreadFile(threadFile) + } + + fileManager.getOutputStream(createdThreadFile)?.use { outputStream -> + // Update the thread file + return@use DataOutputStream(outputStream).use { dos -> + val serializableThread = if (oldSerializableThread != null) { + // Merge with old posts if there are any + oldSerializableThread.merge(posts) + } else { + // Use only the new posts + ThreadMapper.toSerializableThread(posts) + } + + val bytes = gson.toJson(serializableThread) + .toByteArray(StandardCharsets.UTF_8) + + dos.write(bytes) + dos.flush() + + return@use + } + } ?: throw IOException("threadFile.getOutputStream() returned null, threadFile = " + + createdThreadFile.getFullPath()) + } + + inner class CouldNotGetParcelFileDescriptor(threadFile: ExternalFile) + : Exception("getParcelFileDescriptor() returned null, threadFilePath = " + + threadFile.getFullPath()) + + inner class CouldNotCreateThreadFile(threadFile: AbstractFile) + : Exception("Could not create the thread file (path: " + threadFile.getFullPath() + ")") + + companion object { + private const val TAG = "SavedThreadLoaderRepository" + const val THREAD_FILE_NAME = "thread.json" + } +} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaveTask.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaveTask.java index c20f560440..393573867c 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaveTask.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaveTask.java @@ -17,7 +17,6 @@ package com.github.adamantcheese.chan.core.saver; import android.content.Intent; -import android.media.MediaScannerConnection; import android.net.Uri; import com.github.adamantcheese.chan.core.cache.FileCache; @@ -25,8 +24,10 @@ import com.github.adamantcheese.chan.core.model.PostImage; import com.github.adamantcheese.chan.core.model.orm.Loadable; import com.github.adamantcheese.chan.utils.AndroidUtils; -import com.github.adamantcheese.chan.utils.IOUtils; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.RawFile; import java.io.File; import java.io.IOException; @@ -34,18 +35,19 @@ import javax.inject.Inject; import static com.github.adamantcheese.chan.Chan.inject; -import static com.github.adamantcheese.chan.utils.AndroidUtils.getAppContext; public class ImageSaveTask extends FileCacheListener implements Runnable { private static final String TAG = "ImageSaveTask"; @Inject FileCache fileCache; + @Inject + FileManager fileManager; private PostImage postImage; private Loadable loadable; private ImageSaveTaskCallback callback; - private File destination; + private AbstractFile destination; private boolean share; private String subFolder; @@ -73,11 +75,11 @@ public PostImage getPostImage() { return postImage; } - public void setDestination(File destination) { + public void setDestination(AbstractFile destination) { this.destination = destination; } - public File getDestination() { + public AbstractFile getDestination() { return destination; } @@ -92,7 +94,7 @@ public boolean getShare() { @Override public void run() { try { - if (destination.exists()) { + if (fileManager.exists(destination)) { onDestination(); // Manually call postFinished() postFinished(success); @@ -105,8 +107,8 @@ public void run() { } @Override - public void onSuccess(File file) { - if (copyToDestination(file)) { + public void onSuccess(RawFile file) { + if (copyToDestination(new File(file.getFullPath()))) { onDestination(); } else { deleteDestination(); @@ -119,8 +121,8 @@ public void onEnd() { } private void deleteDestination() { - if (destination.exists()) { - if (!destination.delete()) { + if (fileManager.exists(destination)) { + if (!fileManager.delete(destination)) { Logger.e(TAG, "Could not delete destination after an interrupt"); } } @@ -128,26 +130,32 @@ private void deleteDestination() { private void onDestination() { success = true; - MediaScannerConnection.scanFile(getAppContext(), new String[]{destination.getAbsolutePath()}, null, (path, uri) -> { - // Runs on a binder thread - AndroidUtils.runOnUiThread(() -> afterScan(uri)); - }); +// String[] paths = {destination.getFullPath()}; + + // FIXME: does not work. Who in their right mind even wants their downloaded images + // to be scanned by the google botnet +// MediaScannerConnection.scanFile(getAppContext(), paths, null, (path, uri) -> { +// // Runs on a binder thread +// AndroidUtils.runOnUiThread(() -> afterScan(uri)); +// }); } private boolean copyToDestination(File source) { boolean result = false; try { - File parent = destination.getParentFile(); - if (!parent.mkdirs() && !parent.isDirectory()) { - throw new IOException("Could not create parent directory"); + AbstractFile createdDestinationFile = fileManager.create(destination); + if (createdDestinationFile == null) { + throw new IOException("Could not create destination file, path = " + destination.getFullPath()); } - if (destination.isDirectory()) { + if (fileManager.isDirectory(createdDestinationFile)) { throw new IOException("Destination file is already a directory"); } - IOUtils.copyFile(source, destination); + if (!fileManager.copyFileContents(fileManager.fromRawFile(source), createdDestinationFile)) { + throw new IOException("Could not copy source file into destination"); + } result = true; } catch (IOException e) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaver.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaver.java index 306518c9a7..c09d98be48 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaver.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/saver/ImageSaver.java @@ -22,17 +22,23 @@ import android.os.SystemClock; import android.widget.Toast; +import androidx.annotation.Nullable; + import com.github.adamantcheese.chan.R; import com.github.adamantcheese.chan.StartActivity; import com.github.adamantcheese.chan.core.model.PostImage; import com.github.adamantcheese.chan.core.settings.ChanSettings; import com.github.adamantcheese.chan.ui.helper.RuntimePermissionsHelper; import com.github.adamantcheese.chan.ui.service.SavingNotification; +import com.github.adamantcheese.chan.ui.settings.base_directory.SavedFilesBaseDirectory; +import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.FileSegment; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; -import java.io.File; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -45,52 +51,74 @@ public class ImageSaver implements ImageSaveTask.ImageSaveTaskCallback { private static final String TAG = "ImageSaver"; private static final int MAX_NAME_LENGTH = 50; private static final Pattern UNSAFE_CHARACTERS_PATTERN = Pattern.compile("[^a-zA-Z0-9._\\\\ -]"); + + private FileManager fileManager; private ExecutorService executor = Executors.newSingleThreadExecutor(); private int doneTasks = 0; private int totalTasks = 0; private Toast toast; - public ImageSaver() { + public ImageSaver(FileManager fileManager) { + this.fileManager = fileManager; + EventBus.getDefault().register(this); } - public void startDownloadTask(Context context, final ImageSaveTask task) { + public void startDownloadTask( + Context context, + final ImageSaveTask task, + DownloadTaskCallbacks callbacks) { + if (hasPermission(context)) { + startDownloadTaskInternal(task, callbacks); + return; + } + + requestPermission(context, granted -> { + if (!granted) { + callbacks.onError("Cannot start saving images without WRITE permission"); + return; + } + + startDownloadTaskInternal(task, callbacks); + }); + } + + private void startDownloadTaskInternal( + ImageSaveTask task, + DownloadTaskCallbacks callbacks) { + AbstractFile saveLocation = getSaveLocation(task); + if (saveLocation == null) { + callbacks.onError("Couldn't figure out save location"); + return; + } + PostImage postImage = task.getPostImage(); task.setDestination(deduplicateFile(postImage, task)); - if (hasPermission(context)) { - startTask(task); - updateNotification(); - } else { - // This does not request the permission when another request is pending. - // This is ok and will drop the task. - requestPermission(context, granted -> { - if (granted) { - startTask(task); - updateNotification(); - } else { - showToast(null, false, false); - } - }); - } + // At this point we already have disk permissions + startTask(task); + updateNotification(); } public boolean startBundledTask(Context context, final String subFolder, final List tasks) { if (hasPermission(context)) { - startBundledTaskInternal(subFolder, tasks); - return true; - } else { - // This does not request the permission when another request is pending. - // This is ok and will drop the tasks. - requestPermission(context, granted -> { - if (granted) { - startBundledTaskInternal(subFolder, tasks); - } else { - showToast(null, false, false); - } - }); - return false; + return startBundledTaskInternal(subFolder, tasks); } + + // This does not request the permission when another request is pending. + // This is ok and will drop the tasks. + requestPermission(context, granted -> { + if (granted) { + if (startBundledTaskInternal(subFolder, tasks)) { + return; + } + } + + showToast(null, false, false); + }); + + // TODO: uhh not sure about this one + return true; } public String getSubFolder(String name) { @@ -99,14 +127,32 @@ public String getSubFolder(String name) { return filtered; } - public File getSaveLocation(ImageSaveTask task) { - String base = ChanSettings.saveLocation.get(); + @Nullable + public AbstractFile getSaveLocation(ImageSaveTask task) { + AbstractFile baseSaveDir = fileManager.newBaseDirectoryFile(SavedFilesBaseDirectory.class); + if (baseSaveDir == null) { + Logger.e(TAG, "getSaveLocation() fileManager.newSaveLocationFile() returned null"); + return null; + } + + AbstractFile createdBaseSaveDir = fileManager.create(baseSaveDir); + + if (!fileManager.exists(baseSaveDir) || createdBaseSaveDir == null) { + Logger.e(TAG, "Couldn't create base image save directory"); + return null; + } + + if (!fileManager.baseDirectoryExists(SavedFilesBaseDirectory.class)) { + Logger.e(TAG, "Base save local directory does not exist"); + return null; + } + String subFolder = task.getSubFolder(); if (subFolder != null) { - return new File(base + File.separator + subFolder); - } else { - return new File(base); + return baseSaveDir.cloneUnsafe(subFolder); } + + return baseSaveDir; } @Override @@ -134,15 +180,25 @@ private void startTask(ImageSaveTask task) { executor.execute(task); } - private void startBundledTaskInternal(String subFolder, List tasks) { + private boolean startBundledTaskInternal(String subFolder, List tasks) { + boolean allSuccess = true; + for (ImageSaveTask task : tasks) { PostImage postImage = task.getPostImage(); - task.setSubFolder(subFolder); - task.setDestination(deduplicateFile(postImage, task)); + AbstractFile deduplicateFile = deduplicateFile(postImage, task); + if (deduplicateFile == null) { + allSuccess = false; + continue; + } + + task.setSubFolder(subFolder); + task.setDestination(deduplicateFile); startTask(task); } + updateNotification(); + return allSuccess; } private void cancelAll() { @@ -173,15 +229,42 @@ private void showToast(ImageSaveTask task, boolean success, boolean wasAlbumSave toast.cancel(); } - String text = success ? - (wasAlbumSave ? getAppContext().getString(R.string.album_download_success, getSaveLocation(task).getPath()) : getAppContext().getString(R.string.image_save_as, task.getDestination().getName())) : - getString(R.string.image_save_failed); + String text = getText(task, success, wasAlbumSave); toast = Toast.makeText(getAppContext(), text, Toast.LENGTH_LONG); + if (task != null && !task.getShare()) { toast.show(); } } + private String getText(ImageSaveTask task, boolean success, boolean wasAlbumSave) { + String text; + if (success) { + if (wasAlbumSave) { + String location; + AbstractFile locationFile = getSaveLocation(task); + + if (locationFile == null) { + location = "Unknown location"; + } else { + location = locationFile.getFullPath(); + } + + text = getAppContext().getString( + R.string.album_download_success, + location); + } else { + text = getAppContext().getString( + R.string.image_save_as, + fileManager.getName(task.getDestination())); + } + } else { + text = getString(R.string.image_save_failed); + } + + return text; + } + private String filterName(String name) { name = UNSAFE_CHARACTERS_PATTERN.matcher(name).replaceAll(""); if (name.length() == 0) { @@ -190,14 +273,33 @@ private String filterName(String name) { return name; } - private File deduplicateFile(PostImage postImage, ImageSaveTask task) { - String name = ChanSettings.saveServerFilename.get() ? postImage.serverFilename : postImage.filename; + @Nullable + private AbstractFile deduplicateFile(PostImage postImage, ImageSaveTask task) { + String name = ChanSettings.saveServerFilename.get() + ? postImage.serverFilename + : postImage.filename; + String fileName = filterName(name + "." + postImage.extension); - File saveFile = new File(getSaveLocation(task), fileName); - while (saveFile.exists()) { - fileName = filterName(name + "_" + Long.toString(SystemClock.elapsedRealtimeNanos(), Character.MAX_RADIX) + "." + postImage.extension); - saveFile = new File(getSaveLocation(task), fileName); + + AbstractFile saveLocation = getSaveLocation(task); + if (saveLocation == null) { + Logger.e(TAG, "Save location is null!"); + return null; } + + AbstractFile saveFile = saveLocation + .clone(new FileSegment(fileName)); + + while (fileManager.exists(saveFile)) { + String resultFileName = name + "_" + + Long.toString(SystemClock.elapsedRealtimeNanos(), Character.MAX_RADIX) + + "." + postImage.extension; + + fileName = filterName(resultFileName); + saveFile = saveLocation + .clone(new FileSegment(fileName)); + } + return saveFile; } @@ -208,4 +310,8 @@ private boolean hasPermission(Context context) { private void requestPermission(Context context, RuntimePermissionsHelper.Callback callback) { ((StartActivity) context).getRuntimePermissionsHelper().requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, callback); } + + public interface DownloadTaskCallbacks { + void onError(String message); + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/ChanSettings.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/ChanSettings.java index b8df111f92..3a9097a422 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/ChanSettings.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/ChanSettings.java @@ -20,7 +20,11 @@ import android.os.Environment; import android.text.TextUtils; +import androidx.annotation.NonNull; + +import com.github.adamantcheese.chan.BuildConfig; import com.github.adamantcheese.chan.R; +import com.github.adamantcheese.chan.core.manager.ThreadSaveManager; import com.github.adamantcheese.chan.ui.adapter.PostsFilter; import com.github.adamantcheese.chan.utils.AndroidUtils; @@ -100,7 +104,9 @@ public String getKey() { } private static Proxy proxy; - private static final String sharedPrefsFile = "shared_prefs/com.github.adamantcheese.chan_preferences.xml"; + private static final String sharedPrefsFile = "shared_prefs/" + + BuildConfig.APPLICATION_ID + + "_preferences.xml"; private static final StringSetting theme; public static final OptionsSetting layoutMode; @@ -122,6 +128,9 @@ public String getKey() { public static final BooleanSetting shortPinInfo; public static final StringSetting saveLocation; + public static final StringSetting saveLocationUri; + public static final StringSetting localThreadLocation; + public static final StringSetting localThreadsLocationUri; public static final BooleanSetting saveServerFilename; public static final BooleanSetting shareUrl; public static final BooleanSetting enableReplyFab; @@ -182,7 +191,6 @@ public String getKey() { public static final BooleanSetting highResCells; public static final BooleanSetting incrementalThreadDownloadingEnabled; - public static final BooleanSetting fullUserRotationEnable; public static final IntegerSetting drawerAutoOpenCount; @@ -220,9 +228,26 @@ public String getKey() { postPinThread = new BooleanSetting(p, "preference_pin_on_post", false); shortPinInfo = new BooleanSetting(p, "preference_short_pin_info", true); - saveLocation = new StringSetting(p, "preference_image_save_location", Environment.getExternalStorageDirectory() + File.separator + getApplicationLabel()); - saveLocation.addCallback((setting, value) -> - EventBus.getDefault().post(new SettingChanged<>(saveLocation))); + saveLocation = new StringSetting(p, "preference_image_save_location", getDefaultSaveLocationDir()); + saveLocation.addCallback((setting, value) -> { + EventBus.getDefault().post(new SettingChanged<>(saveLocation)); + }); + + saveLocationUri = new StringSetting(p, "preference_image_save_location_uri", ""); + saveLocationUri.addCallback(((setting, value) -> { + EventBus.getDefault().post(new SettingChanged<>(saveLocationUri)); + })); + + localThreadLocation = new StringSetting(p, "local_threads_location", getDefaultLocalThreadsLocation()); + localThreadLocation.addCallback(((setting, value) -> { + EventBus.getDefault().post(new SettingChanged<>(localThreadLocation)); + })); + + localThreadsLocationUri = new StringSetting(p, "local_threads_location_uri", ""); + localThreadsLocationUri.addCallback((settings, value) -> { + EventBus.getDefault().post(new SettingChanged<>(localThreadsLocationUri)); + }); + saveServerFilename = new BooleanSetting(p, "preference_image_save_original", false); shareUrl = new BooleanSetting(p, "preference_image_share_url", false); accessibleInfo = new BooleanSetting(p, "preference_enable_accessible_info", false); @@ -293,9 +318,7 @@ public String getKey() { shiftPostFormat = new BooleanSetting(p, "shift_post_format", true); enableEmoji = new BooleanSetting(p, "enable_emoji", false); highResCells = new BooleanSetting(p, "high_res_cells", false); - incrementalThreadDownloadingEnabled = new BooleanSetting(p, "incremental_thread_downloading", false); - fullUserRotationEnable = new BooleanSetting(p, "full_user_rotation_enable", true); drawerAutoOpenCount = new IntegerSetting(p, "drawer_auto_open_count", 0); @@ -308,6 +331,22 @@ public String getKey() { parsePostImageLinks = new BooleanSetting(p, "parse_post_image_links", false); } + @NonNull + public static String getDefaultLocalThreadsLocation() { + return Environment.getExternalStorageDirectory() + + File.separator + + getApplicationLabel() + + File.separator + + ThreadSaveManager.SAVED_THREADS_DIR_NAME; + } + + @NonNull + public static String getDefaultSaveLocationDir() { + return Environment.getExternalStorageDirectory() + + File.separator + + getApplicationLabel(); + } + public static ThemeColor getThemeAndColor() { String themeRaw = ChanSettings.theme.get(); @@ -356,14 +395,45 @@ private static void loadProxy() { * Called on the Database thread. */ public static String serializeToString() throws IOException { + String prevSaveLocationUri = null; + String prevLocalThreadsLocationUri = null; + + // We need to check if the user has any of the location settings set to a SAF directory. + // We can't export them because if the user reinstalls the app and then imports a location + // setting that point to a SAF directory that directory won't be valid for the app because + // after clearing settings all permissions for that directory will be lost. So in case the + // user tries to export SAF directory paths we don't export them and instead export default + // locations. But we also don't wont to change the paths for the current app so we need to + // save the previous paths, patch the sharedPrefs file read it to string and then restore + // the current paths back to what they were before exporting. + if (!ChanSettings.saveLocationUri.get().isEmpty()) { + // Save the saveLocationUri + prevSaveLocationUri = ChanSettings.saveLocationUri.get(); + + ChanSettings.saveLocationUri.remove(); + ChanSettings.saveLocation.setSyncNoCheck(ChanSettings.getDefaultSaveLocationDir()); + } + + if (!ChanSettings.localThreadsLocationUri.get().isEmpty()) { + // Save the localThreadsLocationUri + prevLocalThreadsLocationUri = ChanSettings.localThreadsLocationUri.get(); + + ChanSettings.localThreadsLocationUri.remove(); + ChanSettings.localThreadLocation.setSyncNoCheck( + ChanSettings.getDefaultLocalThreadsLocation() + ); + } + File file = new File(AndroidUtils.getAppDir(), sharedPrefsFile); if (!file.exists()) { - throw new IOException("Shared preferences file does not exist! (" + file.getAbsolutePath() + ")"); + throw new IOException("Shared preferences file does not exist! " + + "(" + file.getAbsolutePath() + ")"); } if (!file.canRead()) { - throw new IOException("Cannot read from shared preferences file! (" + file.getAbsolutePath() + ")"); + throw new IOException("Cannot read from shared preferences file!" + + "(" + file.getAbsolutePath() + ")"); } byte[] buffer = new byte[(int) file.length()]; @@ -372,10 +442,22 @@ public static String serializeToString() throws IOException { int readAmount = inputStream.read(buffer); if (readAmount != file.length()) { - throw new IOException("Could not read shared prefs file readAmount != fileLength " + readAmount + ", " + file.length()); + throw new IOException("Could not read shared prefs file readAmount != fileLength " + + readAmount + ", " + file.length()); } } + // Restore back the previous paths + if (prevSaveLocationUri != null) { + ChanSettings.saveLocation.setSyncNoCheck(""); + ChanSettings.saveLocationUri.setSyncNoCheck(prevSaveLocationUri); + } + + if (prevLocalThreadsLocationUri != null) { + ChanSettings.localThreadLocation.setSyncNoCheck(""); + ChanSettings.localThreadsLocationUri.setSyncNoCheck(prevLocalThreadsLocationUri); + } + return new String(buffer); } @@ -387,11 +469,13 @@ public static void deserializeFromString(String settings) throws IOException { File file = new File(AndroidUtils.getAppDir(), sharedPrefsFile); if (!file.exists()) { - throw new IOException("Shared preferences file does not exist! (" + file.getAbsolutePath() + ")"); + throw new IOException("Shared preferences file does not exist! " + + "(" + file.getAbsolutePath() + ")"); } if (!file.canWrite()) { - throw new IOException("Cannot write to shared preferences file! (" + file.getAbsolutePath() + ")"); + throw new IOException("Cannot write to shared preferences file! " + + "(" + file.getAbsolutePath() + ")"); } try (FileOutputStream outputStream = new FileOutputStream(file)) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SettingProvider.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SettingProvider.java index 2a98cdc847..eb9aa35927 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SettingProvider.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SettingProvider.java @@ -34,4 +34,6 @@ public interface SettingProvider { void putString(String key, String value); void putStringSync(String key, String value); + + void removeSync(String key); } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SharedPreferencesSettingProvider.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SharedPreferencesSettingProvider.java index c102ea09cb..988c1a215d 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SharedPreferencesSettingProvider.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/SharedPreferencesSettingProvider.java @@ -81,4 +81,9 @@ public void putString(String key, String value) { public void putStringSync(String key, String value) { prefs.edit().putString(key, value).commit(); } + + @Override + public void removeSync(String key) { + prefs.edit().remove(key).commit(); + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/StringSetting.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/StringSetting.java index d0316bd6d0..72a2f4e242 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/StringSetting.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/StringSetting.java @@ -44,6 +44,13 @@ public void set(String value) { } } + public void setNoUpdate(String value) { + if (!value.equals(get())) { + settingProvider.putString(key, value); + cached = value; + } + } + public void setSync(String value) { if (!value.equals(get())) { settingProvider.putStringSync(key, value); @@ -51,4 +58,16 @@ public void setSync(String value) { onValueChanged(); } } + + public void setSyncNoCheck(String value) { + settingProvider.putStringSync(key, value); + cached = value; + onValueChanged(); + } + + public void remove() { + settingProvider.removeSync(key); + hasCached = false; + cached = null; + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/json/JsonSettingsProvider.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/json/JsonSettingsProvider.java index 897b66d691..17018cd243 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/json/JsonSettingsProvider.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/settings/json/JsonSettingsProvider.java @@ -127,4 +127,9 @@ public void putStringSync(String key, String value) { public interface Callback { void save(); } + + @Override + public void removeSync(String key) { + throw new UnsupportedOperationException(); + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/site/SiteIcon.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/site/SiteIcon.java index 27feda3941..54f8afdc49 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/site/SiteIcon.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/core/site/SiteIcon.java @@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable; import com.android.volley.VolleyError; -import com.android.volley.toolbox.ImageLoader; import com.android.volley.toolbox.ImageLoader.ImageContainer; import com.android.volley.toolbox.ImageLoader.ImageListener; import com.github.adamantcheese.chan.core.image.ImageLoaderV2; diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/AlbumDownloadController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/AlbumDownloadController.java index 91ee4e0f7b..cd451b040f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/AlbumDownloadController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/AlbumDownloadController.java @@ -30,7 +30,6 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; -import com.github.adamantcheese.chan.Chan; import com.github.adamantcheese.chan.R; import com.github.adamantcheese.chan.controller.Controller; import com.github.adamantcheese.chan.core.model.PostImage; @@ -47,6 +46,9 @@ import java.util.ArrayList; import java.util.List; +import javax.inject.Inject; + +import static com.github.adamantcheese.chan.Chan.inject; import static com.github.adamantcheese.chan.utils.AndroidUtils.dp; public class AlbumDownloadController extends Controller implements View.OnClickListener { @@ -56,10 +58,15 @@ public class AlbumDownloadController extends Controller implements View.OnClickL private List items = new ArrayList<>(); private Loadable loadable; + @Inject + ImageSaver imageSaver; + private boolean allChecked = true; public AlbumDownloadController(Context context) { super(context); + + inject(this); } @Override @@ -94,7 +101,7 @@ public void onClick(View v) { if (checkCount == 0) { Toast.makeText(context, R.string.album_download_none_checked, Toast.LENGTH_SHORT).show(); } else { - final String folderForAlbum = Chan.injector().instance(ImageSaver.class).getSubFolder(loadable.title); + final String folderForAlbum = imageSaver.getSubFolder(loadable.title); String message = context.getString(R.string.album_download_confirm, context.getResources().getQuantityString(R.plurals.image, checkCount, checkCount), @@ -111,9 +118,15 @@ public void onClick(View v) { } } - if (Chan.injector().instance(ImageSaver.class).startBundledTask(context, folderForAlbum, tasks)) { + if (imageSaver.startBundledTask(context, folderForAlbum, tasks)) { navigationController.popController(); + return; } + + Toast.makeText( + context, + R.string.album_download_could_not_save_one_or_more_images, + Toast.LENGTH_SHORT).show(); }) .show(); } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/DeveloperSettingsController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/DeveloperSettingsController.java index 1971833298..62a6d29c8a 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/DeveloperSettingsController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/DeveloperSettingsController.java @@ -35,7 +35,6 @@ import com.github.adamantcheese.chan.utils.Logger; import java.lang.reflect.Field; -import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.Set; diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ExperimentalSettingsController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ExperimentalSettingsController.java index 2d62e1e469..20f5cf9881 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ExperimentalSettingsController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ExperimentalSettingsController.java @@ -12,9 +12,7 @@ import com.github.adamantcheese.chan.ui.settings.SettingView; import com.github.adamantcheese.chan.ui.settings.SettingsController; import com.github.adamantcheese.chan.ui.settings.SettingsGroup; -import com.github.adamantcheese.chan.utils.IOUtils; -import java.io.File; import java.util.ArrayList; import java.util.List; @@ -23,6 +21,8 @@ import static com.github.adamantcheese.chan.Chan.inject; public class ExperimentalSettingsController extends SettingsController { + private static final String TAG = "ExperimentalSettingsController"; + public ExperimentalSettingsController(Context context) { super(context); } @@ -94,14 +94,9 @@ private void cancelAllDownloads() { databaseManager.getDatabasePinManager().updatePins(downloadPins).call(); for (Pin pin : downloadPins) { - String threadSubDir = ThreadSaveManager.getThreadSubDir(pin.loadable); - File threadSaveDir = new File(ChanSettings.saveLocation.get(), threadSubDir); - - if (!threadSaveDir.exists() || !threadSaveDir.isDirectory()) { - continue; - } - - IOUtils.deleteDirWithContents(threadSaveDir); + databaseManager.getDatabaseSavedThreadManager().deleteThreadFromDisk( + pin.loadable + ); } return null; diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/FiltersController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/FiltersController.java index 980d2b2753..1192efcd89 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/FiltersController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/FiltersController.java @@ -18,7 +18,6 @@ import android.annotation.SuppressLint; import android.content.Context; -import android.graphics.Color; import android.graphics.drawable.Drawable; import android.text.Html; import android.text.TextUtils; diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImageViewerController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImageViewerController.java index 73714a3201..503e7a0c22 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImageViewerController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImageViewerController.java @@ -37,15 +37,15 @@ import android.view.animation.DecelerateInterpolator; import android.widget.ArrayAdapter; import android.widget.ListView; +import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import com.android.volley.VolleyError; -import com.android.volley.toolbox.ImageLoader; import com.android.volley.toolbox.ImageLoader.ImageContainer; import com.android.volley.toolbox.ImageLoader.ImageListener; import com.davemorrissey.labs.subscaleview.ImageViewState; -import com.github.adamantcheese.chan.Chan; import com.github.adamantcheese.chan.R; import com.github.adamantcheese.chan.controller.Controller; import com.github.adamantcheese.chan.core.image.ImageLoaderV2; @@ -74,10 +74,12 @@ import com.github.adamantcheese.chan.ui.view.TransitionImageView; import com.github.adamantcheese.chan.utils.AndroidUtils; import com.github.adamantcheese.chan.utils.Logger; +import com.github.adamantcheese.chan.utils.StringUtils; import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import javax.inject.Inject; @@ -99,6 +101,8 @@ public class ImageViewerController extends Controller implements ImageViewerPres @Inject ImageLoaderV2 imageLoaderV2; + @Inject + ImageSaver imageSaver; private int statusBarColorPrevious; private AnimatorSet startAnimation; @@ -278,37 +282,66 @@ private void saveShare(boolean share, PostImage postImage) { ImageSaveTask task = new ImageSaveTask(loadable, postImage); task.setShare(share); if (ChanSettings.saveBoardFolder.get()) { - String subFolderName = - presenter.getLoadable().site.name() + - File.separator + - presenter.getLoadable().boardCode; + String subFolderName; + if (ChanSettings.saveThreadFolder.get()) { - //save to op no appended with the first 50 characters of the subject - //should be unique and perfectly understandable title wise - // - //if we're saving from the catalog, find the post for the image and use its attributes to keep everything consistent - //as the loadable is for the catalog and doesn't have the right info - subFolderName = subFolderName + - File.separator + - (presenter.getLoadable().no == 0 ? - imageViewerCallback.getPostForPostImage(postImage).no : - presenter.getLoadable().no) + - "_"; - String tempTitle = (presenter.getLoadable().no == 0 ? - PostHelper.getTitle(imageViewerCallback.getPostForPostImage(postImage), null) : - presenter.getLoadable().title) - .toLowerCase() - .replaceAll(" ", "_") - .replaceAll("[^a-z0-9_]", ""); - tempTitle = tempTitle.substring(0, Math.min(tempTitle.length(), 50)); - subFolderName = subFolderName + tempTitle; + subFolderName = appendAdditionalSubDirectories(postImage); + } else { + subFolderName = presenter.getLoadable().site.name() + + File.separator + + presenter.getLoadable().boardCode; } + task.setSubFolder(subFolderName); } - Chan.injector().instance(ImageSaver.class).startDownloadTask(context, task); + + imageSaver.startDownloadTask(context, task, message -> { + String errorMessage = String.format( + Locale.US, + "%s, error message = %s", + "Couldn't start download task", + message); + + Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show(); + }); } } + @NonNull + private String appendAdditionalSubDirectories(PostImage postImage) { + // save to op no appended with the first 50 characters of the subject + // should be unique and perfectly understandable title wise + // + // if we're saving from the catalog, find the post for the image and use its attributes + // to keep everything consistent as the loadable is for the catalog and doesn't have + // the right info + + String siteName = presenter.getLoadable().site.name(); + + int postNoString = presenter.getLoadable().no == 0 + ? imageViewerCallback.getPostForPostImage(postImage).no + : presenter.getLoadable().no; + + String sanitizedSubFolderName = StringUtils.dirNameRemoveBadCharacters(siteName) + + File.separator + + StringUtils.dirNameRemoveBadCharacters(presenter.getLoadable().boardCode) + + File.separator + + postNoString + + "_"; + + String tempTitle = (presenter.getLoadable().no == 0 + ? PostHelper.getTitle(imageViewerCallback.getPostForPostImage(postImage), null) + : presenter.getLoadable().title); + + String sanitizedFileName = StringUtils.dirNameRemoveBadCharacters(tempTitle); + String truncatedFileName = sanitizedFileName.substring( + 0, + Math.min(sanitizedFileName.length(), 50) + ); + + return sanitizedSubFolderName + truncatedFileName; + } + @Override public boolean onBack() { showSystemUI(); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImportExportSettingsController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImportExportSettingsController.java index a4201dace5..5b27b77ed1 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImportExportSettingsController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ImportExportSettingsController.java @@ -16,16 +16,13 @@ */ package com.github.adamantcheese.chan.ui.controller; -import android.Manifest; -import android.content.ClipData; -import android.content.ClipboardManager; +import android.app.AlertDialog; import android.content.Context; -import android.os.Environment; +import android.net.Uri; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import com.github.adamantcheese.chan.R; import com.github.adamantcheese.chan.StartActivity; @@ -36,27 +33,42 @@ import com.github.adamantcheese.chan.ui.settings.SettingsController; import com.github.adamantcheese.chan.ui.settings.SettingsGroup; import com.github.adamantcheese.chan.utils.AndroidUtils; +import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileChooser; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.callback.FileChooserCallback; +import com.github.k1rakishou.fsaf.callback.FileCreateCallback; +import com.github.k1rakishou.fsaf.file.ExternalFile; -import java.io.File; +import org.jetbrains.annotations.NotNull; +import javax.inject.Inject; + +import static com.github.adamantcheese.chan.Chan.inject; import static com.github.adamantcheese.chan.utils.AndroidUtils.getApplicationLabel; public class ImportExportSettingsController extends SettingsController implements ImportExportSettingsPresenter.ImportExportSettingsCallbacks { + private static final String TAG = "ImportExportSettingsController"; public static final String EXPORT_FILE_NAME = getApplicationLabel() + "_exported_settings.json"; - @Nullable + @Inject + FileManager fileManager; + @Inject + FileChooser fileChooser; + private ImportExportSettingsPresenter presenter; @Nullable private OnExportSuccessCallbacks callbacks; private LoadingViewController loadingViewController; - private File settingsFile = new File(ChanSettings.saveLocation.get(), EXPORT_FILE_NAME); public ImportExportSettingsController(Context context, @NonNull OnExportSuccessCallbacks callbacks) { super(context); + inject(this); + this.callbacks = callbacks; this.loadingViewController = new LoadingViewController(context, true); } @@ -72,8 +84,6 @@ public void onCreate() { setupLayout(); populatePreferences(); buildPreferences(); - - showCreateDirectoryDialog(); } @Override @@ -112,103 +122,154 @@ private void populatePreferences() { } private void onExportClicked() { - String state = Environment.getExternalStorageState(); - if (!Environment.MEDIA_MOUNTED.equals(state)) { - showMessage(context.getString(R.string.error_external_storage_is_not_mounted)); + boolean localThreadsLocationIsSAFBacked = !ChanSettings.localThreadsLocationUri.get().isEmpty(); + boolean savedFilesLocationIsSAFBacked = !ChanSettings.saveLocationUri.get().isEmpty(); + + if (localThreadsLocationIsSAFBacked || savedFilesLocationIsSAFBacked) { + showSomeBaseDirectoriesWillBeResetToDefaultDialog( + localThreadsLocationIsSAFBacked, + savedFilesLocationIsSAFBacked + ); return; } - ((StartActivity) context).getRuntimePermissionsHelper().requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, granted -> { - if (granted && presenter != null) { - navigationController.presentController(loadingViewController); - presenter.doExport(settingsFile); - } else { - ((StartActivity) context).getRuntimePermissionsHelper().showPermissionRequiredDialog(context, - context.getString(R.string.update_storage_permission_required_title), - context.getString(R.string.storage_permission_required_to_export_settings), - this::onExportClicked); - } - }); + showCreateNewOrOverwriteDialog(); } - private void showCreateDirectoryDialog() { - String state = Environment.getExternalStorageState(); - if (!Environment.MEDIA_MOUNTED.equals(state)) { - showMessage(context.getString(R.string.error_external_storage_is_not_mounted)); - return; + private void showSomeBaseDirectoriesWillBeResetToDefaultDialog( + boolean localThreadsLocationIsSAFBacked, + boolean savedFilesLocationIsSAFBacked + ) { + if (!localThreadsLocationIsSAFBacked && !savedFilesLocationIsSAFBacked) { + throw new IllegalStateException("Both variables are false, wtf?"); } - // if we already have the permission and the default directory already exists - do not show - // the dialog again - if (((StartActivity) context).getRuntimePermissionsHelper().hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - if (settingsFile.getParentFile().exists()) { - return; - } - } + String localThreadsString = localThreadsLocationIsSAFBacked + ? context.getString(R.string.import_or_export_warning_local_threads_base_dir) + : ""; + String andString = localThreadsLocationIsSAFBacked && savedFilesLocationIsSAFBacked + ? context.getString(R.string.import_or_export_warning_and) + : ""; + String savedFilesString = savedFilesLocationIsSAFBacked + ? context.getString(R.string.import_or_export_warning_saved_files_base_dir) + : ""; + + String message = context.getString( + R.string.import_or_export_warning_super_long_message, + localThreadsString, + andString, + savedFilesString + ); + + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.import_or_export_warning)) + .setMessage(message) + .setPositiveButton(R.string.media_settings_ok, (dialog, which) -> { + dialog.dismiss(); + showCreateNewOrOverwriteDialog(); + }) + .create(); - // Ask the user's permission to check whether the default directory exists and create it if it doesn't - new AlertDialog.Builder(context) - .setTitle(context.getString(R.string.default_directory_may_not_exist_title)) - .setMessage(context.getString(R.string.default_directory_may_not_exist_message)) - .setPositiveButton(context.getString(R.string.create), (dialog1, which) -> ((StartActivity) context).getRuntimePermissionsHelper().requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, granted -> { - if (granted) { - onPermissionGrantedForDirectoryCreation(); - } - })) - .setNegativeButton(context.getString(R.string.do_not), null) - .create() - .show(); + alertDialog.show(); } - private void onPermissionGrantedForDirectoryCreation() { - if (settingsFile.getParentFile().exists()) { - return; - } + /** + * SAF is kinda retarded so it cannot be used to overwrite a file that already exist on the disk + * (or at some network location). When trying to do so, a new file with appended "(1)" at the + * end will appear. That's why there are two methods (one for overwriting an existing file and + * the other one for creating a new file) instead of one that does everything. + * */ + private void showCreateNewOrOverwriteDialog() { + int positiveButtonId = R.string.import_or_export_dialog_positive_button_text; + int negativeButtonId = R.string.import_or_export_dialog_negative_button_text; + + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(R.string.import_or_export_dialog_title) + .setPositiveButton(positiveButtonId, (dialog, which) -> { + overwriteExisting(); + }) + .setNegativeButton(negativeButtonId, (dialog, which) -> { + createNew(); + }) + .create(); - if (!settingsFile.getParentFile().mkdirs()) { - showMessage(context.getString(R.string.could_not_create_dir_for_export_error_text, settingsFile.getParentFile().getAbsolutePath())); - } + alertDialog.show(); } - private void onImportClicked() { - String state = Environment.getExternalStorageState(); - if (!Environment.MEDIA_MOUNTED.equals(state)) { - showMessage(context.getString(R.string.error_external_storage_is_not_mounted)); - return; - } + /** + * Opens an existing file (any file) for overwriting with the settings. + * */ + private void overwriteExisting() { + fileChooser.openChooseFileDialog(new FileChooserCallback() { + @Override + public void onResult(@NotNull Uri uri) { + onFileChosen(uri, false); + } - ((StartActivity) context).getRuntimePermissionsHelper().requestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, granted -> { - if (granted) { - onPermissionGrantedForImport(); - } else { - ((StartActivity) context).getRuntimePermissionsHelper().showPermissionRequiredDialog(context, - context.getString(R.string.update_storage_permission_required_title), - context.getString(R.string.storage_permission_required_to_import_settings), - this::onImportClicked); + @Override + public void onCancel(@NotNull String reason) { + showMessage(reason); } }); } - private void onPermissionGrantedForImport() { - String warningMessage = context.getString(R.string.import_warning_text, - settingsFile.getParentFile().getPath(), - settingsFile.getName()); - - AlertDialog dialog = new AlertDialog.Builder(context) - .setTitle(R.string.import_warning_title) - .setMessage(warningMessage) - .setPositiveButton(R.string.continue_text, (dialog1, which) -> onStartImportButtonClicked()) - .setNegativeButton(R.string.cancel, null) - .create(); + /** + * Creates a new file with the default name (that can be changed in the file chooser) with the + * settings. Cannot be used for overwriting an old settings file (when trying to do so a new file + * with appended "(1)" at the end will appear, e.g. "test (1).txt") + * */ + private void createNew() { + fileChooser.openCreateFileDialog(EXPORT_FILE_NAME, new FileCreateCallback() { + @Override + public void onResult(@NotNull Uri uri) { + onFileChosen(uri, true); + } - dialog.show(); + @Override + public void onCancel(@NotNull String reason) { + showMessage(reason); + } + }); } - private void onStartImportButtonClicked() { - if (presenter != null) { - navigationController.presentController(loadingViewController); - presenter.doImport(settingsFile); + private void onFileChosen(Uri uri, boolean isNewFile) { + // We use SAF here by default because settings importing/exporting does not depend on the + // Kuroba default directory location. There is just no need to use old java files. + ExternalFile externalFile = fileManager.fromUri(uri); + if (externalFile == null) { + String message = "onFileChosen() fileManager.fromUri() returned null, uri = " + uri; + + Logger.d(TAG, message); + showMessage(message); + return; } + + navigationController.presentController(loadingViewController); + presenter.doExport(externalFile, isNewFile); + } + + private void onImportClicked() { + fileChooser.openChooseFileDialog(new FileChooserCallback() { + @Override + public void onResult(@NotNull Uri uri) { + ExternalFile externalFile = fileManager.fromUri(uri); + if (externalFile == null) { + String message = "onImportClicked() fileManager.fromUri() returned null, uri = " + uri; + + Logger.d(TAG, message); + showMessage(message); + return; + } + + navigationController.presentController(loadingViewController); + presenter.doImport(externalFile); + } + + @Override + public void onCancel(@NotNull String reason) { + showMessage(reason); + } + }); } @Override @@ -219,11 +280,8 @@ public void onSuccess(ImportExportRepository.ImportExport importExport) { if (importExport == ImportExportRepository.ImportExport.Import) { ((StartActivity) context).restartApp(); } else { - copyDirPathToClipboard(); clearAllChildControllers(); - - showMessage(context.getString(R.string.successfully_exported_text, - settingsFile.getAbsolutePath())); + showMessage(context.getString(R.string.successfully_exported_text)); if (callbacks != null) { callbacks.finish(); @@ -233,14 +291,6 @@ public void onSuccess(ImportExportRepository.ImportExport importExport) { } } - private void copyDirPathToClipboard() { - ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("exported_file_path", settingsFile.getPath()); - - if (clipboard != null) { - clipboard.setPrimaryClip(clip); - } - } @Override public void onError(String message) { diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/LoadingViewController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/LoadingViewController.java index 0d9eeb81bd..1f766b3b13 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/LoadingViewController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/LoadingViewController.java @@ -2,12 +2,14 @@ import android.content.Context; import android.view.View; +import android.widget.ProgressBar; import android.widget.TextView; import com.github.adamantcheese.chan.R; public class LoadingViewController extends BaseFloatingController { private TextView textView; + private ProgressBar progressBar; private boolean indeterminate; public LoadingViewController(Context context, boolean indeterminate) { @@ -20,9 +22,8 @@ public LoadingViewController(Context context, boolean indeterminate) { public void onCreate() { super.onCreate(); - if (!indeterminate) { - textView = view.findViewById(R.id.progress_percent); - } + textView = view.findViewById(R.id.text); + progressBar = view.findViewById(R.id.progress_bar); } @Override @@ -30,6 +31,9 @@ public boolean onBack() { return true; } + /** + * Shows a progress bar with percentage in the center (cannot be used with indeterminate) + * */ public void updateProgress(int percent) { if (indeterminate) { throw new IllegalStateException("Cannot be used with indeterminate flag"); @@ -39,9 +43,33 @@ public void updateProgress(int percent) { textView.setVisibility(View.VISIBLE); } + if (progressBar.getVisibility() != View.VISIBLE) { + progressBar.setVisibility(View.VISIBLE); + } + textView.setText(String.valueOf(percent)); } + /** + * Hide a progress bar and instead of percentage any text may be shown + * (cannot be used with indeterminate) + * */ + public void updateWithText(String text) { + if (indeterminate) { + throw new IllegalStateException("Cannot be used with indeterminate flag"); + } + + if (textView.getVisibility() != View.VISIBLE) { + textView.setVisibility(View.VISIBLE); + } + + if (progressBar.getVisibility() == View.VISIBLE) { + progressBar.setVisibility(View.GONE); + } + + textView.setText(text); + } + @Override protected int getLayoutId() { return R.layout.controller_loading_view; diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsController.java index 05dcb9ebeb..5e59fa0660 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsController.java @@ -16,9 +16,17 @@ */ package com.github.adamantcheese.chan.ui.controller; +import android.app.AlertDialog; import android.content.Context; +import android.net.Uri; +import android.widget.Toast; + +import androidx.annotation.NonNull; import com.github.adamantcheese.chan.R; +import com.github.adamantcheese.chan.core.database.DatabaseManager; +import com.github.adamantcheese.chan.core.manager.ThreadSaveManager; +import com.github.adamantcheese.chan.core.presenter.MediaSettingsControllerPresenter; import com.github.adamantcheese.chan.core.settings.ChanSettings; import com.github.adamantcheese.chan.ui.settings.BooleanSettingView; import com.github.adamantcheese.chan.ui.settings.LinkSettingView; @@ -27,25 +35,54 @@ import com.github.adamantcheese.chan.ui.settings.SettingsController; import com.github.adamantcheese.chan.ui.settings.SettingsGroup; import com.github.adamantcheese.chan.ui.settings.TextSettingView; +import com.github.adamantcheese.chan.utils.BackgroundUtils; +import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileChooser; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.document_file.CachingDocumentFile; +import com.github.k1rakishou.fsaf.file.AbstractFile; +import com.github.k1rakishou.fsaf.file.ExternalFile; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; +import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; +import javax.inject.Inject; + +import static com.github.adamantcheese.chan.Chan.inject; +import static com.github.adamantcheese.chan.utils.AndroidUtils.getAppContext; import static com.github.adamantcheese.chan.utils.AndroidUtils.getString; -public class MediaSettingsController extends SettingsController { +public class MediaSettingsController + extends SettingsController + implements MediaSettingsControllerCallbacks { + private static final String TAG = "MediaSettingsController"; + // Special setting views private BooleanSettingView boardFolderSetting; private BooleanSettingView threadFolderSetting; private BooleanSettingView videoDefaultMutedSetting; private BooleanSettingView headsetDefaultMutedSetting; private LinkSettingView saveLocation; + private LinkSettingView localThreadsLocation; private ListSettingView imageAutoLoadView; private ListSettingView videoAutoLoadView; + private LoadingViewController loadingViewController; + private MediaSettingsControllerPresenter presenter; + + @Inject + FileManager fileManager; + @Inject + FileChooser fileChooser; + @Inject + DatabaseManager databaseManager; + @Inject + ThreadSaveManager threadSaveManager; + public MediaSettingsController(Context context) { super(context); } @@ -53,15 +90,20 @@ public MediaSettingsController(Context context) { @Override public void onCreate() { super.onCreate(); + inject(this); EventBus.getDefault().register(this); - navigation.setTitle(R.string.settings_screen_media); - setupLayout(); + presenter = new MediaSettingsControllerPresenter( + getAppContext(), + fileManager, + fileChooser, + this + ); + setupLayout(); populatePreferences(); - buildPreferences(); onPreferenceChange(imageAutoLoadView); @@ -75,6 +117,7 @@ public void onCreate() { public void onDestroy() { super.onDestroy(); + presenter.onDestroy(); EventBus.getDefault().unregister(this); } @@ -93,8 +136,26 @@ public void onPreferenceChange(SettingView item) { @Subscribe public void onEvent(ChanSettings.SettingChanged setting) { - if (setting.setting == ChanSettings.saveLocation) { - updateSaveLocationSetting(); + if (setting.setting == ChanSettings.saveLocationUri) { + // Image save location (SAF) was chosen + String defaultDir = ChanSettings.getDefaultSaveLocationDir(); + + ChanSettings.saveLocation.setNoUpdate(defaultDir); + saveLocation.setDescription(ChanSettings.saveLocationUri.get()); + } else if (setting.setting == ChanSettings.localThreadsLocationUri) { + // Local threads location (SAF) was chosen + String defaultDir = ChanSettings.getDefaultLocalThreadsLocation(); + + ChanSettings.localThreadLocation.setNoUpdate(defaultDir); + localThreadsLocation.setDescription(ChanSettings.localThreadsLocationUri.get()); + } else if (setting.setting == ChanSettings.saveLocation) { + // Image save location (Java File API) was chosen + ChanSettings.saveLocationUri.setNoUpdate(""); + saveLocation.setDescription(ChanSettings.saveLocation.get()); + } else if (setting.setting == ChanSettings.localThreadLocation) { + // Local threads location (Java File API) was chosen + ChanSettings.localThreadsLocationUri.setNoUpdate(""); + localThreadsLocation.setDescription(ChanSettings.localThreadLocation.get()); } } @@ -104,6 +165,7 @@ private void populatePreferences() { SettingsGroup media = new SettingsGroup(R.string.settings_group_media); setupSaveLocationSetting(media); + setupLocalThreadLocationSetting(media); media.add(new TextSettingView(this, "These two options don't apply to albums")); @@ -178,6 +240,406 @@ private void populatePreferences() { } } + /** + * ============================================== + * Setup Local Threads location + * ============================================== + */ + + private void setupLocalThreadLocationSetting(SettingsGroup media) { + if (!ChanSettings.incrementalThreadDownloadingEnabled.get()) { + Logger.d(TAG, "setupLocalThreadLocationSetting() " + + "incrementalThreadDownloadingEnabled is disabled"); + return; + } + + LinkSettingView localThreadsLocationSetting = new LinkSettingView(this, + R.string.media_settings_local_threads_location_title, + 0, + v -> showUseSAFOrOldAPIForLocalThreadsLocationDialog() + ); + + localThreadsLocation = (LinkSettingView) media.add(localThreadsLocationSetting); + localThreadsLocation.setDescription(getLocalThreadsLocation()); + } + + private void showStopAllDownloadingThreadsDialog(long downloadingThreadsCount) { + String title = context.getString( + R.string.media_settings_there_are_active_downloads, + downloadingThreadsCount + ); + String message = context.getString(R.string.media_settings_you_have_to_stop_all_downloads); + + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton( + context.getString(R.string.media_settings_ok), + ((dialog, which) -> dialog.dismiss()) + ) + .create() + .show(); + } + + private String getLocalThreadsLocation() { + if (!ChanSettings.localThreadsLocationUri.get().isEmpty()) { + return ChanSettings.localThreadsLocationUri.get(); + } + + return ChanSettings.localThreadLocation.get(); + } + + private void showUseSAFOrOldAPIForLocalThreadsLocationDialog() { + long downloadingThreadsCount = databaseManager.runTask(() -> { + return databaseManager.getDatabaseSavedThreadManager().countDownloadingThreads().call(); + }); + + if (downloadingThreadsCount > 0) { + showStopAllDownloadingThreadsDialog(downloadingThreadsCount); + return; + } + + boolean areThereActiveDownloads = threadSaveManager.isThereAtLeastOneActiveDownload(); + if (areThereActiveDownloads) { + showSomeDownloadsAreStillBeingProcessed(); + return; + } + + int positiveButtonTextId = R.string.media_settings_use_saf_dialog_positive_button_text; + + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(R.string.media_settings_use_saf_for_local_threads_location_dialog_title) + .setMessage(R.string.media_settings_use_saf_for_local_threads_location_dialog_message) + .setPositiveButton(positiveButtonTextId, (dialog, which) -> { + presenter.onLocalThreadsLocationUseSAFClicked(); + }) + .setNegativeButton(R.string.media_settings_use_saf_dialog_negative_button_text, (dialog, which) -> { + onLocalThreadsLocationUseOldApiClicked(); + dialog.dismiss(); + }) + .create(); + + alertDialog.show(); + } + + private void showSomeDownloadsAreStillBeingProcessed() { + String title = + context.getString(R.string.media_settings_some_thread_downloads_are_still_processed); + String message = + context.getString(R.string.media_settings_do_not_terminate_the_app_manually); + int positiveButtonTextId = R.string.media_settings_use_saf_dialog_positive_button_text; + int negativeButtonTextId = R.string.media_settings_use_saf_dialog_negative_button_text; + + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positiveButtonTextId, (dialog, which) -> { + presenter.onLocalThreadsLocationUseSAFClicked(); + }) + .setNegativeButton(negativeButtonTextId, (dialog, which) -> { + onLocalThreadsLocationUseOldApiClicked(); + dialog.dismiss(); + }) + .create(); + + alertDialog.show(); + } + + /** + * Select a directory where local threads will be stored via the old Java File API + */ + private void onLocalThreadsLocationUseOldApiClicked() { + SaveLocationController saveLocationController = new SaveLocationController( + context, + SaveLocationController.SaveLocationControllerMode.LocalThreadsSaveLocation, + dirPath -> { + presenter.onLocalThreadsLocationChosen(dirPath); + }); + + navigationController.pushController(saveLocationController); + } + + /** + * ============================================== + * Setup Save Files location + * ============================================== + */ + + private void setupSaveLocationSetting(SettingsGroup media) { + LinkSettingView chooseSaveLocationSetting = new LinkSettingView(this, + R.string.save_location_screen, + 0, + v -> showUseSAFOrOldAPIForSaveLocationDialog()); + + saveLocation = (LinkSettingView) media.add(chooseSaveLocationSetting); + saveLocation.setDescription(getSaveLocation()); + } + + private String getSaveLocation() { + if (!ChanSettings.saveLocationUri.get().isEmpty()) { + return ChanSettings.saveLocationUri.get(); + } + + return ChanSettings.saveLocation.get(); + } + + private void showUseSAFOrOldAPIForSaveLocationDialog() { + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(R.string.media_settings_use_saf_for_save_location_dialog_title) + .setMessage(R.string.media_settings_use_saf_for_save_location_dialog_message) + .setPositiveButton(R.string.media_settings_use_saf_dialog_positive_button_text, (dialog, which) -> { + presenter.onSaveLocationUseSAFClicked(); + }) + .setNegativeButton(R.string.media_settings_use_saf_dialog_negative_button_text, (dialog, which) -> { + onSaveLocationUseOldApiClicked(); + dialog.dismiss(); + }) + .create(); + + alertDialog.show(); + } + + /** + * Select a directory where saved images will be stored via the old Java File API + */ + private void onSaveLocationUseOldApiClicked() { + SaveLocationController saveLocationController = new SaveLocationController( + context, + SaveLocationController.SaveLocationControllerMode.ImageSaveLocation, + dirPath -> { + presenter.onSaveLocationChosen(dirPath); + }); + + navigationController.pushController(saveLocationController); + } + + /** + * ============================================== + * Presenter callbacks + * ============================================== + */ + + @Override + public void askUserIfTheyWantToMoveOldThreadsToTheNewDirectory( + @NonNull AbstractFile oldBaseDirectory, + @NonNull AbstractFile newBaseDirectory + ) { + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.media_settings_move_threads_to_new_dir)) + .setMessage(context.getString(R.string.media_settings_operation_may_take_some_time)) + .setPositiveButton( + context.getString(R.string.media_settings_move_threads), + (dialog, which) -> { + presenter.moveOldFilesToTheNewDirectory( + oldBaseDirectory, + newBaseDirectory + ); + }) + .setNegativeButton( + context.getString(R.string.media_settings_do_not_move_threads), + (dialog, which) -> { + dialog.dismiss(); + } + ) + .create(); + + alertDialog.show(); + } + + @Override + public void askUserIfTheyWantToMoveOldSavedFilesToTheNewDirectory( + @NotNull AbstractFile oldBaseDirectory, + @NotNull AbstractFile newBaseDirectory + ) { + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.media_settings_move_saved_file_to_new_dir)) + .setMessage(context.getString(R.string.media_settings_operation_may_take_some_time)) + .setPositiveButton( + context.getString(R.string.media_settings_move_saved_files), + (dialog, which) -> { + presenter.moveOldFilesToTheNewDirectory( + oldBaseDirectory, + newBaseDirectory + ); + }) + .setNegativeButton( + context.getString(R.string.media_settings_do_not_move_saved_files), + (dialog, which) -> { + dialog.dismiss(); + } + ) + .create(); + + alertDialog.show(); + } + + @Override + public void onCopyDirectoryEnded( + @NonNull AbstractFile oldBaseDirectory, + @NonNull AbstractFile newBaseDirectory, + boolean result + ) { + BackgroundUtils.ensureMainThread(); + + if (loadingViewController == null) { + throw new IllegalStateException("LoadingViewController was not shown beforehand!"); + } + + loadingViewController.stopPresenting(); + loadingViewController = null; + + if (!result) { + showToast(context.getString(R.string.media_settings_couldnot_copy_files), Toast.LENGTH_LONG); + } else { + showDeleteOldFilesDialog(oldBaseDirectory); + showToast(context.getString(R.string.media_settings_files_copied), Toast.LENGTH_LONG); + } + } + + private void showDeleteOldFilesDialog( + @NonNull AbstractFile oldBaseDirectory + ) { + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.media_settings_would_you_like_to_delete_file_in_old_dir)) + .setMessage(context.getString(R.string.media_settings_file_have_been_copied)) + .setPositiveButton( + context.getString(R.string.media_settings_delete_button_name), + (dialog, which) -> onDeleteOldFilesClicked(oldBaseDirectory) + ) + .setNegativeButton( + context.getString(R.string.media_settings_do_not_delete), + (dialog, which) -> { + if (oldBaseDirectory instanceof ExternalFile) { + forgetPreviousExternalBaseDirectory(oldBaseDirectory); + } + + dialog.dismiss(); + }) + .create(); + + alertDialog.show(); + } + + private void onDeleteOldFilesClicked(@NonNull AbstractFile oldBaseDirectory) { + if (!fileManager.deleteContent(oldBaseDirectory)) { + String message = + context.getString(R.string.media_settings_couldnot_delete_files_in_old_dir); + + showToast(message, Toast.LENGTH_LONG); + return; + } + + if (oldBaseDirectory instanceof ExternalFile) { + forgetPreviousExternalBaseDirectory(oldBaseDirectory); + } + + showToast( + context.getString(R.string.media_settings_old_dir_deleted), + Toast.LENGTH_LONG + ); + } + + @Override + public void updateLoadingViewText(@NotNull String text) { + BackgroundUtils.ensureMainThread(); + + if (loadingViewController != null) { + loadingViewController.updateWithText(text); + } + } + + @Override + public void updateSaveLocationViewText(@NotNull String newLocation) { + BackgroundUtils.ensureMainThread(); + saveLocation.setDescription(newLocation); + } + + @Override + public void showToast(@NotNull String message, int length) { + BackgroundUtils.ensureMainThread(); + Toast.makeText(context, message, length).show(); + } + + @Override + public void showToast(String message) { + BackgroundUtils.ensureMainThread(); + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + } + + @Override + public void updateLocalThreadsLocation(@NotNull String newLocation) { + BackgroundUtils.ensureMainThread(); + localThreadsLocation.setDescription(newLocation); + } + + @Override + public void showCopyFilesDialog( + int filesCount, + @NotNull AbstractFile oldBaseDirectory, + @NotNull AbstractFile newBaseDirectory + ) { + BackgroundUtils.ensureMainThread(); + + if (loadingViewController != null) { + throw new IllegalStateException( + "Previous loadingViewController was not destroyed" + ); + } + + AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.media_settings_copy_files)) + .setMessage( + context.getString(R.string.media_settings_do_you_want_to_copy_files, + filesCount) + ) + .setPositiveButton( + context.getString(R.string.media_settings_copy_files), + (dialog, which) -> { + loadingViewController = new LoadingViewController( + context, + false + ); + + navigationController.presentController(loadingViewController); + + presenter.moveFilesInternal( + oldBaseDirectory, + newBaseDirectory + ); + }) + .setNegativeButton( + context.getString(R.string.media_settings_do_not_copy_files), + (dialog, which) -> { + dialog.dismiss(); + } + ) + .create(); + + alertDialog.show(); + } + + /** + * ============================================== + * Other methods + * ============================================== + */ + + private void forgetPreviousExternalBaseDirectory( + @NonNull AbstractFile oldLocalThreadsDirectory + ) { + if (oldLocalThreadsDirectory instanceof ExternalFile) { + Uri safTreeUri = oldLocalThreadsDirectory + .getFileRoot().getHolder().uri(); + + if (!fileChooser.forgetSAFTree(safTreeUri)) { + showToast( + context.getString(R.string.media_settings_could_not_release_uri_permissions), + Toast.LENGTH_SHORT + ); + } + } + } + private void setupMediaLoadTypesSetting(SettingsGroup loading) { List imageAutoLoadTypes = new ArrayList<>(); List videoAutoLoadTypes = new ArrayList<>(); @@ -234,17 +696,6 @@ private void updateVideoLoadModes() { } } - private void setupSaveLocationSetting(SettingsGroup media) { - saveLocation = (LinkSettingView) media.add(new LinkSettingView(this, - R.string.save_location_screen, 0, - v -> navigationController.pushController(new SaveLocationController(context)))); - updateSaveLocationSetting(); - } - - private void updateSaveLocationSetting() { - saveLocation.setDescription(ChanSettings.saveLocation.get()); - } - private void updateThreadFolderSetting() { if (ChanSettings.saveBoardFolder.get()) { threadFolderSetting.setEnabled(true); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsControllerCallbacks.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsControllerCallbacks.java new file mode 100644 index 0000000000..82b7fbe702 --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/MediaSettingsControllerCallbacks.java @@ -0,0 +1,37 @@ +package com.github.adamantcheese.chan.ui.controller; + +import com.github.k1rakishou.fsaf.file.AbstractFile; + +public interface MediaSettingsControllerCallbacks { + + void showToast(String message, int length); + void showToast(String message); + + void updateLocalThreadsLocation(String newLocation); + + void askUserIfTheyWantToMoveOldThreadsToTheNewDirectory( + AbstractFile oldBaseDirectory, + AbstractFile newBaseDirectory + ); + + void askUserIfTheyWantToMoveOldSavedFilesToTheNewDirectory( + AbstractFile oldBaseDirectory, + AbstractFile newBaseDirectory + ); + + void updateLoadingViewText(String text); + void updateSaveLocationViewText(String newLocation); + + void showCopyFilesDialog( + int filesCount, + AbstractFile oldBaseDirectory, + AbstractFile newBaseDirectory + ); + + void onCopyDirectoryEnded( + AbstractFile oldBaseDirectory, + AbstractFile newBaseDirectory, + boolean result + ); + +} diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/SaveLocationController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/SaveLocationController.java index dd6253c97c..e0f3654e7a 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/SaveLocationController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/SaveLocationController.java @@ -19,6 +19,7 @@ import android.Manifest; import android.annotation.SuppressLint; import android.content.Context; +import android.content.DialogInterface; import android.view.LayoutInflater; import android.view.View; import android.widget.Toast; @@ -42,13 +43,19 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi private FilesLayout filesLayout; private FloatingActionButton setButton; private FloatingActionButton addButton; - private RuntimePermissionsHelper runtimePermissionsHelper; - private FileWatcher fileWatcher; + private SaveLocationControllerMode mode; + private SaveLocationControllerCallback callback; - public SaveLocationController(Context context) { + public SaveLocationController( + Context context, + SaveLocationControllerMode mode, + SaveLocationControllerCallback callback) { super(context); + + this.callback = callback; + this.mode = mode; } @Override @@ -65,7 +72,22 @@ public void onCreate() { addButton = view.findViewById(R.id.add_button); addButton.setOnClickListener(this); - File saveLocation = new File(ChanSettings.saveLocation.get()); + File saveLocation; + + if (mode == SaveLocationControllerMode.ImageSaveLocation) { + if (ChanSettings.saveLocation.get().isEmpty()) { + throw new IllegalStateException("saveLocation is empty!"); + } + + saveLocation = new File(ChanSettings.saveLocation.get()); + } else { + if (ChanSettings.localThreadLocation.get().isEmpty()) { + throw new IllegalStateException("localThreadLocation is empty!"); + } + + saveLocation = new File(ChanSettings.localThreadLocation.get()); + } + fileWatcher = new FileWatcher(this, saveLocation); runtimePermissionsHelper = ((StartActivity) context).getRuntimePermissionsHelper(); @@ -79,8 +101,7 @@ public void onCreate() { @Override public void onClick(View v) { if (v == setButton) { - File currentPath = fileWatcher.getCurrentPath(); - ChanSettings.saveLocation.set(currentPath.getAbsolutePath()); + onDirectoryChosen(); navigationController.popController(); } else if (v == addButton) { @SuppressLint("InflateParams") final NewFolderLayout dialogView = @@ -91,17 +112,7 @@ public void onClick(View v) { .setView(dialogView) .setTitle(R.string.save_new_folder) .setPositiveButton(R.string.add, (dialog, which) -> { - if (!dialogView.getFolderName().matches("\\A\\w+\\z")) { - Toast.makeText(context, "Folder must be a word, no spaces", Toast.LENGTH_SHORT).show(); - } else { - File newDir = new File(fileWatcher.getCurrentPath().getAbsolutePath() + File.separator + dialogView.getFolderName()); - //noinspection ResultOfMethodCallIgnored - newDir.mkdir(); - fileWatcher.navigateTo(newDir); - ChanSettings.saveLocation.set(fileWatcher.getCurrentPath().getAbsolutePath()); - navigationController.popController(); - } - dialog.dismiss(); + onPositionButtonClick(dialogView, dialog); }) .setNegativeButton(R.string.cancel, null) .create() @@ -109,6 +120,36 @@ public void onClick(View v) { } } + private void onPositionButtonClick(NewFolderLayout dialogView, DialogInterface dialog) { + if (!dialogView.getFolderName().matches("\\A\\w+\\z")) { + Toast.makeText( + context, + "Folder must be a word, no spaces", + Toast.LENGTH_SHORT).show(); + } else { + File newDir = new File( + fileWatcher.getCurrentPath().getAbsolutePath() + + File.separator + + dialogView.getFolderName()); + + if (!newDir.mkdir()) { + throw new IllegalStateException("Could not create directory " + + newDir.getAbsolutePath()); + } + + fileWatcher.navigateTo(newDir); + + onDirectoryChosen(); + navigationController.popController(); + } + + dialog.dismiss(); + } + + private void onDirectoryChosen() { + callback.onDirectorySelected(fileWatcher.getCurrentPath().getAbsolutePath()); + } + @Override public void onFiles(FileWatcher.FileItems fileItems) { filesLayout.setFiles(fileItems); @@ -146,4 +187,13 @@ private void initialize() { filesLayout.initialize(); fileWatcher.initialize(); } + + public interface SaveLocationControllerCallback { + void onDirectorySelected(String dirPath); + } + + public enum SaveLocationControllerMode { + ImageSaveLocation, + LocalThreadsSaveLocation + } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ViewThreadController.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ViewThreadController.java index 2d4cae3b33..3ed28223c7 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ViewThreadController.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/controller/ViewThreadController.java @@ -49,12 +49,16 @@ import com.github.adamantcheese.chan.ui.helper.RuntimePermissionsHelper; import com.github.adamantcheese.chan.ui.layout.ArchivesLayout; import com.github.adamantcheese.chan.ui.layout.ThreadLayout; +import com.github.adamantcheese.chan.ui.settings.base_directory.LocalThreadsBaseDirectory; import com.github.adamantcheese.chan.ui.toolbar.NavigationItem; import com.github.adamantcheese.chan.ui.toolbar.Toolbar; import com.github.adamantcheese.chan.ui.toolbar.ToolbarMenuItem; import com.github.adamantcheese.chan.ui.toolbar.ToolbarMenuSubItem; import com.github.adamantcheese.chan.utils.AndroidUtils; import com.github.adamantcheese.chan.utils.AnimationUtils; +import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.AbstractFile; import org.greenrobot.eventbus.Subscribe; @@ -69,6 +73,8 @@ import static com.github.adamantcheese.chan.utils.AndroidUtils.getAttrColor; public class ViewThreadController extends ThreadController implements ThreadLayout.ThreadLayoutCallback, ArchivesLayout.Callback { + private static final String TAG = "ViewThreadController"; + private static final int PIN_ID = 1; private static final int SAVE_THREAD_ID = 2; @@ -77,6 +83,8 @@ public class ViewThreadController extends ThreadController implements ThreadLayo @Inject WatchManager watchManager; + @Inject + FileManager fileManager; private boolean pinItemPinned = false; private DownloadThreadState prevState = DownloadThreadState.Default; @@ -130,11 +138,16 @@ public void onCreate() { } protected void buildMenu() { + prevState = DownloadThreadState.Default; + NavigationItem.MenuBuilder menuBuilder = navigation.buildMenu() .withItem(R.drawable.ic_image_white_24dp, this::albumClicked) .withItem(PIN_ID, R.drawable.ic_bookmark_outline_white_24dp, this::pinClicked); if (ChanSettings.incrementalThreadDownloadingEnabled.get()) { + // This method recreates the menu (and if there was the download animation running it + // will be reset to the default icon). We need to reset the prev state as well so that + // we can start animation again menuBuilder.withItem(SAVE_THREAD_ID, downloadIconOutline, this::saveClicked); } @@ -212,11 +225,44 @@ private void saveClicked(ToolbarMenuItem item) { } private void saveClickedInternal() { + AbstractFile baseLocalThreadsDir = fileManager.newBaseDirectoryFile( + LocalThreadsBaseDirectory.class + ); + + if (baseLocalThreadsDir == null) { + Logger.e(TAG, "saveClickedInternal() fileManager.newLocalThreadFile() returned null"); + Toast.makeText( + context, + R.string.base_local_threads_dir_not_exists, + Toast.LENGTH_LONG).show(); + return; + } + + if (!fileManager.exists(baseLocalThreadsDir) + && fileManager.create(baseLocalThreadsDir) == null) { + Logger.e(TAG, "saveClickedInternal() Couldn't create baseLocalThreadsDir"); + Toast.makeText( + context, + R.string.could_not_create_base_local_threads_dir, + Toast.LENGTH_LONG).show(); + return; + } + + if (!fileManager.baseDirectoryExists(LocalThreadsBaseDirectory.class)) { + Logger.e(TAG, "Base local threads directory does not exist"); + Toast.makeText( + context, + R.string.base_local_threads_dir_not_exists, + Toast.LENGTH_LONG).show(); + return; + } + if (threadLayout.getPresenter().save()) { - setSaveIconState(true); updateDrawerHighlighting(loadable); - populateLocalOrLiveVersionMenu(); + + // Update icon at the very end, otherwise it won't start animating at all + setSaveIconState(true); } } @@ -628,11 +674,11 @@ private DownloadThreadState getThreadDownloadState() { } SavedThread savedThread = watchManager.findSavedThreadByLoadableId(pin.loadable.id); - if (savedThread == null || savedThread.isStopped) { + if (savedThread == null) { return DownloadThreadState.Default; } - if (savedThread.isFullyDownloaded) { + if (savedThread.isFullyDownloaded || savedThread.isStopped) { return DownloadThreadState.FullyDownloaded; } @@ -662,6 +708,7 @@ private void setSaveIconStateDrawable( menuItem.setImage(downloadIconOutline, animated); break; case DownloadInProgress: + // FIXME: shit is broken menuItem.setImage(downloadAnimation, animated); downloadAnimation.start(); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/helper/ImagePickDelegate.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/helper/ImagePickDelegate.java index b8183601fc..63c5d5b3cd 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/helper/ImagePickDelegate.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/helper/ImagePickDelegate.java @@ -33,10 +33,11 @@ import com.github.adamantcheese.chan.core.manager.ReplyManager; import com.github.adamantcheese.chan.utils.IOUtils; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.FileManager; +import com.github.k1rakishou.fsaf.file.RawFile; import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -58,6 +59,8 @@ public class ImagePickDelegate implements Runnable { @Inject ReplyManager replyManager; + @Inject + FileManager fileManager; private Activity activity; @@ -65,7 +68,7 @@ public class ImagePickDelegate implements Runnable { private Uri uri; private String fileName; private boolean success = false; - private File cacheFile; + private RawFile cacheFile; public ImagePickDelegate(Activity activity) { this.activity = activity; @@ -91,10 +94,10 @@ public void pick(ImagePickCallback callback, boolean longPressed) { HttpUrl finalClipboardURL = clipboardURL; Chan.injector().instance(FileCache.class).downloadFile(clipboardURL.toString(), new FileCacheListener() { @Override - public void onSuccess(File file) { + public void onSuccess(RawFile file) { Toast.makeText(activity, activity.getString(R.string.image_url_get_success), Toast.LENGTH_SHORT).show(); Uri imageURL = Uri.parse(finalClipboardURL.toString()); - callback.onFilePicked(imageURL.getLastPathSegment(), file); + callback.onFilePicked(imageURL.getLastPathSegment(), new File(file.getFullPath())); reset(); } @@ -122,60 +125,75 @@ public void onFail(boolean notFound) { } } - public void onActivityResult(int requestCode, int resultCode, Intent data) { + public boolean onActivityResult(int requestCode, int resultCode, Intent data) { if (callback == null) { - return; + return false; + } + + if (requestCode != IMAGE_PICK_RESULT) { + return false; } boolean ok = false; boolean cancelled = false; - if (requestCode == IMAGE_PICK_RESULT) { - if (resultCode == Activity.RESULT_OK && data != null) { - uri = data.getData(); - - Cursor returnCursor = activity.getContentResolver().query(uri, null, null, null, null); - if (returnCursor != null) { - int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - returnCursor.moveToFirst(); - if (nameIndex > -1) { - fileName = returnCursor.getString(nameIndex); - } - - returnCursor.close(); - } - if (fileName == null) { - // As per the comment on OpenableColumns.DISPLAY_NAME: - // If this is not provided then the name should default to the last segment of the file's URI. - fileName = uri.getLastPathSegment(); - } + if (resultCode == Activity.RESULT_OK && data != null) { + uri = data.getData(); - if (fileName == null) { - fileName = DEFAULT_FILE_NAME; + Cursor returnCursor = activity.getContentResolver().query(uri, null, null, null, null); + if (returnCursor != null) { + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + returnCursor.moveToFirst(); + if (nameIndex > -1) { + fileName = returnCursor.getString(nameIndex); } - new Thread(this).start(); - ok = true; - } else if (resultCode == Activity.RESULT_CANCELED) { - cancelled = true; + returnCursor.close(); + } + + if (fileName == null) { + // As per the comment on OpenableColumns.DISPLAY_NAME: + // If this is not provided then the name should default to the last segment of the file's URI. + fileName = uri.getLastPathSegment(); + } + + if (fileName == null) { + fileName = DEFAULT_FILE_NAME; } + + new Thread(this).start(); + ok = true; + } else if (resultCode == Activity.RESULT_CANCELED) { + cancelled = true; } if (!ok) { callback.onFilePickError(cancelled); reset(); } + + return true; } @Override public void run() { - cacheFile = replyManager.getPickFile(); + cacheFile = fileManager.fromRawFile(replyManager.getPickFile()); InputStream is = null; OutputStream os = null; try (ParcelFileDescriptor fileDescriptor = activity.getContentResolver().openFileDescriptor(uri, "r")) { + if (fileDescriptor == null) { + throw new IOException("Couldn't open file descriptor for uri = " + uri); + } + is = new FileInputStream(fileDescriptor.getFileDescriptor()); - os = new FileOutputStream(cacheFile); + os = fileManager.getOutputStream(cacheFile); + + if (os == null) { + throw new IOException("Could not get OutputStream from the cacheFile, " + + "cacheFile = " + cacheFile.getFullPath()); + } + boolean fullyCopied = IOUtils.copy(is, os, MAX_FILE_SIZE); if (fullyCopied) { success = true; @@ -188,14 +206,14 @@ public void run() { } if (!success) { - if (!cacheFile.delete()) { + if (!fileManager.delete(cacheFile)) { Logger.e(TAG, "Could not delete picked_file after copy fail"); } } runOnUiThread(() -> { if (success) { - callback.onFilePicked(fileName, cacheFile); + callback.onFilePicked(fileName, new File(cacheFile.getFullPath())); } else { callback.onFilePickError(false); } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/service/WatchNotification.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/service/WatchNotification.java index 88b72e9b34..fcedfe1d2f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/service/WatchNotification.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/service/WatchNotification.java @@ -260,7 +260,9 @@ private void updateSavedThreads(HashMap>> Loadable loadable = entry.getValue().first; List posts = entry.getValue().second; - threadSaveManager.enqueueThreadToSave(loadable, posts); + if (!threadSaveManager.enqueueThreadToSave(loadable, posts)) { + watchManager.stopSavingThread(loadable); + } } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/settings/base_directory/LocalThreadsBaseDirectory.kt b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/settings/base_directory/LocalThreadsBaseDirectory.kt new file mode 100644 index 0000000000..674f9c6bae --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/settings/base_directory/LocalThreadsBaseDirectory.kt @@ -0,0 +1,30 @@ +package com.github.adamantcheese.chan.ui.settings.base_directory + +import android.net.Uri +import com.github.adamantcheese.chan.BuildConfig +import com.github.adamantcheese.chan.core.settings.ChanSettings +import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory +import java.io.File + +class LocalThreadsBaseDirectory( +) : BaseDirectory(BuildConfig.DEBUG) { + + override fun getDirFile(): File? { + val localThreadsPath = ChanSettings.localThreadLocation.get() + if (localThreadsPath.isEmpty()) { + return null + } + + return File(localThreadsPath) + } + + override fun getDirUri(): Uri? { + val localThreadsSafPath = ChanSettings.localThreadsLocationUri.get() + if (localThreadsSafPath.isEmpty()) { + return null + } + + return Uri.parse(localThreadsSafPath) + } + +} \ No newline at end of file diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/settings/base_directory/SavedFilesBaseDirectory.kt b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/settings/base_directory/SavedFilesBaseDirectory.kt new file mode 100644 index 0000000000..e7ff01b6b7 --- /dev/null +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/settings/base_directory/SavedFilesBaseDirectory.kt @@ -0,0 +1,30 @@ +package com.github.adamantcheese.chan.ui.settings.base_directory + +import android.net.Uri +import com.github.adamantcheese.chan.BuildConfig +import com.github.adamantcheese.chan.core.settings.ChanSettings +import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory +import java.io.File + +class SavedFilesBaseDirectory( +) : BaseDirectory(BuildConfig.DEBUG) { + + override fun getDirFile(): File? { + val saveLocationPath = ChanSettings.saveLocation.get() + if (saveLocationPath.isEmpty()) { + return null + } + + return File(saveLocationPath) + } + + override fun getDirUri(): Uri? { + val saveLocationSafPath = ChanSettings.saveLocationUri.get() + if (saveLocationSafPath.isEmpty()) { + return null + } + + return Uri.parse(saveLocationSafPath) + } + +} \ No newline at end of file diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/MultiImageView.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/MultiImageView.java index 1328095b83..0eeea27f4f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/MultiImageView.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/MultiImageView.java @@ -51,6 +51,7 @@ import com.github.adamantcheese.chan.core.settings.ChanSettings; import com.github.adamantcheese.chan.utils.AndroidUtils; import com.github.adamantcheese.chan.utils.Logger; +import com.github.k1rakishou.fsaf.file.RawFile; import com.google.android.exoplayer2.ExoPlayerFactory; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; @@ -310,8 +311,11 @@ public void onProgress(long downloaded, long total) { } @Override - public void onSuccess(File file) { - setBitImageFileInternal(file, true, Mode.BIGIMAGE); + public void onSuccess(RawFile file) { + setBitImageFileInternal( + new File(file.getFullPath()), + true, + Mode.BIGIMAGE); } @Override @@ -349,9 +353,9 @@ public void onProgress(long downloaded, long total) { } @Override - public void onSuccess(File file) { + public void onSuccess(RawFile file) { if (!hasContent || mode == Mode.GIF) { - setGifFile(file); + setGifFile(new File(file.getFullPath())); } } @@ -416,9 +420,9 @@ public void onProgress(long downloaded, long total) { } @Override - public void onSuccess(File file) { + public void onSuccess(RawFile file) { if (!hasContent || mode == Mode.MOVIE) { - setVideoFile(file); + setVideoFile(new File(file.getFullPath())); } } @@ -444,7 +448,6 @@ private void setVideoFile(final File file) { Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(FileProvider.getUriForFile(getAppContext(), getAppContext().getPackageName() + ".fileprovider", file), "video/*"); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - AndroidUtils.openIntent(intent); onModeLoaded(Mode.MOVIE, null); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/ThumbnailView.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/ThumbnailView.java index 53245249ee..9ab4b1f75f 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/ThumbnailView.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/ui/view/ThumbnailView.java @@ -42,7 +42,6 @@ import com.android.volley.ParseError; import com.android.volley.TimeoutError; import com.android.volley.VolleyError; -import com.android.volley.toolbox.ImageLoader; import com.android.volley.toolbox.ImageLoader.ImageContainer; import com.android.volley.toolbox.ImageLoader.ImageListener; import com.github.adamantcheese.chan.Chan; diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/BackgroundUtils.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/BackgroundUtils.java index a4f08cb202..de587253b5 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/BackgroundUtils.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/BackgroundUtils.java @@ -35,6 +35,18 @@ public static boolean isMainThread() { return Thread.currentThread() == Looper.getMainLooper().getThread(); } + public static void ensureMainThread() { + if (!isMainThread()) { + throw new IllegalStateException("Cannot be executed on a background thread!"); + } + } + + public static void ensureBackgroundThread() { + if (isMainThread()) { + throw new IllegalStateException("Cannot be executed on the main thread!"); + } + } + public static Cancelable runWithExecutor(Executor executor, final Callable background, final BackgroundResult result) { final AtomicBoolean cancelled = new AtomicBoolean(false); diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/IOUtils.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/IOUtils.java index a152bae480..782c5a052e 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/IOUtils.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/IOUtils.java @@ -119,17 +119,4 @@ public static void copyFile(File in, File out) throws IOException { IOUtils.closeQuietly(os); } } - - public static void deleteDirWithContents(File dir) { - if (dir.isDirectory()) { - File[] files = dir.listFiles(); - if (files != null) { - for (File c : files) { - deleteDirWithContents(c); - } - } - } - - dir.delete(); - } } diff --git a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/StringUtils.java b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/StringUtils.java index eea84bdbda..27e107a004 100644 --- a/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/StringUtils.java +++ b/Kuroba/app/src/main/java/com/github/adamantcheese/chan/utils/StringUtils.java @@ -39,4 +39,20 @@ public static String extractFileExtensionFromImageUrl(String url) { return url.substring(index + 1); } + public static String dirNameRemoveBadCharacters(String dirName) { + return dirName + .toLowerCase() + .replaceAll(" ", "_") + .replaceAll("[^a-z0-9_]", ""); + } + + /** + * The same as dirNameRemoveBadCharacters but allows dots since file names can have extensions + * */ + public static String fileNameRemoveBadCharacters(String filename) { + return filename + .toLowerCase() + .replaceAll(" ", "_") + .replaceAll("[^a-z0-9_.]", ""); + } } diff --git a/Kuroba/app/src/main/res/layout/controller_loading_view.xml b/Kuroba/app/src/main/res/layout/controller_loading_view.xml index c4c8f3d694..d5351fafd2 100644 --- a/Kuroba/app/src/main/res/layout/controller_loading_view.xml +++ b/Kuroba/app/src/main/res/layout/controller_loading_view.xml @@ -61,18 +61,18 @@ along with this program. If not, see . tools:ignore="HardcodedText" /> + app:layout_constraintBottom_toBottomOf="@+id/progress_bar" + app:layout_constraintEnd_toEndOf="@+id/progress_bar" + app:layout_constraintStart_toStartOf="@+id/progress_bar" + app:layout_constraintTop_toTopOf="@+id/progress_bar" /> . %1$d new posts, %2$d quoting you + + All posts + Only posts quoting you + + + None + White + Red + Yellow + Green + Cyan + Blue + Purple + + Cancel Add @@ -119,14 +134,6 @@ along with this program. If not, see . "Permission to access storage is required for installing the update. -Re-enable this permission in the app settings if you permanently disabled it." - -"Permission to access storage is required to export settings. - -Re-enable this permission in the app settings if you permanently disabled it." - -"Permission to access storage is required to import settings. - Re-enable this permission in the app settings if you permanently disabled it." %d minutes %1$dR %2$dI @@ -608,6 +615,7 @@ Don't have a 4chan Pass?
Export settings to a file Import settings Import settings from a file + Exported successfully" Warning You are about to import settings/saved pins/filters/hidden posts from a file. The file must be located at \"%1$s\" and have a name \"%2$s\". @@ -615,11 +623,8 @@ Don't have a 4chan Pass?
You MAY LOSE some of your settings/pins/hidden threads if you are trying to import a file created with a different app version (upgrade or downgrade). The app will be restarted. \nAre you sure you want to continue?
- Exported successfully to \"%1$s\". File path has been copied to the clipboard. External storage is not mounted Could not create directory for export file %1$s; you probably won\'t be able to export settings - The default directory may not exist yet. Do you want to check whether the directory exists and create it automatically? - Default directory may not exist Apply to replies Only on OP Apply to own posts @@ -702,18 +707,54 @@ Don't have a 4chan Pass?
Only posts quoting you - - All posts - Only posts quoting you - - - None - White - Red - Yellow - Green - Cyan - Blue - Purple - + Overwrite existing file or create a new one? + Overwrite + Create new + Warning! + You have %1$s %2$s %3$s set to be located in a directory that uses Storage Access Framework. Such directory locations cannot be exported into the settings file because SAF is fucking retarded (Basically if you try to export them and then use on another phone or if you delete the app then install it and import those settings the directory locations they are pointing to WILL NOT BE VALID ANYMORE AND THE APP WILL CRASH). Thus THEY WILL BE RESET TO DEFAULT in the exported settings file! So once you import them you will have to manually set back those locations in the Media Settings (so that the app can work with them). Sorry for such an inconvenience but there is nothing we can do. \n\nBlame Google for forcing this retarded fucking shit. + \"local threads base directory\" + and + \"saved files base directory\" + Base local threads directory does not exist (or it was deleted). You need to manually set it again in Media settings. + Could not save one or more images + Could not create base local threads directory + + Use the new Storage Access Framework API to choose images download directory? + Use the new Storage Access Framework API to choose local threads download directory? + If you choose to use the SAF you will be able to choose the sd-card (or even something like Google Drive) as a place to store downloaded images + If you choose to use the SAF you will be able to choose the sd-card (or even something like Google Drive) as a place to store local threads images + Use SAF API + Use old API + Local threads location + Could not copy one directory\'s file into another one + Successfully copied files + Would you like to delete files in the old directory? + Files have been copied and now exist in two directories. You may want to remove files in the old directory since you won\'t need them anymore + Delete + Couldn\'t delete files in the old directory + Successfully deleted old directory + Do not + Copy files + Do you want to copy %1$d files from old directory to the new one?" + Do not + Couldn\'t release uri permissions + Move old local threads to the new directory? + This operation may take quite some time. Once started this operation must not be canceled. + Move + Move old saved files to the new directory? + Do not + Move + Do not + Old local threads base directory is probably not registered (newBaseDirectoryFile returned null) + New local threads base directory is probably not registered + Old saved files base directory is probably not registered (newBaseDirectoryFile returned null) + New saved files base directory is probably not registered + No files to copy + Copying file %1$d out of %2$d + You are trying to move files in the same directory. The directory was changed but the files were not touched. + There are %1$d threads being downloaded + You have to stop all the threads that are being downloaded before changing local threads base directory! + OK + Some thread downloads are still being processed + You may have to wait for couple of minutes until all active downloads are complete. DO NOT TERMINATE THE APP MANUALLY!!! Because this will break your local threads. diff --git a/Kuroba/build.gradle b/Kuroba/build.gradle index 043e3571c5..ead519acaa 100644 --- a/Kuroba/build.gradle +++ b/Kuroba/build.gradle @@ -1,10 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + ext.kotlin_version = '1.3.50' + repositories { google() jcenter() + maven { url 'https://jitpack.io' } } dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.android.tools.build:gradle:3.5.1' } } @@ -13,5 +17,6 @@ allprojects { repositories { google() jcenter() + maven { url 'https://jitpack.io' } } } diff --git a/README.md b/README.md index 7b5cb5d466..4eb2f79c9c 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ DEV releases are provided by K1rakishou on his server (they are built and upload ### [Latest DEV APK](http://94.140.116.243:8080/latest_apk) -Kuroba is a fast Android app for browsing imageboards, such as 4chan and 8chan. It adds inline replying, thread watching, notifications, themes, pass support, filters and a whole lot more. It is based on Clover by Floens, but has additional features added in because Floens doesn't want to merge PRs. Credits to K1rakishou for a number of features. +Kuroba is a fast Android app for browsing imageboards, such as 4chan and 8chan. It adds inline replying, thread watching, notifications, themes, pass support, filters and a whole lot more. It is based on Clover by Floens, but has additional features added in because Floens doesn't want to merge PRs. Credits to K1rakishou for a number of features. #### [A full feature list can be found here.](https://gist.github.com/Adamantcheese/0c15a36ab983e7829f91f1248ab28844) ## License [Kuroba is GPLv3](https://github.com/Adamantcheese/Kuroba/blob/multi-feature/COPYING.txt), [licenses of the used libraries.](https://github.com/Adamantcheese/Kuroba/blob/multi-feature/Kuroba/app/src/main/assets/html/licenses.html) - +