diff --git a/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.java b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.java deleted file mode 100644 index 5ff4562d21..0000000000 --- a/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , - * Emmanuel Messulam, Raymond Lai and Contributors. - * - * This file is part of Amaze File Manager. - * - * Amaze File Manager 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.amaze.filemanager.filesystem.files; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.channels.Channels; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; -import com.amaze.filemanager.test.DummyFileGenerator; -import com.amaze.filemanager.utils.ProgressHandler; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -@RunWith(AndroidJUnit4.class) -public class GenericCopyUtilEspressoTest { - - private GenericCopyUtil copyUtil; - - private File file1, file2; - - @Before - public void setUp() throws IOException { - copyUtil = - new GenericCopyUtil( - InstrumentationRegistry.getInstrumentation().getTargetContext(), new ProgressHandler()); - file1 = File.createTempFile("test", "bin"); - file2 = File.createTempFile("test", "bin"); - file1.deleteOnExit(); - file2.deleteOnExit(); - } - - @Test - public void testCopyFile1() throws IOException, NoSuchAlgorithmException { - doTestCopyFile1(512); - doTestCopyFile1(10 * 1024 * 1024); - } - - @Test - public void testCopyFile2() throws IOException, NoSuchAlgorithmException { - doTestCopyFile2(512); - doTestCopyFile2(10 * 1024 * 1024); - } - - @Test - public void testCopyFile3() throws IOException, NoSuchAlgorithmException { - doTestCopyFile3(512); - doTestCopyFile3(10 * 1024 * 1024); - } - - // doCopy(ReadableByteChannel in, WritableByteChannel out) - private void doTestCopyFile1(int size) throws IOException, NoSuchAlgorithmException { - byte[] checksum = DummyFileGenerator.createFile(file1, size); - copyUtil.doCopy( - new FileInputStream(file1).getChannel(), - Channels.newChannel(new FileOutputStream(file2)), - ServiceWatcherUtil.UPDATE_POSITION); - assertEquals(file1.length(), file2.length()); - assertSha1Equals(checksum, file2); - } - - // copy(FileChannel in, FileChannel out) - private void doTestCopyFile2(int size) throws IOException, NoSuchAlgorithmException { - byte[] checksum = DummyFileGenerator.createFile(file1, size); - copyUtil.copyFile( - new FileInputStream(file1).getChannel(), - new FileOutputStream(file2).getChannel(), - ServiceWatcherUtil.UPDATE_POSITION); - assertEquals(file1.length(), file2.length()); - assertSha1Equals(checksum, file2); - } - - // copy(BufferedInputStream in, BufferedOutputStream out) - private void doTestCopyFile3(int size) throws IOException, NoSuchAlgorithmException { - byte[] checksum = DummyFileGenerator.createFile(file1, size); - copyUtil.copyFile( - new BufferedInputStream(new FileInputStream(file1)), - new BufferedOutputStream(new FileOutputStream(file2)), - ServiceWatcherUtil.UPDATE_POSITION); - assertEquals(file1.length(), file2.length()); - assertSha1Equals(checksum, file2); - } - - private void assertSha1Equals(byte[] expected, File file) - throws NoSuchAlgorithmException, IOException { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - DigestInputStream in = new DigestInputStream(new FileInputStream(file), md); - byte[] buffer = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; - while (in.read(buffer) > -1) {} - in.close(); - assertArrayEquals(expected, md.digest()); - } -} diff --git a/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.kt b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.kt new file mode 100644 index 0000000000..1bba82c339 --- /dev/null +++ b/app/src/androidTest/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilEspressoTest.kt @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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.amaze.filemanager.filesystem.files + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.test.DummyFileGenerator +import com.amaze.filemanager.utils.ProgressHandler +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.channels.Channels +import java.security.DigestInputStream +import java.security.MessageDigest + +/** + * Instrumented tests for [GenericCopyUtil] to verify the correctness of file copying + * and progress updates. + */ +@Suppress("StringLiteralDuplication") +@RunWith(AndroidJUnit4::class) +class GenericCopyUtilEspressoTest { + private lateinit var progressHandler: ProgressHandler + private lateinit var copyUtil: GenericCopyUtil + private lateinit var file1: File + private lateinit var file2: File + + /** + * Pre-test setup. + */ + @Before + fun setUp() { + progressHandler = ProgressHandler() + copyUtil = + GenericCopyUtil( + InstrumentationRegistry.getInstrumentation().targetContext, + progressHandler, + ) + file1 = File.createTempFile("test", "bin").also { it.deleteOnExit() } + file2 = File.createTempFile("test", "bin").also { it.deleteOnExit() } + } + + /** + * Post test clean up. + */ + @After + fun tearDown() { + if (file1.exists()) file1.delete() + if (file2.exists()) file2.delete() + } + + /** + * Test doCopy with small file + */ + @Test + fun testDoCopySmallFile() { + verifyDoCopy(512) + } + + /** + * Test doCopy with large file + */ + @Test + fun testDoCopyLargeFile() { + verifyDoCopy(10 * 1024 * 1024) + } + + /** + * Test doCopy with empty file + */ + @Test + fun testDoCopyEmptyFile() { + verifyDoCopy(0) + } + + private fun verifyDoCopy(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + Channels.newChannel(FileOutputStream(file2)).use { fout -> + copyUtil.doCopy( + fin, + fout, + updatePosition, + ) + } + } + + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copyFile(FileChannel, FileChannel) with small file + */ + @Test + fun testCopyFileChannelSmallFile() { + verifyCopyFileChannel(512) + } + + /** + * Test copyFile(FileChannel, FileChannel) with large file + */ + @Test + fun testCopyFileChannelLargeFile() { + verifyCopyFileChannel(10 * 1024 * 1024) + } + + /** + * Test copyFile(FileChannel, FileChannel) with empty file + */ + @Test + fun testCopyFileChannelEmptyFile() { + verifyCopyFileChannel(0) + } + + private fun verifyCopyFileChannel(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + FileOutputStream(file2).channel.use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copyFile(BufferedInputStream, BufferedOutputStream) with small file + */ + @Test + fun testCopyBufferedStreamsSmallFile() { + verifyCopyBufferedStreams(512) + } + + /** + * Test copyFile(BufferedInputStream, BufferedOutputStream) with large file + */ + @Test + fun testCopyBufferedStreamsLargeFile() { + verifyCopyBufferedStreams(10 * 1024 * 1024) + } + + /** + * Test copyFile(BufferedInputStream, BufferedOutputStream) with empty file + */ + @Test + fun testCopyBufferedStreamsEmptyFile() { + verifyCopyBufferedStreams(0) + } + + private fun verifyCopyBufferedStreams(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + BufferedInputStream(FileInputStream(file1)).use { fin -> + BufferedOutputStream(FileOutputStream(file2)).use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copyFile(FileChannel, BufferedOutputStream) for small files + */ + @Test + fun testCopyFileChannelToBufferedOutputStreamSmallFile() { + verifyCopyFileChannelToBufferedOutputStream(512) + } + + /** + * Test copyFile(FileChannel, BufferedOutputStream) for large files + */ + @Test + fun testCopyFileChannelToBufferedOutputStreamLargeFile() { + verifyCopyFileChannelToBufferedOutputStream(10 * 1024 * 1024) + } + + private fun verifyCopyFileChannelToBufferedOutputStream(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + BufferedOutputStream(FileOutputStream(file2)).use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copy cancelled + */ + @Test + fun testCancellation() { + val size = 10 * 1024 * 1024 + DummyFileGenerator.createFile(file1, size) + progressHandler.setCancelled(true) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + Channels.newChannel(FileOutputStream(file2)).use { fout -> + copyUtil.doCopy( + fin, + fout, + updatePosition, + ) + } + } + + assertTrue( + "Cancelled copy should write less than full size", + file2.length() < file1.length(), + ) + } + + /** + * Test when copying a large file using the transferTo path, progress updates are batched + */ + @Test + fun testBatchedProgress_transferToPath() { + val size = 10 * 1024 * 1024 + DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + FileOutputStream(file2).channel.use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + assertTrue( + "Batched progress should have fewer callbacks (got ${progressUpdates.size})", + progressUpdates.size <= 5, + ) + } + + private fun assertSha1Equals( + expected: ByteArray, + file: File, + ) { + val md = MessageDigest.getInstance("SHA-1") + DigestInputStream(FileInputStream(file), md).use { din -> + val buffer = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + while (din.read(buffer) > -1) { /* consume */ } + } + assertArrayEquals(expected, md.digest()) + } +} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java deleted file mode 100644 index 75dc8ab34f..0000000000 --- a/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.java +++ /dev/null @@ -1,413 +0,0 @@ -/* - * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , - * Emmanuel Messulam, Raymond Lai and Contributors. - * - * This file is part of Amaze File Manager. - * - * Amaze File Manager 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.amaze.filemanager.filesystem.files; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.RandomAccessFile; -import java.nio.ByteBuffer; -import java.nio.channels.Channels; -import java.nio.channels.FileChannel; -import java.nio.channels.ReadableByteChannel; -import java.nio.channels.WritableByteChannel; -import java.util.Objects; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.amaze.filemanager.fileoperations.filesystem.OpenMode; -import com.amaze.filemanager.fileoperations.utils.OnLowMemory; -import com.amaze.filemanager.fileoperations.utils.UpdatePosition; -import com.amaze.filemanager.filesystem.ExternalSdCardOperation; -import com.amaze.filemanager.filesystem.FileProperties; -import com.amaze.filemanager.filesystem.HybridFile; -import com.amaze.filemanager.filesystem.HybridFileParcelable; -import com.amaze.filemanager.filesystem.MediaStoreHack; -import com.amaze.filemanager.filesystem.SafRootHolder; -import com.amaze.filemanager.filesystem.cloud.CloudUtil; -import com.amaze.filemanager.utils.DataUtils; -import com.amaze.filemanager.utils.OTGUtil; -import com.amaze.filemanager.utils.ProgressHandler; -import com.cloudrail.si.interfaces.CloudStorage; - -import android.content.ContentResolver; -import android.content.Context; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.VisibleForTesting; -import androidx.documentfile.provider.DocumentFile; - -/** Base class to handle file copy. */ -public class GenericCopyUtil { - private final Logger LOG = LoggerFactory.getLogger(GenericCopyUtil.class); - - private HybridFileParcelable mSourceFile; - private HybridFile mTargetFile; - private final Context mContext; // context needed to find the DocumentFile in otg/sd card - private final DataUtils dataUtils = DataUtils.getInstance(); - private final ProgressHandler progressHandler; - - public static final int DEFAULT_BUFFER_SIZE = 8192; - - /* - Defines the block size per transfer over NIO channels. - - Cannot modify DEFAULT_BUFFER_SIZE since it's used by other classes, will have undesired - effect on other functions - */ - private static final int DEFAULT_TRANSFER_QUANTUM = 1024 * 1024; - - public GenericCopyUtil(Context context, ProgressHandler progressHandler) { - this.mContext = context; - this.progressHandler = progressHandler; - } - - /** - * Starts copy of file Supports : {@link File}, {@link jcifs.smb.SmbFile}, {@link DocumentFile}, - * {@link CloudStorage} - * - * @param lowOnMemory defines whether system is running low on memory, in which case we'll switch - * to using streams instead of channel which maps the who buffer in memory. TODO: Use buffers - * even on low memory but don't map the whole file to memory but parts of it, and transfer - * each part instead. - */ - private void startCopy( - boolean lowOnMemory, @NonNull OnLowMemory onLowMemory, @NonNull UpdatePosition updatePosition) - throws IOException { - - ReadableByteChannel inChannel = null; - WritableByteChannel outChannel = null; - BufferedInputStream bufferedInputStream = null; - BufferedOutputStream bufferedOutputStream = null; - - try { - // initializing the input channels based on file types - if (mSourceFile.isOtgFile() || mSourceFile.isDocumentFile()) { - // source is in otg - ContentResolver contentResolver = mContext.getContentResolver(); - DocumentFile documentSourceFile = - mSourceFile.isDocumentFile() - ? OTGUtil.getDocumentFile( - mSourceFile.getPath(), - SafRootHolder.getUriRoot(), - mContext, - mSourceFile.isOtgFile() ? OpenMode.OTG : OpenMode.DOCUMENT_FILE, - false) - : OTGUtil.getDocumentFile(mSourceFile.getPath(), mContext, false); - - bufferedInputStream = - new BufferedInputStream( - contentResolver.openInputStream(documentSourceFile.getUri()), DEFAULT_BUFFER_SIZE); - } else if (mSourceFile.isSmb() || mSourceFile.isSftp() || mSourceFile.isFtp()) { - bufferedInputStream = - new BufferedInputStream(mSourceFile.getInputStream(mContext), DEFAULT_TRANSFER_QUANTUM); - } else if (mSourceFile.isDropBoxFile() - || mSourceFile.isBoxFile() - || mSourceFile.isGoogleDriveFile() - || mSourceFile.isOneDriveFile()) { - OpenMode openMode = mSourceFile.getMode(); - - CloudStorage cloudStorage = dataUtils.getAccount(openMode); - bufferedInputStream = - new BufferedInputStream( - cloudStorage.download(CloudUtil.stripPath(openMode, mSourceFile.getPath()))); - } else { - - // source file is neither smb nor otg; getting a channel from direct file instead of stream - File file = new File(mSourceFile.getPath()); - if (FileProperties.isReadable(file)) { - - if (mTargetFile.isOneDriveFile() - || mTargetFile.isDropBoxFile() - || mTargetFile.isGoogleDriveFile() - || mTargetFile.isBoxFile() - || lowOnMemory) { - // our target is cloud, we need a stream not channel - bufferedInputStream = new BufferedInputStream(new FileInputStream(file)); - } else { - - inChannel = new RandomAccessFile(file, "r").getChannel(); - } - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - ContentResolver contentResolver = mContext.getContentResolver(); - DocumentFile documentSourceFile = - ExternalSdCardOperation.getDocumentFile(file, mSourceFile.isDirectory(), mContext); - - bufferedInputStream = - new BufferedInputStream( - contentResolver.openInputStream(documentSourceFile.getUri()), - DEFAULT_BUFFER_SIZE); - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { - InputStream inputStream1 = - MediaStoreHack.getInputStream(mContext, file, mSourceFile.getSize()); - bufferedInputStream = new BufferedInputStream(inputStream1); - } - } - } - - // initializing the output channels based on file types - if (mTargetFile.isOtgFile() || mTargetFile.isDocumentFile()) { - // target in OTG, obtain streams from DocumentFile Uri's - ContentResolver contentResolver = mContext.getContentResolver(); - DocumentFile documentTargetFile = - mTargetFile.isDocumentFile() - ? OTGUtil.getDocumentFile( - mTargetFile.getPath(), - SafRootHolder.getUriRoot(), - mContext, - mTargetFile.isOtgFile() ? OpenMode.OTG : OpenMode.DOCUMENT_FILE, - true) - : OTGUtil.getDocumentFile(mTargetFile.getPath(), mContext, true); - - bufferedOutputStream = - new BufferedOutputStream( - contentResolver.openOutputStream(documentTargetFile.getUri()), DEFAULT_BUFFER_SIZE); - } else if (mTargetFile.isFtp() || mTargetFile.isSftp() || mTargetFile.isSmb()) { - bufferedOutputStream = - new BufferedOutputStream( - mTargetFile.getOutputStream(mContext), DEFAULT_TRANSFER_QUANTUM); - } else if (mTargetFile.isDropBoxFile() - || mTargetFile.isBoxFile() - || mTargetFile.isGoogleDriveFile() - || mTargetFile.isOneDriveFile()) { - cloudCopy(mTargetFile.getMode(), bufferedInputStream); - return; - } else { - // copying normal file, target not in OTG - File file = new File(mTargetFile.getPath()); - if (FileProperties.isWritable(file)) { - - if (lowOnMemory) { - bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(file)); - } else { - - outChannel = new RandomAccessFile(file, "rw").getChannel(); - } - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - ContentResolver contentResolver = mContext.getContentResolver(); - DocumentFile documentTargetFile = - ExternalSdCardOperation.getDocumentFile( - file, mTargetFile.isDirectory(mContext), mContext); - - bufferedOutputStream = - new BufferedOutputStream( - contentResolver.openOutputStream(documentTargetFile.getUri()), - DEFAULT_BUFFER_SIZE); - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { - // Workaround for Kitkat ext SD card - bufferedOutputStream = - new BufferedOutputStream(MediaStoreHack.getOutputStream(mContext, file.getPath())); - } - } - } - - if (bufferedInputStream != null) { - inChannel = Channels.newChannel(bufferedInputStream); - } - - if (bufferedOutputStream != null) { - outChannel = Channels.newChannel(bufferedOutputStream); - } - - Objects.requireNonNull(inChannel); - Objects.requireNonNull(outChannel); - - doCopy(inChannel, outChannel, updatePosition); - } catch (IOException e) { - LOG.error("I/O Error copy {} to {}: {}", mSourceFile, mTargetFile, e); - throw new IOException(e); - } catch (OutOfMemoryError e) { - LOG.warn("low memory while copying {} to {}: {}", mSourceFile, mTargetFile, e); - - onLowMemory.onLowMemory(); - - startCopy(true, onLowMemory, updatePosition); - } finally { - - try { - if (inChannel != null && inChannel.isOpen()) inChannel.close(); - if (outChannel != null && outChannel.isOpen()) outChannel.close(); - /* - * It does seems closing the inChannel/outChannel is already sufficient closing the below - * bufferedInputStream and bufferedOutputStream instances. These 2 lines prevented FTP - * copy from working, especially on Android 9 - TranceLove - */ - // if (bufferedInputStream != null) bufferedInputStream.close(); - // if (bufferedOutputStream != null) bufferedOutputStream.close(); - } catch (IOException e) { - LOG.warn("failed to close stream after copying", e); - // failure in closing stream - } - - // If target file is copied onto the device and copy was successful, trigger media store - // rescan - if (mTargetFile != null) { - MediaConnectionUtils.scanFile(mContext, new HybridFile[] {mTargetFile}); - } - } - } - - private void cloudCopy( - @NonNull OpenMode openMode, @NonNull BufferedInputStream bufferedInputStream) - throws IOException { - DataUtils dataUtils = DataUtils.getInstance(); - // API doesn't support output stream, we'll upload the file directly - CloudStorage cloudStorage = dataUtils.getAccount(openMode); - - if (mSourceFile.getMode() == openMode) { - // we're in the same provider, use api method - cloudStorage.copy( - CloudUtil.stripPath(openMode, mSourceFile.getPath()), - CloudUtil.stripPath(openMode, mTargetFile.getPath())); - } else { - cloudStorage.upload( - CloudUtil.stripPath(openMode, mTargetFile.getPath()), - bufferedInputStream, - mSourceFile.getSize(), - true); - bufferedInputStream.close(); - } - } - - /** - * Method exposes this class to initiate copy - * - * @param sourceFile the source file, which is to be copied - * @param targetFile the target file - */ - public void copy( - HybridFileParcelable sourceFile, - HybridFile targetFile, - @NonNull OnLowMemory onLowMemory, - @NonNull UpdatePosition updatePosition) - throws IOException { - this.mSourceFile = sourceFile; - this.mTargetFile = targetFile; - - startCopy(false, onLowMemory, updatePosition); - } - - /** - * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. - * - * @see Channels#newChannel(InputStream) - * @param bufferedInputStream source - * @param outChannel target - * @throws IOException - */ - @VisibleForTesting - void copyFile( - @NonNull BufferedInputStream bufferedInputStream, - @NonNull FileChannel outChannel, - @NonNull UpdatePosition updatePosition) - throws IOException { - doCopy(Channels.newChannel(bufferedInputStream), outChannel, updatePosition); - } - - /** - * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. - * - * @param inChannel source - * @param outChannel target - * @throws IOException - */ - @VisibleForTesting - void copyFile( - @NonNull FileChannel inChannel, - @NonNull FileChannel outChannel, - @NonNull UpdatePosition updatePosition) - throws IOException { - // MappedByteBuffer inByteBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, - // inChannel.size()); - // MappedByteBuffer outByteBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, 0, - // inChannel.size()); - doCopy(inChannel, outChannel, updatePosition); - } - - /** - * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. - * - * @see Channels#newChannel(InputStream) - * @see Channels#newChannel(OutputStream) - * @param bufferedInputStream source - * @param bufferedOutputStream target - * @throws IOException - */ - @VisibleForTesting - void copyFile( - @NonNull BufferedInputStream bufferedInputStream, - @NonNull BufferedOutputStream bufferedOutputStream, - @NonNull UpdatePosition updatePosition) - throws IOException { - doCopy( - Channels.newChannel(bufferedInputStream), - Channels.newChannel(bufferedOutputStream), - updatePosition); - } - - /** - * Calls {@link #doCopy(ReadableByteChannel, WritableByteChannel, UpdatePosition)}. - * - * @see Channels#newChannel(OutputStream) - * @param inChannel source - * @param bufferedOutputStream target - * @throws IOException - */ - @VisibleForTesting - void copyFile( - @NonNull FileChannel inChannel, - @NonNull BufferedOutputStream bufferedOutputStream, - @NonNull UpdatePosition updatePosition) - throws IOException { - doCopy(inChannel, Channels.newChannel(bufferedOutputStream), updatePosition); - } - - @VisibleForTesting - void doCopy( - @NonNull ReadableByteChannel from, - @NonNull WritableByteChannel to, - @NonNull UpdatePosition updatePosition) - throws IOException { - ByteBuffer buffer = ByteBuffer.allocateDirect(DEFAULT_TRANSFER_QUANTUM); - long count; - while ((from.read(buffer) != -1 || buffer.position() > 0) && !progressHandler.getCancelled()) { - buffer.flip(); - count = to.write(buffer); - updatePosition.updatePosition(count); - buffer.compact(); - } - - buffer.flip(); - while (buffer.hasRemaining()) to.write(buffer); - - from.close(); - to.close(); - } -} diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.kt b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.kt new file mode 100644 index 0000000000..0416051e69 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/filesystem/files/GenericCopyUtil.kt @@ -0,0 +1,500 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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.amaze.filemanager.filesystem.files + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.KITKAT +import android.os.Build.VERSION_CODES.LOLLIPOP +import androidx.annotation.VisibleForTesting +import com.amaze.filemanager.fileoperations.filesystem.OpenMode +import com.amaze.filemanager.fileoperations.utils.OnLowMemory +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.filesystem.ExternalSdCardOperation +import com.amaze.filemanager.filesystem.FileProperties +import com.amaze.filemanager.filesystem.HybridFile +import com.amaze.filemanager.filesystem.HybridFileParcelable +import com.amaze.filemanager.filesystem.MediaStoreHack +import com.amaze.filemanager.filesystem.SafRootHolder +import com.amaze.filemanager.filesystem.cloud.CloudUtil +import com.amaze.filemanager.utils.DataUtils +import com.amaze.filemanager.utils.OTGUtil +import com.amaze.filemanager.utils.ProgressHandler +import org.slf4j.LoggerFactory +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.channels.Channels +import java.nio.channels.FileChannel +import java.nio.channels.ReadableByteChannel +import java.nio.channels.WritableByteChannel + +/** Base class to handle file copy. */ +@Suppress("ComplexMethod", "LongMethod") +class GenericCopyUtil( + private val mContext: Context, + private val progressHandler: ProgressHandler, +) { + private var mSourceFile: HybridFileParcelable? = null + private var mTargetFile: HybridFile? = null + private val dataUtils = DataUtils.getInstance() + + companion object { + @JvmStatic + private val LOG = LoggerFactory.getLogger(GenericCopyUtil::class.java) + + const val DEFAULT_BUFFER_SIZE = 8192 + + // Defines the block size per transfer over NIO channels. + // Cannot modify DEFAULT_BUFFER_SIZE since it's used by other classes, will have undesired + // effect on other functions + @JvmStatic + private val DEFAULT_TRANSFER_QUANTUM = 1024 * 1024 + + @JvmStatic + private val PROGRESS_UPDATE_THRESHOLD = 4L * 1024 * 1024 + } + + /** + * Starts copy of file Supports : [File], [jcifs.smb.SmbFile], [DocumentFile], + * [CloudStorage] + * + * @param lowOnMemory defines whether system is running low on memory, in which case we'll switch + * to using streams instead of channel which maps the whole buffer in memory. + */ + @Throws(IOException::class) + private fun startCopy( + lowOnMemory: Boolean, + onLowMemory: OnLowMemory, + updatePosition: UpdatePosition, + ) { + var inChannel: ReadableByteChannel? = null + var outChannel: WritableByteChannel? = null + var bufferedInputStream: BufferedInputStream? = null + var bufferedOutputStream: BufferedOutputStream? = null + + try { + val sourceFile = requireNotNull(mSourceFile) + val targetFile = requireNotNull(mTargetFile) + + // initializing the input channels based on file types + when { + sourceFile.isOtgFile || sourceFile.isDocumentFile -> { + val contentResolver = mContext.contentResolver + val documentSourceFile = + if (sourceFile.isDocumentFile) { + OTGUtil.getDocumentFile( + sourceFile.path, + SafRootHolder.uriRoot!!, + mContext, + if (sourceFile.isOtgFile) OpenMode.OTG else OpenMode.DOCUMENT_FILE, + false, + ) + } else { + OTGUtil.getDocumentFile(sourceFile.path, mContext, false) + } + bufferedInputStream = + BufferedInputStream( + contentResolver.openInputStream(documentSourceFile!!.uri), + DEFAULT_TRANSFER_QUANTUM, + ) + } + sourceFile.isSmb || sourceFile.isSftp || sourceFile.isFtp -> { + bufferedInputStream = + BufferedInputStream( + sourceFile.getInputStream(mContext), + DEFAULT_TRANSFER_QUANTUM, + ) + } + sourceFile.isDropBoxFile || sourceFile.isBoxFile || + sourceFile.isGoogleDriveFile || sourceFile.isOneDriveFile -> { + val openMode = sourceFile.mode + val cloudStorage = dataUtils.getAccount(openMode) + bufferedInputStream = + BufferedInputStream( + cloudStorage.download(CloudUtil.stripPath(openMode, sourceFile.path)), + ) + } + else -> { + // source file is neither smb nor otg; getting a channel from direct file + val file = File(sourceFile.path) + if (FileProperties.isReadable(file)) { + if (targetFile.isOneDriveFile || targetFile.isDropBoxFile || + targetFile.isGoogleDriveFile || targetFile.isBoxFile || lowOnMemory + ) { + bufferedInputStream = BufferedInputStream(FileInputStream(file)) + } else { + inChannel = RandomAccessFile(file, "r").channel + } + } else { + if (SDK_INT >= LOLLIPOP) { + val contentResolver = mContext.contentResolver + val documentSourceFile = + ExternalSdCardOperation.getDocumentFile( + file, + sourceFile.isDirectory, + mContext, + ) + bufferedInputStream = + BufferedInputStream( + contentResolver.openInputStream(documentSourceFile!!.uri), + DEFAULT_TRANSFER_QUANTUM, + ) + } else if (SDK_INT == KITKAT) { + val inputStream = + MediaStoreHack.getInputStream( + mContext, + file, + sourceFile.getSize(), + ) + bufferedInputStream = BufferedInputStream(inputStream) + } + } + } + } + + // initializing the output channels based on file types + when { + targetFile.isOtgFile || targetFile.isDocumentFile -> { + val contentResolver = mContext.contentResolver + val documentTargetFile = + if (targetFile.isDocumentFile) { + OTGUtil.getDocumentFile( + targetFile.path, + SafRootHolder.uriRoot!!, + mContext, + if (targetFile.isOtgFile) OpenMode.OTG else OpenMode.DOCUMENT_FILE, + true, + ) + } else { + OTGUtil.getDocumentFile(targetFile.path, mContext, true) + } + bufferedOutputStream = + BufferedOutputStream( + contentResolver.openOutputStream(documentTargetFile!!.uri), + DEFAULT_TRANSFER_QUANTUM, + ) + } + targetFile.isFtp || targetFile.isSftp || targetFile.isSmb -> { + bufferedOutputStream = + BufferedOutputStream( + targetFile.getOutputStream(mContext), + DEFAULT_TRANSFER_QUANTUM, + ) + } + targetFile.isDropBoxFile || targetFile.isBoxFile || + targetFile.isGoogleDriveFile || targetFile.isOneDriveFile -> { + cloudCopy(targetFile.mode, requireNotNull(bufferedInputStream)) + return + } + else -> { + val file = File(targetFile.path) + if (FileProperties.isWritable(file)) { + if (lowOnMemory) { + bufferedOutputStream = BufferedOutputStream(FileOutputStream(file)) + } else { + outChannel = RandomAccessFile(file, "rw").channel + } + } else { + if (SDK_INT >= LOLLIPOP) { + val contentResolver = mContext.contentResolver + val documentTargetFile = + ExternalSdCardOperation.getDocumentFile( + file, + targetFile.isDirectory(mContext), + mContext, + ) + bufferedOutputStream = + BufferedOutputStream( + contentResolver.openOutputStream(documentTargetFile!!.uri), + DEFAULT_TRANSFER_QUANTUM, + ) + } else if (SDK_INT == KITKAT) { + bufferedOutputStream = + BufferedOutputStream( + MediaStoreHack.getOutputStream(mContext, file.path), + ) + } + } + } + } + + if (bufferedInputStream != null) { + inChannel = Channels.newChannel(bufferedInputStream) + } + if (bufferedOutputStream != null) { + outChannel = Channels.newChannel(bufferedOutputStream) + } + + requireNotNull(inChannel) { "Input channel must not be null" } + requireNotNull(outChannel) { "Output channel must not be null" } + + doCopy(inChannel, outChannel, updatePosition) + } catch (e: IOException) { + LOG.error("I/O Error copy {} to {}: {}", mSourceFile, mTargetFile, e) + throw IOException(e) + } catch (e: OutOfMemoryError) { + LOG.warn("low memory while copying {} to {}: {}", mSourceFile, mTargetFile, e) + onLowMemory.onLowMemory() + startCopy(true, onLowMemory, updatePosition) + } finally { + try { + if (inChannel != null && inChannel.isOpen) inChannel.close() + if (outChannel != null && outChannel.isOpen) outChannel.close() + /* + * It does seem closing the inChannel/outChannel is already sufficient closing the below + * bufferedInputStream and bufferedOutputStream instances. These 2 lines prevented FTP + * copy from working, especially on Android 9 - TranceLove + */ + } catch (e: IOException) { + LOG.warn("failed to close stream after copying", e) + } + + // If target file is copied onto the device and copy was successful, trigger media store + // rescan + mTargetFile?.let { + MediaConnectionUtils.scanFile(mContext, arrayOf(it)) + } + } + } + + @Throws(IOException::class) + private fun cloudCopy( + openMode: OpenMode, + bufferedInputStream: BufferedInputStream, + ) { + val dataUtils = DataUtils.getInstance() + val cloudStorage = dataUtils.getAccount(openMode) + + try { + if (mSourceFile?.mode == openMode) { + cloudStorage.copy( + CloudUtil.stripPath(openMode, mSourceFile!!.path), + CloudUtil.stripPath(openMode, mTargetFile!!.path), + ) + } else { + cloudStorage.upload( + CloudUtil.stripPath(openMode, mTargetFile!!.path), + bufferedInputStream, + mSourceFile!!.getSize(), + true, + ) + } + } finally { + try { + bufferedInputStream.close() + } catch (e: IOException) { + LOG.warn("Failed to close BufferedInputStream in cloudCopy", e) + } + } + } + + /** + * Method exposes this class to initiate copy + * + * @param sourceFile the source file, which is to be copied + * @param targetFile the target file + */ + @Throws(IOException::class) + fun copy( + sourceFile: HybridFileParcelable, + targetFile: HybridFile, + onLowMemory: OnLowMemory, + updatePosition: UpdatePosition, + ) { + mSourceFile = sourceFile + mTargetFile = targetFile + startCopy(false, onLowMemory, updatePosition) + } + + /** + * Calls [doCopy]. + * + * @see Channels.newChannel + * @param bufferedInputStream source + * @param outChannel target + */ + @VisibleForTesting + @Throws(IOException::class) + fun copyFile( + bufferedInputStream: BufferedInputStream, + outChannel: FileChannel, + updatePosition: UpdatePosition, + ) { + doCopy(Channels.newChannel(bufferedInputStream), outChannel, updatePosition) + } + + /** + * Calls [doCopy]. + * + * @param inChannel source + * @param outChannel target + */ + @VisibleForTesting + @Throws(IOException::class) + fun copyFile( + inChannel: FileChannel, + outChannel: FileChannel, + updatePosition: UpdatePosition, + ) { + doCopy(inChannel, outChannel, updatePosition) + } + + /** + * Calls [doCopy]. + * + * @see Channels.newChannel + * @param bufferedInputStream source + * @param bufferedOutputStream target + */ + @VisibleForTesting + @Throws(IOException::class) + fun copyFile( + bufferedInputStream: BufferedInputStream, + bufferedOutputStream: BufferedOutputStream, + updatePosition: UpdatePosition, + ) { + doCopy( + Channels.newChannel(bufferedInputStream), + Channels.newChannel(bufferedOutputStream), + updatePosition, + ) + } + + /** + * Calls [doCopy]. + * + * @see Channels.newChannel + * @param inChannel source + * @param bufferedOutputStream target + */ + @VisibleForTesting + @Throws(IOException::class) + fun copyFile( + inChannel: FileChannel, + bufferedOutputStream: BufferedOutputStream, + updatePosition: UpdatePosition, + ) { + doCopy(inChannel, Channels.newChannel(bufferedOutputStream), updatePosition) + } + + /** + * Core copy method. Uses [FileChannel.transferTo] for file-to-file copies (zero-copy + * optimization on Linux/Android via sendfile syscall), falls back to a [ByteBuffer] loop + * for other channel types. Progress updates are batched to reduce callback overhead. + */ + @VisibleForTesting + @Throws(IOException::class) + fun doCopy( + from: ReadableByteChannel, + to: WritableByteChannel, + updatePosition: UpdatePosition, + ) { + if (from is FileChannel && to is FileChannel) { + val size = from.size() + var position = 0L + var pendingProgress = 0L + var fallbackToBuffer = false + while (position < size && !progressHandler.cancelled) { + val remaining = size - position + val chunk = minOf(DEFAULT_TRANSFER_QUANTUM.toLong(), remaining) + val transferred = from.transferTo(position, chunk, to) + + if (transferred <= 0L) { + fallbackToBuffer = true + break + } + + position += transferred + pendingProgress += transferred + if (pendingProgress >= PROGRESS_UPDATE_THRESHOLD) { + updatePosition.updatePosition(pendingProgress) + pendingProgress = 0L + } + } + + if (fallbackToBuffer && position < size && !progressHandler.cancelled) { + from.position(position) + to.position(position) + + val buffer = ByteBuffer.allocateDirect(DEFAULT_TRANSFER_QUANTUM) + + while (position < size && !progressHandler.cancelled) { + buffer.clear() + val maxRead = minOf(buffer.capacity().toLong(), size - position).toInt() + buffer.limit(maxRead) + + val read = from.read(buffer) + if (read < 0) break + if (read == 0) throw IOException("Copy stalled: no read progress in fallback") + + buffer.flip() + var writtenInChunk = 0 + while (buffer.hasRemaining()) { + val written = to.write(buffer) + if (written <= 0) throw IOException("Copy stalled: no write progress in fallback") + writtenInChunk += written + } + + position += writtenInChunk.toLong() + pendingProgress += writtenInChunk.toLong() + if (pendingProgress >= PROGRESS_UPDATE_THRESHOLD) { + updatePosition.updatePosition(pendingProgress) + pendingProgress = 0L + } + } + } + + if (pendingProgress > 0L) { + updatePosition.updatePosition(pendingProgress) + } + } else { + val buffer = ByteBuffer.allocateDirect(DEFAULT_TRANSFER_QUANTUM) + var pendingProgress = 0L + while (!progressHandler.cancelled) { + buffer.clear() + val read = from.read(buffer) + if (read < 0) break + if (read == 0) throw IOException("Copy stalled: no read progress") + + buffer.flip() + while (buffer.hasRemaining()) { + val written = to.write(buffer) + if (written <= 0) throw IOException("Copy stalled: no write progress") + pendingProgress += written.toLong() + + if (pendingProgress >= PROGRESS_UPDATE_THRESHOLD) { + updatePosition.updatePosition(pendingProgress) + pendingProgress = 0L + } + } + } + if (pendingProgress > 0L) { + updatePosition.updatePosition(pendingProgress) + } + } + } +} diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilTest.java deleted file mode 100644 index 89d19d94d4..0000000000 --- a/app/src/test/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilTest.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , - * Emmanuel Messulam, Raymond Lai and Contributors. - * - * This file is part of Amaze File Manager. - * - * Amaze File Manager 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.amaze.filemanager.filesystem.files; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.channels.Channels; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -import org.junit.Before; -import org.junit.experimental.theories.DataPoints; -import org.junit.experimental.theories.Theories; -import org.junit.experimental.theories.Theory; -import org.junit.runner.RunWith; -import org.robolectric.RuntimeEnvironment; - -import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil; -import com.amaze.filemanager.test.DummyFileGenerator; -import com.amaze.filemanager.utils.ProgressHandler; - -@RunWith(Theories.class) -public class GenericCopyUtilTest { - - private GenericCopyUtil copyUtil; - - private File file1, file2; - - public static final @DataPoints int fileSizes[] = {512, 187139366}; - - @Before - public void setUp() throws IOException { - copyUtil = new GenericCopyUtil(RuntimeEnvironment.application, new ProgressHandler()); - file1 = File.createTempFile("test", "bin"); - file2 = File.createTempFile("test", "bin"); - file1.deleteOnExit(); - file2.deleteOnExit(); - } - - @Theory // doCopy(ReadableByteChannel in, WritableByteChannel out) - public void testCopyFile1(int size) throws IOException, NoSuchAlgorithmException { - byte[] checksum = DummyFileGenerator.createFile(file1, size); - copyUtil.doCopy( - new FileInputStream(file1).getChannel(), - Channels.newChannel(new FileOutputStream(file2)), - ServiceWatcherUtil.UPDATE_POSITION); - assertEquals(file1.length(), file2.length()); - assertSha1Equals(checksum, file2); - } - - @Theory // copy(FileChannel in, FileChannel out) - public void testCopyFile2(int size) throws IOException, NoSuchAlgorithmException { - byte[] checksum = DummyFileGenerator.createFile(file1, size); - copyUtil.copyFile( - new FileInputStream(file1).getChannel(), - new FileOutputStream(file2).getChannel(), - ServiceWatcherUtil.UPDATE_POSITION); - assertEquals(file1.length(), file2.length()); - assertSha1Equals(checksum, file2); - } - - @Theory // copy(BufferedInputStream in, BufferedOutputStream out) - public void testCopyFile3(int size) throws IOException, NoSuchAlgorithmException { - byte[] checksum = DummyFileGenerator.createFile(file1, size); - copyUtil.copyFile( - new BufferedInputStream(new FileInputStream(file1)), - new BufferedOutputStream(new FileOutputStream(file2)), - ServiceWatcherUtil.UPDATE_POSITION); - assertEquals(file1.length(), file2.length()); - assertSha1Equals(checksum, file2); - } - - private void assertSha1Equals(byte[] expected, File file) - throws NoSuchAlgorithmException, IOException { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - DigestInputStream in = new DigestInputStream(new FileInputStream(file), md); - byte[] buffer = new byte[GenericCopyUtil.DEFAULT_BUFFER_SIZE]; - while (in.read(buffer) > -1) {} - in.close(); - assertArrayEquals(expected, md.digest()); - } -} diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilTest.kt new file mode 100644 index 0000000000..0494a5a539 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/filesystem/files/GenericCopyUtilTest.kt @@ -0,0 +1,392 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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.amaze.filemanager.filesystem.files + +import android.os.Build.VERSION_CODES.LOLLIPOP +import com.amaze.filemanager.fileoperations.utils.UpdatePosition +import com.amaze.filemanager.test.DummyFileGenerator +import com.amaze.filemanager.utils.ProgressHandler +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.channels.Channels +import java.security.DigestInputStream +import java.security.MessageDigest + +/** + * Tests for [GenericCopyUtil].sss + */ +@Suppress("StringLiteralDuplication") +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [LOLLIPOP]) +class GenericCopyUtilTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var progressHandler: ProgressHandler + private lateinit var copyUtil: GenericCopyUtil + private lateinit var file1: File + private lateinit var file2: File + + /** + * Pre-test setup. + */ + @Before + fun setUp() { + progressHandler = ProgressHandler() + copyUtil = GenericCopyUtil(RuntimeEnvironment.getApplication(), progressHandler) + file1 = tempFolder.newFile("test1.bin") + file2 = tempFolder.newFile("test2.bin") + } + + /** + * Post test clean up. + */ + @After + fun tearDown() { + try { + if (file1.exists()) file1.delete() + if (file2.exists()) file2.delete() + } catch (_: Exception) { + // Ignore cleanup errors - TemporaryFolder will handle remaining files + } + } + + /** + * Test copy small file + */ + @Test + fun testDoCopySmallFile() { + verifyDoCopy(512) + } + + /** + * Test copy large file + */ + @Test + fun testDoCopyLargeFile() { + verifyDoCopy(10 * 1024 * 1024) + } + + /** + * Test copy empty file + */ + @Test + fun testDoCopyEmptyFile() { + verifyDoCopy(0) + } + + private fun verifyDoCopy(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + Channels.newChannel(FileOutputStream(file2)).use { fout -> + copyUtil.doCopy( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copy small file using FileChannel + */ + @Test + fun testCopyFileChannelSmallFile() { + verifyCopyFileChannel(512) + } + + /** + * Test copy large file using FileChannel + */ + @Test + fun testCopyFileChannelLargeFile() { + verifyCopyFileChannel(10 * 1024 * 1024) + } + + /** + * Test copy empty file using FileChannel + */ + @Test + fun testCopyFileChannelEmptyFile() { + verifyCopyFileChannel(0) + } + + private fun verifyCopyFileChannel(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + FileOutputStream(file2).channel.use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copy small file using Buffered Streams + */ + @Test + fun testCopyBufferedStreamsSmallFile() { + verifyCopyBufferedStreams(512) + } + + /** + * Test copy large file using Buffered Streams + */ + @Test + fun testCopyBufferedStreamsLargeFile() { + verifyCopyBufferedStreams(10 * 1024 * 1024) // 10 MB + } + + /** + * Test copy empty file using Buffered Streams + */ + @Test + fun testCopyBufferedStreamsEmptyFile() { + verifyCopyBufferedStreams(0) + } + + private fun verifyCopyBufferedStreams(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + BufferedInputStream(FileInputStream(file1)).use { fin -> + BufferedOutputStream(FileOutputStream(file2)).use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copy small file using FileChannel to BufferedOutputStream + */ + @Test + fun testCopyFileChannelToBufferedOutputStreamSmallFile() { + verifyCopyFileChannelToBufferedOutputStream(512) + } + + /** + * Test copy large file using FileChannel to BufferedOutputStream + */ + @Test + fun testCopyFileChannelToBufferedOutputStreamLargeFile() { + verifyCopyFileChannelToBufferedOutputStream(10 * 1024 * 1024) // 10 MB + } + + /** + * Test copy empty file using FileChannel to BufferedOutputStream + */ + @Test + fun testCopyFileChannelToBufferedOutputStreamEmptyFile() { + verifyCopyFileChannelToBufferedOutputStream(0) + } + + private fun verifyCopyFileChannelToBufferedOutputStream(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + BufferedOutputStream(FileOutputStream(file2)).use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test copy small file using BufferedInputStream to FileChannel + */ + @Test + fun testCopyBufferedInputStreamToFileChannelSmallFile() { + verifyCopyBufferedInputStreamToFileChannel(512) + } + + /** + * Test copy large file using BufferedInputStream to FileChannel + */ + @Test + fun testCopyBufferedInputStreamToFileChannelLargeFile() { + verifyCopyBufferedInputStreamToFileChannel(10 * 1024 * 1024) + } + + private fun verifyCopyBufferedInputStreamToFileChannel(size: Int) { + val checksum = DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + BufferedInputStream(FileInputStream(file1)).use { fin -> + FileOutputStream(file2).channel.use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals(file1.length(), file2.length()) + if (size > 0) { + assertSha1Equals(checksum, file2) + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + } + + /** + * Test cancellation of copy operation + */ + @Test + fun testCancellation() { + // Create a larger file so there's time to cancel + val size = 10 * 1024 * 1024 + DummyFileGenerator.createFile(file1, size) + + progressHandler.setCancelled(true) + + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + Channels.newChannel(FileOutputStream(file2)).use { fout -> + copyUtil.doCopy( + fin, + fout, + updatePosition, + ) + } + } + + // When cancelled before starting, nothing or very little should be copied + assertTrue( + "Cancelled copy should write less than full size", + file2.length() < file1.length(), + ) + } + + /** + * Test progress updates batched for large files + */ + @Test + fun testBatchedProgressLargeFileFileChannelPath() { + val size = 10 * 1024 * 1024 // 10 MB + DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + FileOutputStream(file2).channel.use { fout -> + copyUtil.copyFile( + fin, + fout, + updatePosition, + ) + } + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + // With 10 MB file and 4 MB threshold, we expect ~2-3 callbacks, not 10 + assertTrue( + "Batched progress should have fewer callbacks than unbatched (got ${progressUpdates.size})", + progressUpdates.size <= 5, + ) + } + + /** + * Test progress updates batched for large files when using ByteBuffer copy path + */ + @Test + fun testBatchedProgressLargeFileByteBufferPath() { + val size = 10 * 1024 * 1024 // 10 MB + DummyFileGenerator.createFile(file1, size) + val progressUpdates = mutableListOf() + val updatePosition = UpdatePosition { progressUpdates.add(it) } + FileInputStream(file1).channel.use { fin -> + Channels.newChannel(FileOutputStream(file2)).use { fout -> + copyUtil.doCopy( + fin, + fout, + updatePosition, + ) + } + } + assertEquals("Progress sum should equal file size", file1.length(), progressUpdates.sum()) + // With 10 MB file and 4 MB threshold, we expect ~2-3 callbacks, not 10 + assertTrue( + "Batched progress should have fewer callbacks than unbatched (got ${progressUpdates.size})", + progressUpdates.size <= 5, + ) + } + + private fun assertSha1Equals( + expected: ByteArray, + file: File, + ) { + val md = MessageDigest.getInstance("SHA-1") + DigestInputStream(FileInputStream(file), md).use { din -> + val buffer = ByteArray(GenericCopyUtil.DEFAULT_BUFFER_SIZE) + while (din.read(buffer) > -1) { /* consume */ } + } + assertArrayEquals(expected, md.digest()) + } +} diff --git a/file_operations/build.gradle b/file_operations/build.gradle index 5400d64458..c7e27c53ea 100644 --- a/file_operations/build.gradle +++ b/file_operations/build.gradle @@ -7,6 +7,11 @@ android { namespace "com.amaze.filemanager.fileoperations" compileSdk libs.versions.compileSdk.get().toInteger() ndkVersion libs.versions.ndk.get() + packagingOptions { + resources { + excludes += ['proguard-project.txt', 'project.properties', 'META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + } + } defaultConfig { minSdkVersion libs.versions.minSdk.get().toInteger() diff --git a/testShared/src/test/java/com/amaze/filemanager/test/DummyFileGenerator.java b/testShared/src/test/java/com/amaze/filemanager/test/DummyFileGenerator.java index 43f65912e4..f40e6cd9f2 100644 --- a/testShared/src/test/java/com/amaze/filemanager/test/DummyFileGenerator.java +++ b/testShared/src/test/java/com/amaze/filemanager/test/DummyFileGenerator.java @@ -55,7 +55,7 @@ public abstract class DummyFileGenerator { @RestrictTo(RestrictTo.Scope.TESTS) public static byte[] createFile(@NonNull File destFile, int size) throws IOException { Random rand = new SecureRandom(); - MessageDigest md = null; + MessageDigest md; try { md = MessageDigest.getInstance("SHA-1"); } catch (NoSuchAlgorithmException shouldNeverHappen) { @@ -64,11 +64,13 @@ public static byte[] createFile(@NonNull File destFile, int size) throws IOExcep FileOutputStream out = new FileOutputStream(destFile); DigestOutputStream dout = new DigestOutputStream(out, md); - int count = 0; - for (int i = size; i >= 0; i -= DEFAULT_BUFFER_SIZE, count += DEFAULT_BUFFER_SIZE) { - byte[] bytes = new byte[i > DEFAULT_BUFFER_SIZE ? DEFAULT_BUFFER_SIZE : i]; + int remaining = size; + while (remaining > 0) { + int toWrite = Math.min(remaining, DEFAULT_BUFFER_SIZE); + byte[] bytes = new byte[toWrite]; rand.nextBytes(bytes); dout.write(bytes); + remaining -= toWrite; } dout.flush(); dout.close();