From a2b1f5093e290d6fed469abf6fa9d87b75c24e97 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 26 Aug 2024 18:18:17 +0530 Subject: [PATCH 01/11] Added the file picker in play store variant for move or copy the zim files in application's public directory --- .../library/LocalLibraryFragment.kt | 203 +++++++++++++++++- .../library/OnlineLibraryFragment.kt | 1 - core/src/main/AndroidManifest.xml | 3 +- .../core/utils/SharedPreferenceUtil.kt | 10 + .../core/utils/dialog/AlertDialogShower.kt | 9 +- .../core/utils/dialog/KiwixDialog.kt | 16 ++ core/src/main/res/values/strings.xml | 6 + 7 files changed, 232 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt index 069f4a450f..cbe319e213 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt @@ -27,6 +27,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment +import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -35,9 +36,11 @@ import android.view.View import android.view.View.GONE import android.view.View.VISIBLE import android.view.ViewGroup +import android.widget.ProgressBar import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar @@ -51,14 +54,19 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomnavigation.BottomNavigationView +import eu.mhutti1.utils.storage.Bytes +import eu.mhutti1.utils.storage.StorageDevice +import eu.mhutti1.utils.storage.StorageSelectDialog import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.kiwix.kiwixmobile.R import org.kiwix.kiwixmobile.cachedComponent import org.kiwix.kiwixmobile.core.R.string @@ -69,7 +77,10 @@ import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.navigate import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.viewModel import org.kiwix.kiwixmobile.core.extensions.browserIntent import org.kiwix.kiwixmobile.core.extensions.coreMainActivity +import org.kiwix.kiwixmobile.core.extensions.deleteFile +import org.kiwix.kiwixmobile.core.extensions.isFileExist import org.kiwix.kiwixmobile.core.extensions.setBottomMarginToFragmentContainerView +import org.kiwix.kiwixmobile.core.extensions.snack import org.kiwix.kiwixmobile.core.extensions.toast import org.kiwix.kiwixmobile.core.main.CoreMainActivity import org.kiwix.kiwixmobile.core.main.KIWIX_APK_WEBSITE_URL @@ -78,6 +89,9 @@ import org.kiwix.kiwixmobile.core.navigateToAppSettings import org.kiwix.kiwixmobile.core.navigateToSettings import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimReaderSource +import org.kiwix.kiwixmobile.core.settings.StorageCalculator +import org.kiwix.kiwixmobile.core.utils.EXTERNAL_SELECT_POSITION +import org.kiwix.kiwixmobile.core.utils.INTERNAL_SELECT_POSITION import org.kiwix.kiwixmobile.core.utils.LanguageUtils import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.SimpleRecyclerViewScrollListener @@ -100,6 +114,7 @@ import org.kiwix.kiwixmobile.zimManager.ZimManageViewModel.FileSelectActions.Req import org.kiwix.kiwixmobile.zimManager.ZimManageViewModel.FileSelectActions.RequestSelect import org.kiwix.kiwixmobile.zimManager.fileselectView.FileSelectListState import java.io.File +import java.util.Locale import javax.inject.Inject private const val WAS_IN_ACTION_MODE = "WAS_IN_ACTION_MODE" @@ -112,12 +127,16 @@ class LocalLibraryFragment : BaseFragment() { @Inject lateinit var dialogShower: DialogShower @Inject lateinit var mainRepositoryActions: MainRepositoryActions @Inject lateinit var zimReaderFactory: ZimFileReader.Factory + @Inject lateinit var storageCalculator: StorageCalculator private var actionMode: ActionMode? = null private val disposable = CompositeDisposable() private var fragmentDestinationLibraryBinding: FragmentDestinationLibraryBinding? = null private var permissionDeniedLayoutShowing = false private var fileSelectListState: FileSelectListState? = null + private var selectedFileUri: Uri? = null + private var selectedFile: File? = null + private var progressBarDialog: AlertDialog? = null private val zimManageViewModel by lazy { requireActivity().viewModel(viewModelFactory) @@ -269,7 +288,7 @@ class LocalLibraryFragment : BaseFragment() { offerAction(FileSelectActions.UserClickedDownloadBooksButton) } } - hideFilePickerButton() + setUpFilePickerButton() fragmentDestinationLibraryBinding?.zimfilelist?.addOnScrollListener( SimpleRecyclerViewScrollListener { _, newState -> @@ -340,13 +359,7 @@ class LocalLibraryFragment : BaseFragment() { } } - private fun hideFilePickerButton() { - if (sharedPreferenceUtil.isPlayStoreBuild) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - fragmentDestinationLibraryBinding?.selectFile?.visibility = View.GONE - } - } - + private fun setUpFilePickerButton() { fragmentDestinationLibraryBinding?.selectFile?.setOnClickListener { if (!requireActivity().isManageExternalStoragePermissionGranted(sharedPreferenceUtil)) { showManageExternalStoragePermissionDialog() @@ -372,12 +385,178 @@ class LocalLibraryFragment : BaseFragment() { private val fileSelectLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { - result.data?.data?.let { uri -> - getZimFileFromUri(uri)?.let(::navigateToReaderFragment) + result.data?.data?.let(::handleSelectedFileUri) + } + } + + private fun handleSelectedFileUri(uri: Uri) { + getZimFileFromUri(uri)?.let { file -> + if (sharedPreferenceUtil.isPlayStoreBuildWithAndroid11OrAbove()) { + selectedFileUri = uri + selectedFile = file + showMoveFileToPublicDirectoryDialog() + } else { + navigateToReaderFragment(file) + } + } + } + + private fun showMoveFileToPublicDirectoryDialog() { + if (!sharedPreferenceUtil.copyMoveZimFilePermissionDialog) { + dialogShower.show( + KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, + { + sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true + showCopyMoveDialog() + } + ) + } else { + showCopyMoveDialog() + } + } + + private fun showCopyMoveDialog() { + val availableSpace = storageCalculator.availableBytes() + val fileSize = selectedFile?.length() ?: 0L + + if (availableSpace > fileSize) { + dialogShower.show( + KiwixDialog.CopyMoveFileToPublicDirectoryDialog, + { + copyZimFileToPublicAppDirectory() + Log.e("CLICKED", "showCopyMoveDialog: copied clicked") + }, + { + moveZimFileToPublicAppDirectory() + Log.e("CLICKED", "showCopyMoveDialog: move clicked") + } + ) + } else { + showInsufficientSpaceError(availableSpace) + } + } + + private fun copyZimFileToPublicAppDirectory() { + lifecycleScope.launch { + val destinationFile = getDestinationFile() + showProgressDialog() + try { + selectedFileUri?.let { + val destinationUri = Uri.fromFile(destinationFile) + + // Perform the copy operation in a background thread + withContext(Dispatchers.IO) { + copyFile(it, destinationUri) + } + + // Notify the user on the main thread + withContext(Dispatchers.Main) { + dismissProgressDialog() + Toast.makeText(requireContext(), "File copied successfully", Toast.LENGTH_SHORT).show() + } + } + } catch (e: Exception) { + dismissProgressDialog() + e.printStackTrace() + activity.toast("Unable to copy zim file ${e.message}", Toast.LENGTH_SHORT).also { + // delete the temporary file if any error happens + destinationFile.deleteFile() + } + } + } + } + + private suspend fun copyFile(sourceUri: Uri, destinationUri: Uri) { + val inputStream = requireContext().contentResolver.openInputStream(sourceUri) + val outputStream = requireContext().contentResolver.openOutputStream(destinationUri) + + if (inputStream != null && outputStream != null) { + val buffer = ByteArray(1024) + var bytesRead: Int + var totalBytesCopied = 0L + val fileSize = inputStream.available().toLong() // Get file size + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesCopied += bytesRead + + // Update progress (on main thread) + withContext(Dispatchers.Main) { + val progress = (totalBytesCopied * 100 / fileSize).toInt() + progressBarDialog?.setMessage("Copying file: $progress%") } } + + outputStream.flush() + inputStream.close() + outputStream.close() + } else { + throw Exception("Error accessing streams") + } + } + + private fun moveZimFileToPublicAppDirectory() { + } + + private fun getDestinationFile(): File = + File("${sharedPreferenceUtil.prefStorage}/${selectedFile?.name}").also { + if (!it.isFileExist()) it.createNewFile() } + private fun showInsufficientSpaceError(availableSpace: Long) { + val message = """ + ${getString(string.move_no_space)} + ${getString(string.space_available)} ${Bytes(availableSpace).humanReadable} + """.trimIndent() + + fragmentDestinationLibraryBinding?.zimfilelist?.snack( + message, + requireActivity().findViewById(R.id.bottom_nav_view), + string.download_change_storage, + ::showStorageSelectDialog + ) + } + + private fun showStorageSelectDialog() = StorageSelectDialog() + .apply { + onSelectAction = ::storeDeviceInPreferences + } + .show(parentFragmentManager, getString(string.pref_storage)) + + private fun storeDeviceInPreferences( + storageDevice: StorageDevice + ) { + sharedPreferenceUtil.putPrefStorage( + sharedPreferenceUtil.getPublicDirectoryPath(storageDevice.name) + ) + sharedPreferenceUtil.putStoragePosition( + if (storageDevice.isInternal) INTERNAL_SELECT_POSITION + else EXTERNAL_SELECT_POSITION + ) + // after selecting the storage try to copy/move the zim file. + showCopyMoveDialog() + } + + private fun showProgressDialog() { + val builder = AlertDialog.Builder(requireContext()) + val progressBar = ProgressBar(requireContext()) + progressBar.isIndeterminate = true + + builder.setTitle("Copying File") + builder.setMessage("Please wait...") + builder.setView(progressBar) + builder.setCancelable(false) + + progressBarDialog = builder.create() + progressBarDialog?.show() + } + + private fun dismissProgressDialog() { + if (progressBarDialog?.isShowing == true) { + progressBarDialog?.dismiss() + } + } + private fun getZimFileFromUri( uri: Uri ): File? { @@ -462,6 +641,8 @@ class LocalLibraryFragment : BaseFragment() { disposable.clear() storagePermissionLauncher?.unregister() storagePermissionLauncher = null + selectedFile = null + selectedFileUri = null } private fun sideEffects() = zimManageViewModel.sideEffects @@ -521,7 +702,7 @@ class LocalLibraryFragment : BaseFragment() { } private fun setActionModeTitle(selectedBookCount: Int) { - actionMode?.title = String.format("%d", selectedBookCount) + actionMode?.title = String.format(Locale.getDefault(), "%d", selectedBookCount) } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt index 0e5db16ab9..9d15012f77 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt @@ -373,7 +373,6 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions { } } - @SuppressLint("InflateParams") private fun storeDeviceInPreferences( storageDevice: StorageDevice ) { diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 5700616006..f848bdd2e4 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -58,13 +58,14 @@ android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules = "@xml/data_extraction_rules" android:hardwareAccelerated="true" + android:enableOnBackInvokedCallback="true" android:largeHeap="true" android:requestLegacyExternalStorage="true" android:resizeableActivity="true" android:supportsRtl="true" android:theme="@style/KiwixTheme" android:usesCleartextTraffic="true" - tools:targetApi="s"> + tools:targetApi="tiramisu"> = Build.VERSION_CODES.R) { path @@ -312,5 +321,6 @@ class SharedPreferenceUtil @Inject constructor(val context: Context) { const val PREF_HISTORY_MIGRATED = "pref_history_migrated" const val PREF_NOTES_MIGRATED = "pref_notes_migrated" const val PREF_APP_DIRECTORY_TO_PUBLIC_MIGRATED = "pref_app_directory_to_public_migrated" + const val PREF_COPY_MOVE_PERMISSION = "pref_copy_move_permission" } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt index 3b2b39b145..6beb47aa14 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt @@ -56,18 +56,21 @@ class AlertDialogShower @Inject constructor(private val activity: Activity) : dialog.icon?.let(this::setIcon) dialog.message?.let { setMessage(activity.getString(it, *bodyArguments(dialog))) } - setPositiveButton(dialog.positiveMessage) { _, _ -> + setPositiveButton(dialog.positiveMessage) { h, _ -> + h.dismiss() clickListeners.getOrNull(0) ?.invoke() } dialog.negativeMessage?.let { - setNegativeButton(it) { _, _ -> + setNegativeButton(it) { h, _ -> + h.dismiss() clickListeners.getOrNull(1) ?.invoke() } } dialog.neutralMessage?.let { - setNeutralButton(it) { _, _ -> + setNeutralButton(it) { h, _ -> + h.dismiss() clickListeners.getOrNull(2) ?.invoke() } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt index afd3b24cd2..9ca2d3044d 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt @@ -109,6 +109,22 @@ sealed class KiwixDialog( cancelable = false ) + data object MoveFileToPublicDirectoryPermissionDialog : KiwixDialog( + R.string.move_files_permission_dialog_title, + R.string.move_files_permission_dialog_description, + R.string.yes, + R.string.no, + cancelable = false + ) + + data object CopyMoveFileToPublicDirectoryDialog : KiwixDialog( + null, + R.string.copy_move_files_dialog_description, + R.string.copy, + R.string.move, + cancelable = false + ) + object SaveOrOpenUnsupportedFiles : KiwixDialog( R.string.save_or_open_unsupported_files_dialog_title, R.string.save_or_open_unsupported_files_dialog_message, diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index cc7703127d..99e11790f5 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -123,6 +123,7 @@ Download Download Notification Channel Name Space Available: + Insufficient space to move/copy. Simple No Pictures No Videos @@ -326,6 +327,11 @@ Zim files not showing? Due to Google Play policies on Android 11 and above, this Google Store app can\’t open sideloaded ZIM files. You can either download them through the app or, instead, install the full version of kiwix app from our official website %s Understood + Copy + Move + Do you want to copy or move the file? + Move/Copy files to app public directory? + Due to Google Play policies on Android 11 and above, our app can no longer directly access files stored elsewhere on your device. To let you view your selected files, we need to move or copy them into a special folder within our application directory. This allows us to access and open the files. Do you agree to this? How to update content? To update content (a zim file) you need to download the full latest version of this very same content. You can do that via the download section. All Files Permission Needed From 1ec4df47536d3cb2ad995bdeb09357ce3e8db7fd Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 27 Aug 2024 19:17:46 +0530 Subject: [PATCH 02/11] Created a CopyMoveFileHandler for handing the copy/move of files. * Improved the showing of progressBar to inform the user about the copy/moving progress. --- .../library/LocalLibraryFragment.kt | 241 +++++------------- .../library/OnlineLibraryFragment.kt | 1 - .../destination/reader/CopyMoveFileHandler.kt | 230 +++++++++++++++++ .../res/layout/copy_move_progress_bar.xml | 48 ++++ .../core/utils/SharedPreferenceUtil.kt | 1 - core/src/main/res/values/strings.xml | 2 + 6 files changed, 346 insertions(+), 177 deletions(-) create mode 100644 app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt create mode 100644 app/src/main/res/layout/copy_move_progress_bar.xml diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt index cbe319e213..921d0a2841 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt @@ -27,7 +27,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment -import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -36,11 +35,9 @@ import android.view.View import android.view.View.GONE import android.view.View.VISIBLE import android.view.ViewGroup -import android.widget.ProgressBar import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar @@ -66,7 +63,6 @@ import io.reactivex.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.kiwix.kiwixmobile.R import org.kiwix.kiwixmobile.cachedComponent import org.kiwix.kiwixmobile.core.R.string @@ -77,8 +73,6 @@ import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.navigate import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.viewModel import org.kiwix.kiwixmobile.core.extensions.browserIntent import org.kiwix.kiwixmobile.core.extensions.coreMainActivity -import org.kiwix.kiwixmobile.core.extensions.deleteFile -import org.kiwix.kiwixmobile.core.extensions.isFileExist import org.kiwix.kiwixmobile.core.extensions.setBottomMarginToFragmentContainerView import org.kiwix.kiwixmobile.core.extensions.snack import org.kiwix.kiwixmobile.core.extensions.toast @@ -89,7 +83,6 @@ import org.kiwix.kiwixmobile.core.navigateToAppSettings import org.kiwix.kiwixmobile.core.navigateToSettings import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimReaderSource -import org.kiwix.kiwixmobile.core.settings.StorageCalculator import org.kiwix.kiwixmobile.core.utils.EXTERNAL_SELECT_POSITION import org.kiwix.kiwixmobile.core.utils.INTERNAL_SELECT_POSITION import org.kiwix.kiwixmobile.core.utils.LanguageUtils @@ -105,6 +98,7 @@ import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDis import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk import org.kiwix.kiwixmobile.databinding.FragmentDestinationLibraryBinding +import org.kiwix.kiwixmobile.nav.destination.reader.CopyMoveFileHandler import org.kiwix.kiwixmobile.zimManager.MAX_PROGRESS import org.kiwix.kiwixmobile.zimManager.ZimManageViewModel import org.kiwix.kiwixmobile.zimManager.ZimManageViewModel.FileSelectActions @@ -120,23 +114,23 @@ import javax.inject.Inject private const val WAS_IN_ACTION_MODE = "WAS_IN_ACTION_MODE" private const val MATERIAL_BOTTOM_VIEW_ENTER_ANIMATION_DURATION = 225L -class LocalLibraryFragment : BaseFragment() { +class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCallback { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @Inject lateinit var sharedPreferenceUtil: SharedPreferenceUtil @Inject lateinit var dialogShower: DialogShower @Inject lateinit var mainRepositoryActions: MainRepositoryActions @Inject lateinit var zimReaderFactory: ZimFileReader.Factory - @Inject lateinit var storageCalculator: StorageCalculator + + @JvmField + @Inject + var copyMoveFileHandler: CopyMoveFileHandler? = null private var actionMode: ActionMode? = null private val disposable = CompositeDisposable() private var fragmentDestinationLibraryBinding: FragmentDestinationLibraryBinding? = null private var permissionDeniedLayoutShowing = false private var fileSelectListState: FileSelectListState? = null - private var selectedFileUri: Uri? = null - private var selectedFile: File? = null - private var progressBarDialog: AlertDialog? = null private val zimManageViewModel by lazy { requireActivity().viewModel(viewModelFactory) @@ -250,6 +244,8 @@ class LocalLibraryFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpSwipeRefreshLayout() + copyMoveFileHandler?.fileCopyMoveCallback = this + copyMoveFileHandler?.lifecycleScope = lifecycleScope fragmentDestinationLibraryBinding?.zimfilelist?.run { adapter = booksOnDiskAdapter layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) @@ -371,7 +367,7 @@ class LocalLibraryFragment : BaseFragment() { private fun showFileChooser() { val intent = Intent().apply { - action = Intent.ACTION_GET_CONTENT + action = Intent.ACTION_OPEN_DOCUMENT type = "*/*" addCategory(Intent.CATEGORY_OPENABLE) } @@ -385,178 +381,27 @@ class LocalLibraryFragment : BaseFragment() { private val fileSelectLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { - result.data?.data?.let(::handleSelectedFileUri) + result.data?.data?.let { + requireActivity().applicationContext.contentResolver.takePersistableUriPermission( + it, + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + handleSelectedFileUri(it) + } } } private fun handleSelectedFileUri(uri: Uri) { getZimFileFromUri(uri)?.let { file -> if (sharedPreferenceUtil.isPlayStoreBuildWithAndroid11OrAbove()) { - selectedFileUri = uri - selectedFile = file - showMoveFileToPublicDirectoryDialog() + copyMoveFileHandler?.showMoveFileToPublicDirectoryDialog(uri, file) } else { navigateToReaderFragment(file) } } } - private fun showMoveFileToPublicDirectoryDialog() { - if (!sharedPreferenceUtil.copyMoveZimFilePermissionDialog) { - dialogShower.show( - KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, - { - sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true - showCopyMoveDialog() - } - ) - } else { - showCopyMoveDialog() - } - } - - private fun showCopyMoveDialog() { - val availableSpace = storageCalculator.availableBytes() - val fileSize = selectedFile?.length() ?: 0L - - if (availableSpace > fileSize) { - dialogShower.show( - KiwixDialog.CopyMoveFileToPublicDirectoryDialog, - { - copyZimFileToPublicAppDirectory() - Log.e("CLICKED", "showCopyMoveDialog: copied clicked") - }, - { - moveZimFileToPublicAppDirectory() - Log.e("CLICKED", "showCopyMoveDialog: move clicked") - } - ) - } else { - showInsufficientSpaceError(availableSpace) - } - } - - private fun copyZimFileToPublicAppDirectory() { - lifecycleScope.launch { - val destinationFile = getDestinationFile() - showProgressDialog() - try { - selectedFileUri?.let { - val destinationUri = Uri.fromFile(destinationFile) - - // Perform the copy operation in a background thread - withContext(Dispatchers.IO) { - copyFile(it, destinationUri) - } - - // Notify the user on the main thread - withContext(Dispatchers.Main) { - dismissProgressDialog() - Toast.makeText(requireContext(), "File copied successfully", Toast.LENGTH_SHORT).show() - } - } - } catch (e: Exception) { - dismissProgressDialog() - e.printStackTrace() - activity.toast("Unable to copy zim file ${e.message}", Toast.LENGTH_SHORT).also { - // delete the temporary file if any error happens - destinationFile.deleteFile() - } - } - } - } - - private suspend fun copyFile(sourceUri: Uri, destinationUri: Uri) { - val inputStream = requireContext().contentResolver.openInputStream(sourceUri) - val outputStream = requireContext().contentResolver.openOutputStream(destinationUri) - - if (inputStream != null && outputStream != null) { - val buffer = ByteArray(1024) - var bytesRead: Int - var totalBytesCopied = 0L - val fileSize = inputStream.available().toLong() // Get file size - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesCopied += bytesRead - - // Update progress (on main thread) - withContext(Dispatchers.Main) { - val progress = (totalBytesCopied * 100 / fileSize).toInt() - progressBarDialog?.setMessage("Copying file: $progress%") - } - } - - outputStream.flush() - inputStream.close() - outputStream.close() - } else { - throw Exception("Error accessing streams") - } - } - - private fun moveZimFileToPublicAppDirectory() { - } - - private fun getDestinationFile(): File = - File("${sharedPreferenceUtil.prefStorage}/${selectedFile?.name}").also { - if (!it.isFileExist()) it.createNewFile() - } - - private fun showInsufficientSpaceError(availableSpace: Long) { - val message = """ - ${getString(string.move_no_space)} - ${getString(string.space_available)} ${Bytes(availableSpace).humanReadable} - """.trimIndent() - - fragmentDestinationLibraryBinding?.zimfilelist?.snack( - message, - requireActivity().findViewById(R.id.bottom_nav_view), - string.download_change_storage, - ::showStorageSelectDialog - ) - } - - private fun showStorageSelectDialog() = StorageSelectDialog() - .apply { - onSelectAction = ::storeDeviceInPreferences - } - .show(parentFragmentManager, getString(string.pref_storage)) - - private fun storeDeviceInPreferences( - storageDevice: StorageDevice - ) { - sharedPreferenceUtil.putPrefStorage( - sharedPreferenceUtil.getPublicDirectoryPath(storageDevice.name) - ) - sharedPreferenceUtil.putStoragePosition( - if (storageDevice.isInternal) INTERNAL_SELECT_POSITION - else EXTERNAL_SELECT_POSITION - ) - // after selecting the storage try to copy/move the zim file. - showCopyMoveDialog() - } - - private fun showProgressDialog() { - val builder = AlertDialog.Builder(requireContext()) - val progressBar = ProgressBar(requireContext()) - progressBar.isIndeterminate = true - - builder.setTitle("Copying File") - builder.setMessage("Please wait...") - builder.setView(progressBar) - builder.setCancelable(false) - - progressBarDialog = builder.create() - progressBarDialog?.show() - } - - private fun dismissProgressDialog() { - if (progressBarDialog?.isShowing == true) { - progressBarDialog?.dismiss() - } - } - private fun getZimFileFromUri( uri: Uri ): File? { @@ -641,8 +486,8 @@ class LocalLibraryFragment : BaseFragment() { disposable.clear() storagePermissionLauncher?.unregister() storagePermissionLauncher = null - selectedFile = null - selectedFileUri = null + copyMoveFileHandler?.fileCopyMoveCallback = null + copyMoveFileHandler?.lifecycleScope = null } private fun sideEffects() = zimManageViewModel.sideEffects @@ -768,4 +613,50 @@ class LocalLibraryFragment : BaseFragment() { private fun readStorageHasBeenPermanentlyDenied(isPermissionGranted: Boolean) = !isPermissionGranted && !shouldShowRationalePermission() + + override fun onFileCopied(file: File) { + navigateToReaderFragment(file = file) + } + + override fun onFileMoved(file: File) { + navigateToReaderFragment(file = file) + } + + override fun onError(errorMessage: String) { + activity.toast("Unable to copy zim file $errorMessage", Toast.LENGTH_SHORT) + } + + override fun insufficientSpaceInStorage(availableSpace: Long) { + val message = """ + ${getString(string.move_no_space)} + ${getString(string.space_available)} ${Bytes(availableSpace).humanReadable} + """.trimIndent() + + fragmentDestinationLibraryBinding?.zimfilelist?.snack( + message, + requireActivity().findViewById(R.id.bottom_nav_view), + string.download_change_storage, + ::showStorageSelectDialog + ) + } + + private fun showStorageSelectDialog() = StorageSelectDialog() + .apply { + onSelectAction = ::storeDeviceInPreferences + } + .show(parentFragmentManager, getString(string.pref_storage)) + + private fun storeDeviceInPreferences( + storageDevice: StorageDevice + ) { + sharedPreferenceUtil.putPrefStorage( + sharedPreferenceUtil.getPublicDirectoryPath(storageDevice.name) + ) + sharedPreferenceUtil.putStoragePosition( + if (storageDevice.isInternal) INTERNAL_SELECT_POSITION + else EXTERNAL_SELECT_POSITION + ) + // after selecting the storage try to copy/move the zim file. + copyMoveFileHandler?.showMoveFileToPublicDirectoryDialog() + } } diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt index 9d15012f77..1241f61c35 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt @@ -21,7 +21,6 @@ package org.kiwix.kiwixmobile.nav.destination.library import android.Manifest import android.Manifest.permission.POST_NOTIFICATIONS import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager.PERMISSION_GRANTED import android.net.ConnectivityManager diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt new file mode 100644 index 0000000000..4bb28706b2 --- /dev/null +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -0,0 +1,230 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.nav.destination.reader + +import android.annotation.SuppressLint +import android.app.Activity +import android.net.Uri +import android.provider.DocumentsContract +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LifecycleCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.kiwix.kiwixmobile.R.id +import org.kiwix.kiwixmobile.R.layout +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.extensions.deleteFile +import org.kiwix.kiwixmobile.core.extensions.isFileExist +import org.kiwix.kiwixmobile.core.settings.StorageCalculator +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower +import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog +import java.io.File +import javax.inject.Inject + +class CopyMoveFileHandler @Inject constructor( + private val activity: Activity, + private val sharedPreferenceUtil: SharedPreferenceUtil, + private val alertDialogShower: AlertDialogShower, + private val storageCalculator: StorageCalculator +) { + var fileCopyMoveCallback: FileCopyMoveCallback? = null + private var selectedFileUri: Uri? = null + private var selectedFile: File? = null + private var progressBarDialog: AlertDialog? = null + var lifecycleScope: LifecycleCoroutineScope? = null + private var progressBar: ProgressBar? = null + private var progressBarTextView: TextView? = null + private var isCopySelected = false + + private val copyMoveTitle: String by lazy { + if (isCopySelected) { + activity.getString(R.string.file_copying_in_progress) + } else { + activity.getString(R.string.file_moving_in_progress) + } + } + + private fun updateProgress(progress: Int) { + progressBar?.post { + progressBarTextView?.text = activity.getString(R.string.percentage, progress) + progressBar?.setProgress(progress, true) + } + } + + fun showMoveFileToPublicDirectoryDialog(uri: Uri? = null, file: File? = null) { + uri?.let { selectedFileUri = it } + file?.let { selectedFile = it } + if (!sharedPreferenceUtil.copyMoveZimFilePermissionDialog) { + alertDialogShower.show( + KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, + { + sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true + showCopyMoveDialog() + } + ) + } else { + showCopyMoveDialog() + } + } + + private fun showCopyMoveDialog() { + val availableSpace = storageCalculator.availableBytes() + val fileSize = selectedFile?.length() ?: 0L + + if (availableSpace > fileSize) { + alertDialogShower.show( + KiwixDialog.CopyMoveFileToPublicDirectoryDialog, + { + isCopySelected = true + copyZimFileToPublicAppDirectory() + }, + { + isCopySelected = false + moveZimFileToPublicAppDirectory() + } + ) + } else { + fileCopyMoveCallback?.insufficientSpaceInStorage(availableSpace) + } + } + + private fun copyZimFileToPublicAppDirectory() { + lifecycleScope?.launch { + val destinationFile = getDestinationFile() + try { + selectedFileUri?.let { + showProgressDialog() + val destinationUri = Uri.fromFile(destinationFile) + copyFile(it, destinationUri) + withContext(Dispatchers.Main) { + dismissProgressDialog() + fileCopyMoveCallback?.onFileCopied(destinationFile) + } + } + } catch (ignore: Exception) { + dismissProgressDialog() + ignore.printStackTrace() + fileCopyMoveCallback?.onError("Unable to copy zim file ${ignore.message}") + .also { + // delete the temporary file if any error happens + destinationFile.deleteFile() + } + } + } + } + + private suspend fun copyFile(sourceUri: Uri, destinationUri: Uri) = withContext(Dispatchers.IO) { + val inputStream = activity.contentResolver.openInputStream(sourceUri) + val outputStream = activity.contentResolver.openOutputStream(destinationUri) + if (inputStream != null && outputStream != null) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalBytesCopied = 0L + val fileSize = selectedFile?.length() ?: 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesCopied += bytesRead + + // Update progress (on main thread) + withContext(Dispatchers.Main) { + @Suppress("MagicNumber") + val progress = (totalBytesCopied * 100 / fileSize).toInt() + updateProgress(progress) + } + } + + outputStream.flush() + inputStream.close() + outputStream.close() + } else { + @Suppress("TooGenericExceptionThrown") + throw Exception("Error accessing streams") + } + } + + private fun moveZimFileToPublicAppDirectory() { + lifecycleScope?.launch { + val destinationFile = getDestinationFile() + try { + selectedFileUri?.let { uri -> + showProgressDialog() + val destinationUri = Uri.fromFile(destinationFile) + copyFile(uri, destinationUri) + withContext(Dispatchers.Main) { + dismissProgressDialog() + fileCopyMoveCallback?.onFileMoved(destinationFile) + }.also { + DocumentsContract.deleteDocument(activity.applicationContext.contentResolver, uri) + } + } + } catch (ignore: Exception) { + dismissProgressDialog() + ignore.printStackTrace() + fileCopyMoveCallback?.onError("Unable to copy zim file ${ignore.message}") + .also { + // delete the temporary file if any error happens + destinationFile.deleteFile() + } + } + } + } + + private fun getDestinationFile(): File = + File("${sharedPreferenceUtil.prefStorage}/${selectedFile?.name}").also { + if (!it.isFileExist()) it.createNewFile() + } + + @SuppressLint("InflateParams") + private fun showProgressDialog() { + val dialogView = + activity.layoutInflater.inflate(layout.copy_move_progress_bar, null) + progressBar = + dialogView.findViewById(id.progressBar).apply { + isIndeterminate = false + } + progressBarTextView = + dialogView.findViewById(id.progressTextView) + val builder = AlertDialog.Builder(activity).apply { + setTitle(copyMoveTitle) + setView(dialogView) + setCancelable(false) + } + + progressBarDialog = builder.create() + progressBarDialog?.show() + } + + private fun dismissProgressDialog() { + if (progressBarDialog?.isShowing == true) { + progressBarDialog?.dismiss() + } + } + + interface FileCopyMoveCallback { + fun onFileCopied(file: File) + fun onFileMoved(file: File) + fun insufficientSpaceInStorage(availableSpace: Long) + fun onError(errorMessage: String) + } +} diff --git a/app/src/main/res/layout/copy_move_progress_bar.xml b/app/src/main/res/layout/copy_move_progress_bar.xml new file mode 100644 index 0000000000..2450afdce0 --- /dev/null +++ b/app/src/main/res/layout/copy_move_progress_bar.xml @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/SharedPreferenceUtil.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/SharedPreferenceUtil.kt index b432d3c4c9..323facf7a5 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/SharedPreferenceUtil.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/SharedPreferenceUtil.kt @@ -31,7 +31,6 @@ import org.kiwix.kiwixmobile.core.DarkModeConfig import org.kiwix.kiwixmobile.core.DarkModeConfig.Mode.Companion.from import org.kiwix.kiwixmobile.core.R import org.kiwix.kiwixmobile.core.extensions.isFileExist -import org.kiwix.kiwixmobile.core.utils.dialog.RateAppCounter import java.io.File import java.util.Locale import javax.inject.Inject diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 99e11790f5..c42fd48818 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -329,6 +329,8 @@ Understood Copy Move + Zim file coping in progress + Zim file moving in progress Do you want to copy or move the file? Move/Copy files to app public directory? Due to Google Play policies on Android 11 and above, our app can no longer directly access files stored elsewhere on your device. To let you view your selected files, we need to move or copy them into a special folder within our application directory. This allows us to access and open the files. Do you agree to this? From c88428dbbe7156c1a2b594306462ea99ea31c0aa Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Wed, 28 Aug 2024 14:54:04 +0530 Subject: [PATCH 03/11] Properly handling the fileSystemState when copy/move the files. --- .../library/LocalLibraryFragment.kt | 12 +- .../destination/reader/CopyMoveFileHandler.kt | 103 +++++++++++++++--- .../core/utils/dialog/AlertDialogShower.kt | 9 +- .../core/utils/dialog/KiwixDialog.kt | 9 ++ core/src/main/res/values/strings.xml | 1 + 5 files changed, 109 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt index 921d0a2841..d2be1b0caf 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt @@ -488,6 +488,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal storagePermissionLauncher = null copyMoveFileHandler?.fileCopyMoveCallback = null copyMoveFileHandler?.lifecycleScope = null + copyMoveFileHandler?.fileSystemDisposable?.dispose() } private fun sideEffects() = zimManageViewModel.sideEffects @@ -623,7 +624,12 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal } override fun onError(errorMessage: String) { - activity.toast("Unable to copy zim file $errorMessage", Toast.LENGTH_SHORT) + activity.toast(errorMessage) + } + + override fun filesystemDoesNotSupportedCopyMoveFilesOver4GB() { + val message = "Your fileSystem does not support files over 4GB" + showStorageSelectionSnackBar(message) } override fun insufficientSpaceInStorage(availableSpace: Long) { @@ -632,6 +638,10 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal ${getString(string.space_available)} ${Bytes(availableSpace).humanReadable} """.trimIndent() + showStorageSelectionSnackBar(message) + } + + private fun showStorageSelectionSnackBar(message: String) { fragmentDestinationLibraryBinding?.zimfilelist?.snack( message, requireActivity().findViewById(R.id.bottom_nav_view), diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt index 4bb28706b2..425ae95adc 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -20,12 +20,16 @@ package org.kiwix.kiwixmobile.nav.destination.reader import android.annotation.SuppressLint import android.app.Activity +import android.app.Dialog import android.net.Uri import android.provider.DocumentsContract +import android.view.View import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LifecycleCoroutineScope +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -38,6 +42,10 @@ import org.kiwix.kiwixmobile.core.settings.StorageCalculator import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog +import org.kiwix.kiwixmobile.zimManager.Fat32Checker +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.Companion.FOUR_GIGABYTES_IN_KILOBYTES +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.DetectingFileSystem import java.io.File import javax.inject.Inject @@ -45,16 +53,19 @@ class CopyMoveFileHandler @Inject constructor( private val activity: Activity, private val sharedPreferenceUtil: SharedPreferenceUtil, private val alertDialogShower: AlertDialogShower, - private val storageCalculator: StorageCalculator + private val storageCalculator: StorageCalculator, + private val fat32Checker: Fat32Checker ) { var fileCopyMoveCallback: FileCopyMoveCallback? = null private var selectedFileUri: Uri? = null private var selectedFile: File? = null + private var copyMovePreparingDialog: Dialog? = null private var progressBarDialog: AlertDialog? = null var lifecycleScope: LifecycleCoroutineScope? = null private var progressBar: ProgressBar? = null private var progressBarTextView: TextView? = null private var isCopySelected = false + var fileSystemDisposable: Disposable? = null private val copyMoveTitle: String by lazy { if (isCopySelected) { @@ -79,35 +90,72 @@ class CopyMoveFileHandler @Inject constructor( KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, { sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true - showCopyMoveDialog() + validateAndShowCopyMoveDialog() } ) } else { - showCopyMoveDialog() + validateAndShowCopyMoveDialog() } } - private fun showCopyMoveDialog() { + private fun isBookLessThan4GB(): Boolean = + (selectedFile?.length() ?: 0L) < FOUR_GIGABYTES_IN_KILOBYTES + + private fun validateAndShowCopyMoveDialog() { + hidePreparingCopyMoveDialog() // hide the dialog if already showing val availableSpace = storageCalculator.availableBytes() - val fileSize = selectedFile?.length() ?: 0L + if (hasNotSufficientStorageSpace(availableSpace)) { + fileCopyMoveCallback?.insufficientSpaceInStorage(availableSpace) + return + } + when (fat32Checker.fileSystemStates.value) { + DetectingFileSystem -> handleDetectingFileSystemState() + CannotWrite4GbFile -> handleCannotWrite4GbFileState() + else -> showCopyMoveDialog() + } + } - if (availableSpace > fileSize) { - alertDialogShower.show( - KiwixDialog.CopyMoveFileToPublicDirectoryDialog, - { - isCopySelected = true - copyZimFileToPublicAppDirectory() - }, - { - isCopySelected = false - moveZimFileToPublicAppDirectory() - } - ) + private fun handleDetectingFileSystemState() { + if (isBookLessThan4GB()) { + showCopyMoveDialog() } else { - fileCopyMoveCallback?.insufficientSpaceInStorage(availableSpace) + showPreparingCopyMoveDialog() + observeFileSystemState() } } + private fun handleCannotWrite4GbFileState() { + if (isBookLessThan4GB()) { + showCopyMoveDialog() + } else { + // Show an error dialog indicating the file system limitation + fileCopyMoveCallback?.filesystemDoesNotSupportedCopyMoveFilesOver4GB() + } + } + + private fun observeFileSystemState() { + if (fileSystemDisposable?.isDisposed == false) return + fileSystemDisposable = fat32Checker.fileSystemStates + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + validateAndShowCopyMoveDialog() + } + } + + private fun showCopyMoveDialog() { + alertDialogShower.show( + KiwixDialog.CopyMoveFileToPublicDirectoryDialog, + { + isCopySelected = true + copyZimFileToPublicAppDirectory() + }, + { + isCopySelected = false + moveZimFileToPublicAppDirectory() + } + ) + } + private fun copyZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() @@ -195,6 +243,24 @@ class CopyMoveFileHandler @Inject constructor( if (!it.isFileExist()) it.createNewFile() } + private fun hasNotSufficientStorageSpace(availableSpace: Long): Boolean = + availableSpace < (selectedFile?.length() ?: 0L) + + @SuppressLint("InflateParams") + private fun showPreparingCopyMoveDialog() { + if (copyMovePreparingDialog == null) { + val dialogView: View = + activity.layoutInflater.inflate(R.layout.item_custom_spinner, null) + copyMovePreparingDialog = + alertDialogShower.create(KiwixDialog.PreparingCopyingFilesDialog { dialogView }) + } + copyMovePreparingDialog?.show() + } + + private fun hidePreparingCopyMoveDialog() { + copyMovePreparingDialog?.dismiss() + } + @SuppressLint("InflateParams") private fun showProgressDialog() { val dialogView = @@ -225,6 +291,7 @@ class CopyMoveFileHandler @Inject constructor( fun onFileCopied(file: File) fun onFileMoved(file: File) fun insufficientSpaceInStorage(availableSpace: Long) + fun filesystemDoesNotSupportedCopyMoveFilesOver4GB() fun onError(errorMessage: String) } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt index 6beb47aa14..3b2b39b145 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/AlertDialogShower.kt @@ -56,21 +56,18 @@ class AlertDialogShower @Inject constructor(private val activity: Activity) : dialog.icon?.let(this::setIcon) dialog.message?.let { setMessage(activity.getString(it, *bodyArguments(dialog))) } - setPositiveButton(dialog.positiveMessage) { h, _ -> - h.dismiss() + setPositiveButton(dialog.positiveMessage) { _, _ -> clickListeners.getOrNull(0) ?.invoke() } dialog.negativeMessage?.let { - setNegativeButton(it) { h, _ -> - h.dismiss() + setNegativeButton(it) { _, _ -> clickListeners.getOrNull(1) ?.invoke() } } dialog.neutralMessage?.let { - setNeutralButton(it) { h, _ -> - h.dismiss() + setNeutralButton(it) { _, _ -> clickListeners.getOrNull(2) ?.invoke() } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt index 9ca2d3044d..cd27e0a2b9 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt @@ -260,6 +260,15 @@ sealed class KiwixDialog( getView = customGetView ) + data class PreparingCopyingFilesDialog(val customGetView: (() -> View)?) : KiwixDialog( + R.string.preparing_file_for_copy, + null, + R.string.empty_string, + null, + cancelable = false, + getView = customGetView + ) + object NotesDiscardConfirmation : KiwixDialog( null, R.string.confirmation_alert_dialog_message, diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c42fd48818..2972221cba 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -327,6 +327,7 @@ Zim files not showing? Due to Google Play policies on Android 11 and above, this Google Store app can\’t open sideloaded ZIM files. You can either download them through the app or, instead, install the full version of kiwix app from our official website %s Understood + Preparing for copy/move Copy Move Zim file coping in progress From 657783e09a2254c68b7f420142deb2329237d50c Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Wed, 28 Aug 2024 15:48:46 +0530 Subject: [PATCH 04/11] Fixed the memory leak on Android 13 and Android 14. --- .../org/kiwix/kiwixmobile/language/LanguageFragment.kt | 7 +++++-- .../nav/destination/library/LocalLibraryFragment.kt | 1 + .../org/kiwix/kiwixmobile/core/search/SearchFragment.kt | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt index f5175168d3..d68ad6d89b 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt @@ -70,6 +70,7 @@ class LanguageFragment : BaseFragment() { private val compositeDisposable = CompositeDisposable() private var activityLanguageBinding: ActivityLanguageBinding? = null + private var searchView: SearchView? = null private val languageAdapter = LanguageAdapter( @@ -127,8 +128,8 @@ class LanguageFragment : BaseFragment() { object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_language, menu) - val search = menu.findItem(R.id.menu_language_search).actionView as SearchView - search.apply { + searchView = menu.findItem(R.id.menu_language_search).actionView as SearchView + searchView?.apply { setUpSearchView(requireActivity()) setOnQueryTextListener( SimpleTextListener { query, _ -> @@ -172,6 +173,8 @@ class LanguageFragment : BaseFragment() { override fun onDestroyView() { super.onDestroyView() + searchView?.setOnQueryTextListener(null) + searchView = null activityLanguageBinding = null } } diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt index d2be1b0caf..d302496215 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt @@ -489,6 +489,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal copyMoveFileHandler?.fileCopyMoveCallback = null copyMoveFileHandler?.lifecycleScope = null copyMoveFileHandler?.fileSystemDisposable?.dispose() + copyMoveFileHandler = null } private fun sideEffects() = zimManageViewModel.sideEffects diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt index a6ab9f50fb..7ac7e11324 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt @@ -206,6 +206,7 @@ class SearchFragment : BaseFragment() { renderingJob?.cancel() renderingJob = null activity?.intent?.action = null + searchView?.setOnQueryTextListener(null) searchView = null searchInTextMenuItem = null findInPageTextView = null From 1e2eeabbf085a6a4172b620b0e025aa395772968 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Wed, 28 Aug 2024 21:42:26 +0530 Subject: [PATCH 05/11] Added the functionality to move zim files from another storage. --- .../destination/reader/CopyMoveFileHandler.kt | 100 +++++++++++++++--- core/src/main/res/values/strings.xml | 2 + 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt index 425ae95adc..f0182520f5 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -21,12 +21,14 @@ package org.kiwix.kiwixmobile.nav.destination.reader import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog +import android.content.ContentResolver import android.net.Uri import android.provider.DocumentsContract import android.view.View import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleCoroutineScope import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -42,6 +44,7 @@ import org.kiwix.kiwixmobile.core.settings.StorageCalculator import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog +import org.kiwix.kiwixmobile.core.utils.files.Log import org.kiwix.kiwixmobile.zimManager.Fat32Checker import org.kiwix.kiwixmobile.zimManager.Fat32Checker.Companion.FOUR_GIGABYTES_IN_KILOBYTES import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile @@ -171,12 +174,13 @@ class CopyMoveFileHandler @Inject constructor( } } catch (ignore: Exception) { dismissProgressDialog() - ignore.printStackTrace() - fileCopyMoveCallback?.onError("Unable to copy zim file ${ignore.message}") - .also { - // delete the temporary file if any error happens - destinationFile.deleteFile() - } + fileCopyMoveCallback?.onError( + activity.getString(R.string.copy_file_error_message, ignore.message) + ).also { + // delete the temporary file if any error happens + destinationFile.deleteFile() + ignore.printStackTrace() + } } } } @@ -211,6 +215,7 @@ class CopyMoveFileHandler @Inject constructor( } } + @Suppress("UnsafeCallOnNullableType") private fun moveZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() @@ -218,26 +223,91 @@ class CopyMoveFileHandler @Inject constructor( selectedFileUri?.let { uri -> showProgressDialog() val destinationUri = Uri.fromFile(destinationFile) - copyFile(uri, destinationUri) + if (isSameStorage()) { + // if we try to move the file in same storage. + // then simply move the document. + val contentResolver = activity.applicationContext.contentResolver + val parentDocumentUri = getParentDocumentUri(uri) + val destinationParentUri = + getParentDocumentUri(DocumentFile.fromFile(destinationFile).uri) + Log.e( + "AUTHORITY", + "moveZimFileToPublicAppDirectory: ${uri.authority} \n" + + "destination = $parentDocumentUri \n uri ${parentDocumentUri?.authority}" + + " after uri = ${destinationParentUri?.authority}" + ) + DocumentsContract.moveDocument( + contentResolver, + uri, + parentDocumentUri!!, + destinationParentUri!! + ) + } else { + // if we move the document in other storage, then copy the file to configured storage + // and delete the main file. + copyFile(uri, destinationUri) + DocumentsContract.deleteDocument(activity.applicationContext.contentResolver, uri) + } withContext(Dispatchers.Main) { dismissProgressDialog() fileCopyMoveCallback?.onFileMoved(destinationFile) - }.also { - DocumentsContract.deleteDocument(activity.applicationContext.contentResolver, uri) } } } catch (ignore: Exception) { dismissProgressDialog() - ignore.printStackTrace() - fileCopyMoveCallback?.onError("Unable to copy zim file ${ignore.message}") - .also { - // delete the temporary file if any error happens - destinationFile.deleteFile() - } + fileCopyMoveCallback?.onError( + activity.getString(R.string.move_file_error_message, ignore.message) + ).also { + // delete the temporary file if any error happens + destinationFile.deleteFile() + ignore.printStackTrace() + } } } } + private fun getParentDocumentUri(contentResolver: ContentResolver, documentUri: Uri): Uri? { + val cursor = contentResolver.query( + documentUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), + null, + null, + null + ) + + cursor?.use { + if (it.moveToFirst()) { + val documentId = it.getString(0) + val parentDocumentId = documentId.substringBeforeLast(':') + val parentDocumentUri = + DocumentsContract.buildDocumentUriUsingTree(documentUri, parentDocumentId) + return@getParentDocumentUri parentDocumentUri + } + } + return null + } + + private fun getParentDocumentUri(documentUri: Uri): Uri? { + val docId = DocumentsContract.getDocumentId(documentUri) + val pathSegments = docId.split(":") + val parentId = if (pathSegments.size > 1) pathSegments[1].substringBeforeLast("/") else "" + val parentDocId = "${pathSegments[0]}:$parentId" + + return if (parentId.isNotEmpty() && documentUri.authority != null) { + DocumentsContract.buildDocumentUri(documentUri.authority, parentDocId) + } else { + null + } + } + + private fun isSameStorage(): Boolean { + return selectedFile?.path?.contains( + sharedPreferenceUtil.prefStorage.substringBefore( + activity.getString(R.string.android_directory_seperator) + ) + ) == true + } + private fun getDestinationFile(): File = File("${sharedPreferenceUtil.prefStorage}/${selectedFile?.name}").also { if (!it.isFileExist()) it.createNewFile() diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 2972221cba..29bc11cb32 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -333,8 +333,10 @@ Zim file coping in progress Zim file moving in progress Do you want to copy or move the file? + Error in copying the zim file: %s. Move/Copy files to app public directory? Due to Google Play policies on Android 11 and above, our app can no longer directly access files stored elsewhere on your device. To let you view your selected files, we need to move or copy them into a special folder within our application directory. This allows us to access and open the files. Do you agree to this? + Error in moving the zim file: %s. How to update content? To update content (a zim file) you need to download the full latest version of this very same content. You can do that via the download section. All Files Permission Needed From 6a8c34b5659802202c5afce5026a566fed6f0523 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 29 Aug 2024 17:29:26 +0530 Subject: [PATCH 06/11] Improved the move functionality and copy functionality. --- .../destination/reader/CopyMoveFileHandler.kt | 233 +++++++++--------- 1 file changed, 117 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt index f0182520f5..d73b101a5d 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -21,14 +21,12 @@ package org.kiwix.kiwixmobile.nav.destination.reader import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog -import android.content.ContentResolver import android.net.Uri import android.provider.DocumentsContract import android.view.View import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog -import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleCoroutineScope import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -44,12 +42,12 @@ import org.kiwix.kiwixmobile.core.settings.StorageCalculator import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog -import org.kiwix.kiwixmobile.core.utils.files.Log import org.kiwix.kiwixmobile.zimManager.Fat32Checker import org.kiwix.kiwixmobile.zimManager.Fat32Checker.Companion.FOUR_GIGABYTES_IN_KILOBYTES import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.DetectingFileSystem import java.io.File +import java.io.FileNotFoundException import javax.inject.Inject class CopyMoveFileHandler @Inject constructor( @@ -67,14 +65,14 @@ class CopyMoveFileHandler @Inject constructor( var lifecycleScope: LifecycleCoroutineScope? = null private var progressBar: ProgressBar? = null private var progressBarTextView: TextView? = null - private var isCopySelected = false + private var isMoveOperation = false var fileSystemDisposable: Disposable? = null private val copyMoveTitle: String by lazy { - if (isCopySelected) { - activity.getString(R.string.file_copying_in_progress) - } else { + if (isMoveOperation) { activity.getString(R.string.file_moving_in_progress) + } else { + activity.getString(R.string.file_copying_in_progress) } } @@ -149,142 +147,104 @@ class CopyMoveFileHandler @Inject constructor( alertDialogShower.show( KiwixDialog.CopyMoveFileToPublicDirectoryDialog, { - isCopySelected = true - copyZimFileToPublicAppDirectory() + isMoveOperation = false + copyMoveZimFileToPublicAppDirectory() }, { - isCopySelected = false - moveZimFileToPublicAppDirectory() + isMoveOperation = true + copyMoveZimFileToPublicAppDirectory() } ) } - private fun copyZimFileToPublicAppDirectory() { + private fun copyMoveZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() try { + if (selectedFileUri == null) { + throw FileNotFoundException("Selected file not found") + } selectedFileUri?.let { showProgressDialog() val destinationUri = Uri.fromFile(destinationFile) copyFile(it, destinationUri) + if (isMoveOperation) { + // delete the source file after successfully moved. + deleteSourceFile(it) + } + // val contentResolver = activity.applicationContext.contentResolver + // val parentDocumentUri = getParentDocumentUri(it) + // val documentFile = DocumentFile.fromSingleUri(activity, it) + // val destinationParentUri = getContentUriFromFilePath(destinationFile.parentFile.path) + // Log.e( + // "AUTHORITY", + // "moveZimFileToPublicAppDirectory: ${it.authority} \n" + + // "destination = $parentDocumentUri \n uri ${parentDocumentUri?.authority}" + + // " after uri = ${destinationParentUri?.authority}" + // ) + // if (isSameStorage()) { + // // if we try to move the file in same storage. + // // then simply move the document. + // DocumentsContract.copyDocument( + // contentResolver, + // it, + // destinationParentUri!! + // ) + // } else { + // // if we move the document in other storage, then copy the file to configured storage + // // and delete the main file. + // copyFile(it, destinationUri) + // deleteSourceFile(it) + // } withContext(Dispatchers.Main) { - dismissProgressDialog() - fileCopyMoveCallback?.onFileCopied(destinationFile) + notifyFileOperationSuccess(destinationFile) } } } catch (ignore: Exception) { - dismissProgressDialog() - fileCopyMoveCallback?.onError( - activity.getString(R.string.copy_file_error_message, ignore.message) - ).also { - // delete the temporary file if any error happens - destinationFile.deleteFile() - ignore.printStackTrace() - } + ignore.printStackTrace() + handleFileOperationError(ignore.message, destinationFile) } } } - private suspend fun copyFile(sourceUri: Uri, destinationUri: Uri) = withContext(Dispatchers.IO) { - val inputStream = activity.contentResolver.openInputStream(sourceUri) - val outputStream = activity.contentResolver.openOutputStream(destinationUri) - if (inputStream != null && outputStream != null) { - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytesRead: Int - var totalBytesCopied = 0L - val fileSize = selectedFile?.length() ?: 0 - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesCopied += bytesRead - - // Update progress (on main thread) - withContext(Dispatchers.Main) { - @Suppress("MagicNumber") - val progress = (totalBytesCopied * 100 / fileSize).toInt() - updateProgress(progress) - } - } + // private fun getContentUriFromFilePath(filePath: String): Uri? { + // val basePath = Environment.getExternalStorageDirectory().absolutePath + // val relativePath = filePath.removePrefix(basePath).removePrefix("/") + // val documentId = "primary:$relativePath" + // return Uri.parse("content://com.android.externalstorage.documents/document/$documentId") + // } - outputStream.flush() - inputStream.close() - outputStream.close() + private fun handleFileOperationError( + errorMessage: String?, + destinationFile: File + ) { + dismissProgressDialog() + val userFriendlyMessage = if (isMoveOperation) { + activity.getString(R.string.move_file_error_message, errorMessage) } else { - @Suppress("TooGenericExceptionThrown") - throw Exception("Error accessing streams") + activity.getString(R.string.copy_file_error_message, errorMessage) + } + fileCopyMoveCallback?.onError(userFriendlyMessage).also { + // Clean up the destination file if an error occurs + destinationFile.deleteFile() } } - @Suppress("UnsafeCallOnNullableType") - private fun moveZimFileToPublicAppDirectory() { - lifecycleScope?.launch { - val destinationFile = getDestinationFile() - try { - selectedFileUri?.let { uri -> - showProgressDialog() - val destinationUri = Uri.fromFile(destinationFile) - if (isSameStorage()) { - // if we try to move the file in same storage. - // then simply move the document. - val contentResolver = activity.applicationContext.contentResolver - val parentDocumentUri = getParentDocumentUri(uri) - val destinationParentUri = - getParentDocumentUri(DocumentFile.fromFile(destinationFile).uri) - Log.e( - "AUTHORITY", - "moveZimFileToPublicAppDirectory: ${uri.authority} \n" + - "destination = $parentDocumentUri \n uri ${parentDocumentUri?.authority}" + - " after uri = ${destinationParentUri?.authority}" - ) - DocumentsContract.moveDocument( - contentResolver, - uri, - parentDocumentUri!!, - destinationParentUri!! - ) - } else { - // if we move the document in other storage, then copy the file to configured storage - // and delete the main file. - copyFile(uri, destinationUri) - DocumentsContract.deleteDocument(activity.applicationContext.contentResolver, uri) - } - withContext(Dispatchers.Main) { - dismissProgressDialog() - fileCopyMoveCallback?.onFileMoved(destinationFile) - } - } - } catch (ignore: Exception) { - dismissProgressDialog() - fileCopyMoveCallback?.onError( - activity.getString(R.string.move_file_error_message, ignore.message) - ).also { - // delete the temporary file if any error happens - destinationFile.deleteFile() - ignore.printStackTrace() - } - } + private fun notifyFileOperationSuccess(destinationFile: File) { + dismissProgressDialog() + if (isMoveOperation) { + fileCopyMoveCallback?.onFileMoved(destinationFile) + } else { + fileCopyMoveCallback?.onFileCopied(destinationFile) } } - private fun getParentDocumentUri(contentResolver: ContentResolver, documentUri: Uri): Uri? { - val cursor = contentResolver.query( - documentUri, - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), - null, - null, - null - ) - - cursor?.use { - if (it.moveToFirst()) { - val documentId = it.getString(0) - val parentDocumentId = documentId.substringBeforeLast(':') - val parentDocumentUri = - DocumentsContract.buildDocumentUriUsingTree(documentUri, parentDocumentId) - return@getParentDocumentUri parentDocumentUri - } + private fun deleteSourceFile(uri: Uri) { + try { + DocumentsContract.deleteDocument(activity.applicationContext.contentResolver, uri) + } catch (ignore: Exception) { + ignore.printStackTrace() } - return null } private fun getParentDocumentUri(documentUri: Uri): Uri? { @@ -308,10 +268,51 @@ class CopyMoveFileHandler @Inject constructor( ) == true } - private fun getDestinationFile(): File = - File("${sharedPreferenceUtil.prefStorage}/${selectedFile?.name}").also { - if (!it.isFileExist()) it.createNewFile() + private suspend fun copyFile(sourceUri: Uri, destinationUri: Uri) = withContext(Dispatchers.IO) { + val inputStream = activity.contentResolver.openInputStream(sourceUri) + val outputStream = activity.contentResolver.openOutputStream(destinationUri) + if (inputStream != null && outputStream != null) { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalBytesCopied = 0L + val fileSize = selectedFile?.length() ?: 0 + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesCopied += bytesRead + + // Update progress (on main thread) + withContext(Dispatchers.Main) { + @Suppress("MagicNumber") + val progress = (totalBytesCopied * 100 / fileSize).toInt() + updateProgress(progress) + } + } + + outputStream.flush() + inputStream.close() + outputStream.close() + } else { + throw FileNotFoundException("The selected zim file could not open") } + } + + private fun getDestinationFile(): File { + val root = File(sharedPreferenceUtil.prefStorage) + val fileName = selectedFile?.name ?: "" + + val destinationFile = sequence { + yield(File(root, fileName)) + yieldAll( + generateSequence(1) { it + 1 }.map { + File(root, fileName.replace(".", "_$it.")) + } + ) + }.first { !it.isFileExist() } + + destinationFile.createNewFile() + return destinationFile + } private fun hasNotSufficientStorageSpace(availableSpace: Long): Boolean = availableSpace < (selectedFile?.length() ?: 0L) From e8856c1cbe80c343117e2e054b3477857c8928a6 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Fri, 30 Aug 2024 18:49:55 +0530 Subject: [PATCH 07/11] Implemented move functionality with fallbacks: If the DocumentsContract API fails to move the document, a fallback method will copy the source file and delete the original file, ensuring a smooth user experience. * Improved file copying performance: To enhance copy speed, we now use FileChannel for copying files, which is faster than the standard InputStream operation. --- .../destination/reader/CopyMoveFileHandler.kt | 206 ++++++++++-------- 1 file changed, 115 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt index d73b101a5d..bdded98e58 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -21,12 +21,14 @@ package org.kiwix.kiwixmobile.nav.destination.reader import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog +import android.content.ContentResolver import android.net.Uri import android.provider.DocumentsContract import android.view.View import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleCoroutineScope import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -47,7 +49,9 @@ import org.kiwix.kiwixmobile.zimManager.Fat32Checker.Companion.FOUR_GIGABYTES_IN import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.DetectingFileSystem import java.io.File +import java.io.FileInputStream import java.io.FileNotFoundException +import java.io.FileOutputStream import javax.inject.Inject class CopyMoveFileHandler @Inject constructor( @@ -148,56 +152,53 @@ class CopyMoveFileHandler @Inject constructor( KiwixDialog.CopyMoveFileToPublicDirectoryDialog, { isMoveOperation = false - copyMoveZimFileToPublicAppDirectory() + copyZimFileToPublicAppDirectory() }, { isMoveOperation = true - copyMoveZimFileToPublicAppDirectory() + moveZimFileToPublicAppDirectory() } ) } - private fun copyMoveZimFileToPublicAppDirectory() { + private fun copyZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() try { - if (selectedFileUri == null) { - throw FileNotFoundException("Selected file not found") + val sourceUri = selectedFileUri ?: throw FileNotFoundException("Selected file not found") + showProgressDialog() + copyFile(sourceUri, destinationFile) + withContext(Dispatchers.Main) { + notifyFileOperationSuccess(destinationFile) } - selectedFileUri?.let { - showProgressDialog() - val destinationUri = Uri.fromFile(destinationFile) - copyFile(it, destinationUri) - if (isMoveOperation) { - // delete the source file after successfully moved. - deleteSourceFile(it) - } - // val contentResolver = activity.applicationContext.contentResolver - // val parentDocumentUri = getParentDocumentUri(it) - // val documentFile = DocumentFile.fromSingleUri(activity, it) - // val destinationParentUri = getContentUriFromFilePath(destinationFile.parentFile.path) - // Log.e( - // "AUTHORITY", - // "moveZimFileToPublicAppDirectory: ${it.authority} \n" + - // "destination = $parentDocumentUri \n uri ${parentDocumentUri?.authority}" + - // " after uri = ${destinationParentUri?.authority}" - // ) - // if (isSameStorage()) { - // // if we try to move the file in same storage. - // // then simply move the document. - // DocumentsContract.copyDocument( - // contentResolver, - // it, - // destinationParentUri!! - // ) - // } else { - // // if we move the document in other storage, then copy the file to configured storage - // // and delete the main file. - // copyFile(it, destinationUri) - // deleteSourceFile(it) - // } - withContext(Dispatchers.Main) { + } catch (ignore: Exception) { + ignore.printStackTrace() + handleFileOperationError(ignore.message, destinationFile) + } + } + } + + private fun moveZimFileToPublicAppDirectory() { + lifecycleScope?.launch { + val destinationFile = getDestinationFile() + try { + val sourceUri = selectedFileUri ?: throw FileNotFoundException("Selected file not found") + showProgressDialog() + var moveSuccess = false + if (tryMoveWithRenamingFile(destinationFile)) { + moveSuccess = true + } else if (tryMoveWithDocumentContract(sourceUri)) { + moveSuccess = true + } else { + moveSuccess = true + copyFile(sourceUri, destinationFile) + deleteSourceFile(sourceUri) + } + withContext(Dispatchers.Main) { + if (moveSuccess) { notifyFileOperationSuccess(destinationFile) + } else { + handleFileOperationError("File move failed", destinationFile) } } } catch (ignore: Exception) { @@ -207,12 +208,50 @@ class CopyMoveFileHandler @Inject constructor( } } - // private fun getContentUriFromFilePath(filePath: String): Uri? { - // val basePath = Environment.getExternalStorageDirectory().absolutePath - // val relativePath = filePath.removePrefix(basePath).removePrefix("/") - // val documentId = "primary:$relativePath" - // return Uri.parse("content://com.android.externalstorage.documents/document/$documentId") - // } + private fun tryMoveWithRenamingFile(destinationFile: File): Boolean = + selectedFile?.renameTo(destinationFile) == true + + @Suppress("UnsafeCallOnNullableType") + private fun tryMoveWithDocumentContract(selectedUri: Uri): Boolean { + return try { + val contentResolver = activity.contentResolver + if (documentCanMove(selectedUri, contentResolver)) { + val sourceParentFolderUri = DocumentFile.fromFile(selectedFile?.parentFile!!).uri + val destinationFolderUri = DocumentFile.fromFile(File(sharedPreferenceUtil.prefStorage)).uri + + DocumentsContract.moveDocument( + contentResolver, + selectedUri, + sourceParentFolderUri, + destinationFolderUri + ) + true + } else { + false + } + } catch (ignore: Exception) { + ignore.printStackTrace() + false + } + } + + private fun documentCanMove(uri: Uri, contentResolver: ContentResolver): Boolean { + if (!DocumentsContract.isDocumentUri(activity, uri)) return false + + val flags = + contentResolver.query( + uri, + arrayOf(DocumentsContract.Document.COLUMN_FLAGS), + null, + null, + null + ) + ?.use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } ?: 0 + + return flags and DocumentsContract.Document.FLAG_SUPPORTS_MOVE != 0 + } private fun handleFileOperationError( errorMessage: String?, @@ -247,55 +286,40 @@ class CopyMoveFileHandler @Inject constructor( } } - private fun getParentDocumentUri(documentUri: Uri): Uri? { - val docId = DocumentsContract.getDocumentId(documentUri) - val pathSegments = docId.split(":") - val parentId = if (pathSegments.size > 1) pathSegments[1].substringBeforeLast("/") else "" - val parentDocId = "${pathSegments[0]}:$parentId" - - return if (parentId.isNotEmpty() && documentUri.authority != null) { - DocumentsContract.buildDocumentUri(documentUri.authority, parentDocId) - } else { - null - } - } - - private fun isSameStorage(): Boolean { - return selectedFile?.path?.contains( - sharedPreferenceUtil.prefStorage.substringBefore( - activity.getString(R.string.android_directory_seperator) - ) - ) == true - } - - private suspend fun copyFile(sourceUri: Uri, destinationUri: Uri) = withContext(Dispatchers.IO) { - val inputStream = activity.contentResolver.openInputStream(sourceUri) - val outputStream = activity.contentResolver.openOutputStream(destinationUri) - if (inputStream != null && outputStream != null) { - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytesRead: Int - var totalBytesCopied = 0L - val fileSize = selectedFile?.length() ?: 0 - - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - totalBytesCopied += bytesRead - - // Update progress (on main thread) - withContext(Dispatchers.Main) { - @Suppress("MagicNumber") - val progress = (totalBytesCopied * 100 / fileSize).toInt() - updateProgress(progress) + @Suppress("MagicNumber") + private suspend fun copyFile(sourceUri: Uri, destinationFile: File) = + withContext(Dispatchers.IO) { + val contentResolver = activity.contentResolver + + // Open the ParcelFileDescriptor from the Uri + val parcelFileDescriptor = contentResolver.openFileDescriptor(sourceUri, "r") + val fileSize = + parcelFileDescriptor?.fileDescriptor?.let { FileInputStream(it).channel.size() } ?: 0L + var totalBytesTransferred = 0L + + parcelFileDescriptor?.use { pfd -> + val sourceFd = pfd.fileDescriptor + FileInputStream(sourceFd).channel.use { sourceChannel -> + FileOutputStream(destinationFile).channel.use { destinationChannel -> + var bytesTransferred: Long + val bufferSize = 1024 * 1024 + while (totalBytesTransferred < fileSize) { + // Transfer data from source to destination in chunks + bytesTransferred = sourceChannel.transferTo( + totalBytesTransferred, + bufferSize.toLong(), + destinationChannel + ) + totalBytesTransferred += bytesTransferred + val progress = (totalBytesTransferred * 100 / fileSize).toInt() + withContext(Dispatchers.Main) { + updateProgress(progress) + } + } + } } - } - - outputStream.flush() - inputStream.close() - outputStream.close() - } else { - throw FileNotFoundException("The selected zim file could not open") + } ?: throw FileNotFoundException("The selected file could not be opened") } - } private fun getDestinationFile(): File { val root = File(sharedPreferenceUtil.prefStorage) From bba2b1d95fcb2b65dbd5ff4f17714e0bcf626781 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 2 Sep 2024 12:08:50 +0530 Subject: [PATCH 08/11] Fixed some memory leaks in SearchFragment, LanguageFragment, and KiwixPrefsFragment. --- .../settings/KiwixSettingsFragmentTest.kt | 1 + .../kiwixmobile/language/LanguageFragment.kt | 10 ++++------ .../kiwixmobile/settings/KiwixPrefsFragment.kt | 1 + .../kiwixmobile/core/search/SearchFragment.kt | 16 ++++++++++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/settings/KiwixSettingsFragmentTest.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/settings/KiwixSettingsFragmentTest.kt index afaa28c01e..0dfb9cb75a 100644 --- a/app/src/androidTest/java/org/kiwix/kiwixmobile/settings/KiwixSettingsFragmentTest.kt +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/settings/KiwixSettingsFragmentTest.kt @@ -81,6 +81,7 @@ class KiwixSettingsFragmentTest { "en", SharedPreferenceUtil(it).apply { setIsPlayStoreBuildType(true) + playStoreRestrictionPermissionDialog = false } ) it.navigate(R.id.introFragment) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt index d68ad6d89b..0a63e23bf8 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/language/LanguageFragment.kt @@ -142,8 +142,8 @@ class LanguageFragment : BaseFragment() { override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.menu_language_save -> { - languageViewModel.actions.offer(Action.SaveAll) closeKeyboard() + languageViewModel.actions.offer(Action.SaveAll) true } @@ -156,11 +156,6 @@ class LanguageFragment : BaseFragment() { ) } - override fun onDestroy() { - super.onDestroy() - compositeDisposable.clear() - } - private fun render(state: State) = when (state) { Loading -> activityLanguageBinding?.languageProgressbar?.show() is Content -> { @@ -173,8 +168,11 @@ class LanguageFragment : BaseFragment() { override fun onDestroyView() { super.onDestroyView() + compositeDisposable.clear() + activityLanguageBinding?.root?.removeAllViews() searchView?.setOnQueryTextListener(null) searchView = null + activityLanguageBinding?.languageRecyclerView?.adapter = null activityLanguageBinding = null } } diff --git a/app/src/main/java/org/kiwix/kiwixmobile/settings/KiwixPrefsFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/settings/KiwixPrefsFragment.kt index 87926c4d07..8b8aff5077 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/settings/KiwixPrefsFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/settings/KiwixPrefsFragment.kt @@ -177,6 +177,7 @@ class KiwixPrefsFragment : CorePrefsFragment() { override fun onDestroyView() { storageDisposable?.dispose() + storageDisposable = null super.onDestroyView() } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt index 7ac7e11324..50c8e403c1 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt @@ -52,6 +52,7 @@ import org.kiwix.kiwixmobile.core.base.BaseActivity import org.kiwix.kiwixmobile.core.base.BaseFragment import org.kiwix.kiwixmobile.core.databinding.FragmentSearchBinding import org.kiwix.kiwixmobile.core.extensions.ActivityExtensions.cachedComponent +import org.kiwix.kiwixmobile.core.extensions.closeKeyboard import org.kiwix.kiwixmobile.core.extensions.coreMainActivity import org.kiwix.kiwixmobile.core.extensions.setUpSearchView import org.kiwix.kiwixmobile.core.extensions.viewModel @@ -87,6 +88,7 @@ class SearchFragment : BaseFragment() { private var searchView: SearchView? = null private var searchInTextMenuItem: MenuItem? = null + private var searchMenuItem: MenuItem? = null private var findInPageTextView: TextView? = null private var fragmentSearchBinding: FragmentSearchBinding? = null @@ -210,7 +212,11 @@ class SearchFragment : BaseFragment() { searchView = null searchInTextMenuItem = null findInPageTextView = null + searchMenuItem?.setOnActionExpandListener(null) + searchMenuItem = null + fragmentSearchBinding?.searchList?.adapter = null searchAdapter = null + fragmentSearchBinding?.root?.removeAllViews() fragmentSearchBinding = null } @@ -224,9 +230,9 @@ class SearchFragment : BaseFragment() { object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_search, menu) - val searchMenuItem = menu.findItem(R.id.menu_search) - searchMenuItem.expandActionView() - searchView = searchMenuItem.actionView as SearchView + searchMenuItem = menu.findItem(R.id.menu_search) + searchMenuItem?.expandActionView() + searchView = searchMenuItem?.actionView as SearchView searchView?.apply { setUpSearchView(requireActivity()) searchView?.setOnQueryTextListener( @@ -249,7 +255,7 @@ class SearchFragment : BaseFragment() { ) } - searchMenuItem.setOnActionExpandListener(object : OnActionExpandListener { + searchMenuItem?.setOnActionExpandListener(object : OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem) = false override fun onMenuItemActionCollapse(item: MenuItem): Boolean { @@ -343,10 +349,12 @@ class SearchFragment : BaseFragment() { } private fun onItemClick(it: SearchListItem) { + closeKeyboard() searchViewModel.actions.trySend(OnItemClick(it)).isSuccess } private fun onItemClickNewTab(it: SearchListItem) { + closeKeyboard() searchViewModel.actions.trySend(OnOpenInNewTabClick(it)).isSuccess } From 1d0c0422fedcadf41aa32378587151465c5199b8 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 2 Sep 2024 15:11:22 +0530 Subject: [PATCH 09/11] Added copy, move, cancel options in permission dialog. * Using the error message from string file instead of a hardcoded string. --- .../library/LocalLibraryFragment.kt | 3 +- .../destination/reader/CopyMoveFileHandler.kt | 75 +++++++++++++------ .../core/utils/dialog/KiwixDialog.kt | 5 +- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt index d302496215..1af021240f 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt @@ -629,8 +629,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal } override fun filesystemDoesNotSupportedCopyMoveFilesOver4GB() { - val message = "Your fileSystem does not support files over 4GB" - showStorageSelectionSnackBar(message) + showStorageSelectionSnackBar(getString(R.string.file_system_does_not_support_4gb)) } override fun insufficientSpaceInStorage(availableSpace: Long) { diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt index bdded98e58..6de4663dca 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -91,32 +91,54 @@ class CopyMoveFileHandler @Inject constructor( uri?.let { selectedFileUri = it } file?.let { selectedFile = it } if (!sharedPreferenceUtil.copyMoveZimFilePermissionDialog) { - alertDialogShower.show( - KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, - { - sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true - validateAndShowCopyMoveDialog() - } - ) + showMoveToPublicDirectoryPermissionDialog() } else { - validateAndShowCopyMoveDialog() + if (validateZimFileCanCopyOrMove()) { + showCopyMoveDialog() + } } } + private fun showMoveToPublicDirectoryPermissionDialog() { + alertDialogShower.show( + KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, + { + sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true + if (validateZimFileCanCopyOrMove()) { + performCopyOperation() + } + }, + { + sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true + if (validateZimFileCanCopyOrMove()) { + performMoveOperation() + } + } + ) + } + private fun isBookLessThan4GB(): Boolean = (selectedFile?.length() ?: 0L) < FOUR_GIGABYTES_IN_KILOBYTES - private fun validateAndShowCopyMoveDialog() { + private fun validateZimFileCanCopyOrMove(): Boolean { hidePreparingCopyMoveDialog() // hide the dialog if already showing val availableSpace = storageCalculator.availableBytes() if (hasNotSufficientStorageSpace(availableSpace)) { fileCopyMoveCallback?.insufficientSpaceInStorage(availableSpace) - return + return false } - when (fat32Checker.fileSystemStates.value) { - DetectingFileSystem -> handleDetectingFileSystemState() - CannotWrite4GbFile -> handleCannotWrite4GbFileState() - else -> showCopyMoveDialog() + return when (fat32Checker.fileSystemStates.value) { + DetectingFileSystem -> { + handleDetectingFileSystemState() + false + } + + CannotWrite4GbFile -> { + handleCannotWrite4GbFileState() + false + } + + else -> true } } @@ -143,24 +165,31 @@ class CopyMoveFileHandler @Inject constructor( fileSystemDisposable = fat32Checker.fileSystemStates .observeOn(AndroidSchedulers.mainThread()) .subscribe { - validateAndShowCopyMoveDialog() + hidePreparingCopyMoveDialog() + if (validateZimFileCanCopyOrMove()) { + showCopyMoveDialog() + } } } private fun showCopyMoveDialog() { alertDialogShower.show( KiwixDialog.CopyMoveFileToPublicDirectoryDialog, - { - isMoveOperation = false - copyZimFileToPublicAppDirectory() - }, - { - isMoveOperation = true - moveZimFileToPublicAppDirectory() - } + ::performCopyOperation, + ::performMoveOperation ) } + private fun performCopyOperation() { + isMoveOperation = false + copyZimFileToPublicAppDirectory() + } + + private fun performMoveOperation() { + isMoveOperation = true + moveZimFileToPublicAppDirectory() + } + private fun copyZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt index cd27e0a2b9..1897f7521e 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt @@ -112,8 +112,9 @@ sealed class KiwixDialog( data object MoveFileToPublicDirectoryPermissionDialog : KiwixDialog( R.string.move_files_permission_dialog_title, R.string.move_files_permission_dialog_description, - R.string.yes, - R.string.no, + R.string.copy, + R.string.move, + neutralMessage = R.string.cancel, cancelable = false ) From d5013674ab4351b991cf3f21e56cbcd04e6bcf58 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 2 Sep 2024 19:02:08 +0530 Subject: [PATCH 10/11] Added test cases for CopyMoveFileHandler. --- app/build.gradle.kts | 1 + .../destination/reader/CopyMoveFileHandler.kt | 27 +- .../reader/CopyMoveFileHandlerTest.kt | 253 ++++++++++++++++++ 3 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae41837ad3..aa4308075f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,6 +95,7 @@ play { dependencies { androidTestImplementation(Libs.leakcanary_android_instrumentation) + testImplementation(Libs.kotlinx_coroutines_test) } task("generateVersionCodeAndName") { val file = File("VERSION_INFO") diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt index 6de4663dca..8f5a73bbe9 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt @@ -29,9 +29,9 @@ import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.LifecycleCoroutineScope import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -65,8 +65,8 @@ class CopyMoveFileHandler @Inject constructor( private var selectedFileUri: Uri? = null private var selectedFile: File? = null private var copyMovePreparingDialog: Dialog? = null - private var progressBarDialog: AlertDialog? = null - var lifecycleScope: LifecycleCoroutineScope? = null + var progressBarDialog: AlertDialog? = null + var lifecycleScope: CoroutineScope? = null private var progressBar: ProgressBar? = null private var progressBarTextView: TextView? = null private var isMoveOperation = false @@ -88,8 +88,7 @@ class CopyMoveFileHandler @Inject constructor( } fun showMoveFileToPublicDirectoryDialog(uri: Uri? = null, file: File? = null) { - uri?.let { selectedFileUri = it } - file?.let { selectedFile = it } + setSelectedFileAndUri(uri, file) if (!sharedPreferenceUtil.copyMoveZimFilePermissionDialog) { showMoveToPublicDirectoryPermissionDialog() } else { @@ -99,6 +98,11 @@ class CopyMoveFileHandler @Inject constructor( } } + fun setSelectedFileAndUri(uri: Uri?, file: File?) { + selectedFileUri = uri + selectedFile = file + } + private fun showMoveToPublicDirectoryPermissionDialog() { alertDialogShower.show( KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, @@ -120,9 +124,9 @@ class CopyMoveFileHandler @Inject constructor( private fun isBookLessThan4GB(): Boolean = (selectedFile?.length() ?: 0L) < FOUR_GIGABYTES_IN_KILOBYTES - private fun validateZimFileCanCopyOrMove(): Boolean { + fun validateZimFileCanCopyOrMove(file: File = File(sharedPreferenceUtil.prefStorage)): Boolean { hidePreparingCopyMoveDialog() // hide the dialog if already showing - val availableSpace = storageCalculator.availableBytes() + val availableSpace = storageCalculator.availableBytes(file) if (hasNotSufficientStorageSpace(availableSpace)) { fileCopyMoveCallback?.insufficientSpaceInStorage(availableSpace) return false @@ -190,7 +194,7 @@ class CopyMoveFileHandler @Inject constructor( moveZimFileToPublicAppDirectory() } - private fun copyZimFileToPublicAppDirectory() { + fun copyZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() try { @@ -316,7 +320,7 @@ class CopyMoveFileHandler @Inject constructor( } @Suppress("MagicNumber") - private suspend fun copyFile(sourceUri: Uri, destinationFile: File) = + suspend fun copyFile(sourceUri: Uri, destinationFile: File) = withContext(Dispatchers.IO) { val contentResolver = activity.contentResolver @@ -350,8 +354,7 @@ class CopyMoveFileHandler @Inject constructor( } ?: throw FileNotFoundException("The selected file could not be opened") } - private fun getDestinationFile(): File { - val root = File(sharedPreferenceUtil.prefStorage) + fun getDestinationFile(root: File = File(sharedPreferenceUtil.prefStorage)): File { val fileName = selectedFile?.name ?: "" val destinationFile = sequence { @@ -386,7 +389,7 @@ class CopyMoveFileHandler @Inject constructor( } @SuppressLint("InflateParams") - private fun showProgressDialog() { + fun showProgressDialog() { val dialogView = activity.layoutInflater.inflate(layout.copy_move_progress_bar, null) progressBar = diff --git a/app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt b/app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt new file mode 100644 index 0000000000..d24af3dc98 --- /dev/null +++ b/app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt @@ -0,0 +1,253 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.reader + +import android.app.Activity +import android.app.AlertDialog +import android.content.ContentResolver +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.kiwix.kiwixmobile.R +import org.kiwix.kiwixmobile.core.settings.StorageCalculator +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower +import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog +import org.kiwix.kiwixmobile.nav.destination.reader.CopyMoveFileHandler +import org.kiwix.kiwixmobile.nav.destination.reader.CopyMoveFileHandler.FileCopyMoveCallback +import org.kiwix.kiwixmobile.zimManager.Fat32Checker +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.DetectingFileSystem +import java.io.File +import java.io.FileDescriptor +import java.io.FileNotFoundException + +class CopyMoveFileHandlerTest { + private lateinit var fileHandler: CopyMoveFileHandler + + private val activity: Activity = mockk(relaxed = true) + private val sharedPreferenceUtil: SharedPreferenceUtil = mockk(relaxed = true) + private val alertDialogShower: AlertDialogShower = mockk(relaxed = true) + private val storageCalculator: StorageCalculator = mockk(relaxed = true) + private val fat32Checker: Fat32Checker = mockk(relaxed = true) + private val fileCopyMoveCallback: FileCopyMoveCallback = mockk(relaxed = true) + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val progressBarDialog: AlertDialog = mockk(relaxed = true) + private val destinationFile: File = mockk(relaxed = true) + private val parcelFileDescriptor: ParcelFileDescriptor = mockk(relaxed = true) + private val storageFile: File = mockk(relaxed = true) + private val selectedFile: File = mockk(relaxed = true) + private val storagePath = "storage/0/emulated/Android/media/org.kiwix.kiwixmobile" + + @BeforeEach + fun setup() { + fileHandler = CopyMoveFileHandler( + activity, + sharedPreferenceUtil, + alertDialogShower, + storageCalculator, + fat32Checker + ).apply { + setSelectedFileAndUri(null, selectedFile) + lifecycleScope = testScope + this.fileCopyMoveCallback = this@CopyMoveFileHandlerTest.fileCopyMoveCallback + } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnTrueWhenSufficientSpaceAndValidFileSystem() { + every { storageFile.exists() } returns true + every { storageFile.freeSpace } returns 1000L + every { storageFile.path } returns storagePath + every { selectedFile.length() } returns 100L + every { storageCalculator.availableBytes(storageFile) } returns 1000L + every { fat32Checker.fileSystemStates.value } returns CanWrite4GbFile + + val result = fileHandler.validateZimFileCanCopyOrMove(storageFile) + + assertTrue(result) + // check insufficientSpaceInStorage callback should not call. + verify(exactly = 0) { fileCopyMoveCallback.insufficientSpaceInStorage(any()) } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnFalseAndCallCallbackWhenInsufficientSpace() { + every { selectedFile.length() } returns 2000L + every { storageFile.exists() } returns true + every { storageFile.freeSpace } returns 1000L + every { storageFile.path } returns storagePath + every { storageCalculator.availableBytes(storageFile) } returns 1000L + every { fat32Checker.fileSystemStates.value } returns CanWrite4GbFile + + val result = fileHandler.validateZimFileCanCopyOrMove() + + assertFalse(result) + verify { fileCopyMoveCallback.insufficientSpaceInStorage(any()) } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnFalseWhenDetectingAndCanNotWrite4GBFiles() { + every { selectedFile.length() } returns 1000L + every { storageFile.exists() } returns true + every { storageFile.freeSpace } returns 2000L + every { storageFile.path } returns storagePath + every { storageCalculator.availableBytes(storageFile) } returns 2000L + every { fat32Checker.fileSystemStates.value } returns DetectingFileSystem + + // check when detecting the fileSystem + assertFalse(fileHandler.validateZimFileCanCopyOrMove()) + + every { fat32Checker.fileSystemStates.value } returns CannotWrite4GbFile + + // check when Can not write 4GB files on the fileSystem + assertFalse(fileHandler.validateZimFileCanCopyOrMove()) + } + + @Test + fun showMoveToPublicDirectoryPermissionDialogShouldShowPermissionDialogAtFirstLaunch() { + every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns false + every { alertDialogShower.show(any(), any(), any()) } just Runs + fileHandler.showMoveFileToPublicDirectoryDialog() + + verify { + alertDialogShower.show( + KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, + any(), + any() + ) + } + } + + @Test + fun showProgressDialogShouldDisplayProgressDialog() { + val progressBar: ProgressBar = mockk(relaxed = true) + val progressTextView: TextView = mockk(relaxed = true) + val inflatedView: View = mockk() + val alertDialogBuilder: AlertDialog.Builder = mockk(relaxed = true) + + every { + activity.layoutInflater.inflate( + R.layout.copy_move_progress_bar, + null + ) + } returns inflatedView + every { inflatedView.findViewById(R.id.progressBar) } returns progressBar + every { inflatedView.findViewById(R.id.progressTextView) } returns progressTextView + + every { AlertDialog.Builder(activity) } returns alertDialogBuilder + every { alertDialogBuilder.setTitle(any()) } returns alertDialogBuilder + every { alertDialogBuilder.setView(inflatedView) } returns alertDialogBuilder + every { alertDialogBuilder.setCancelable(any()) } returns alertDialogBuilder + every { alertDialogBuilder.create() } returns progressBarDialog + every { progressBarDialog.show() } just Runs + + fileHandler.showProgressDialog() + + assertTrue(fileHandler.progressBarDialog?.isShowing == true) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun copyZimFileToPublicAppDirectory() = testScope.runTest { + val sourceUri: Uri = mockk() + val mockFileDescriptor = mockk(relaxed = true) + val contentResolver: ContentResolver = mockk() + every { activity.contentResolver } returns contentResolver + every { + contentResolver.openFileDescriptor( + sourceUri, + "r" + ) + } returns parcelFileDescriptor + every { parcelFileDescriptor.fileDescriptor } returns mockFileDescriptor + every { destinationFile.createNewFile() } returns true + every { destinationFile.name } returns "demo.zim" + every { sharedPreferenceUtil.prefStorage } returns storagePath + fileHandler = spyk(fileHandler) + every { fileHandler.getDestinationFile() } returns destinationFile + + // test when selected file is not found + fileHandler.setSelectedFileAndUri(null, null) + fileHandler.copyZimFileToPublicAppDirectory() + verify { fileCopyMoveCallback.onError(any()) } + verify { destinationFile.delete() } + + // test when selected file found + fileHandler.setSelectedFileAndUri(sourceUri, selectedFile) + fileHandler.copyZimFileToPublicAppDirectory() + verify { fileCopyMoveCallback.onFileCopied(destinationFile) } + + // test when there is an error in copying file + coEvery { + fileHandler.copyFile( + sourceUri, + destinationFile + ) + } throws FileNotFoundException("Test Exception") + + fileHandler.copyZimFileToPublicAppDirectory() + + advanceUntilIdle() + + verify(exactly = 0) { fileCopyMoveCallback.onFileCopied(destinationFile) } + verify { fileCopyMoveCallback.onError(any()) } + } + + @Test + fun getDestinationFile() { + val fileName = "test.txt" + val rootFile: File = mockk(relaxed = true) + val newFile: File = mockk(relaxed = true) + every { newFile.name } returns fileName + every { rootFile.path } returns storagePath + + every { selectedFile.name } returns fileName + every { File(rootFile, fileName).exists() } returns false + every { File(rootFile, fileName).createNewFile() } returns true + fileHandler = spyk(fileHandler) + every { fileHandler.getDestinationFile(rootFile) } returns newFile + + // Run the test + val resultFile = fileHandler.getDestinationFile(rootFile) + + assertEquals(newFile, resultFile) + verify { File(rootFile, fileName).createNewFile() } + } +} From e5d7b2a17e47715d9c9efbb915ef60eb8620dfe2 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 3 Sep 2024 18:02:14 +0530 Subject: [PATCH 11/11] Added unit and instrumentation test cases for CopyMoveFileHandler to properly test the all scenario with edge cases. --- .../localLibrary/CopyMoveFileHandlerRobot.kt | 95 ++++++ .../localLibrary/CopyMoveFileHandlerTest.kt | 278 ++++++++++++++++ .../CopyMoveFileHandler.kt | 51 +-- .../library/LocalLibraryFragment.kt | 11 +- .../localLibrary/CopyMoveFileHandlerTest.kt | 298 ++++++++++++++++++ .../reader/CopyMoveFileHandlerTest.kt | 253 --------------- 6 files changed, 708 insertions(+), 278 deletions(-) create mode 100644 app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerRobot.kt create mode 100644 app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt rename app/src/main/java/org/kiwix/kiwixmobile/nav/destination/{reader => library}/CopyMoveFileHandler.kt (91%) create mode 100644 app/src/test/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt delete mode 100644 app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerRobot.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerRobot.kt new file mode 100644 index 0000000000..0d027ee56a --- /dev/null +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerRobot.kt @@ -0,0 +1,95 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.localLibrary + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator +import applyWithViewHierarchyPrinting +import com.adevinta.android.barista.interaction.BaristaSleepInteractions +import junit.framework.AssertionFailedError +import org.kiwix.kiwixmobile.BaseRobot +import org.kiwix.kiwixmobile.Findable +import org.kiwix.kiwixmobile.Findable.StringId.TextId +import org.kiwix.kiwixmobile.R.id +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.testutils.TestUtils +import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView + +fun copyMoveFileHandler(func: CopyMoveFileHandlerRobot.() -> Unit) = + CopyMoveFileHandlerRobot().applyWithViewHierarchyPrinting(func) + +class CopyMoveFileHandlerRobot : BaseRobot() { + + fun assertCopyMovePermissionDialogDisplayed() { + isVisible(TextId(R.string.move_files_permission_dialog_title)) + } + + fun assertCopyMoveDialogDisplayed() { + isVisible(TextId(R.string.copy_move_files_dialog_description)) + } + + fun clickOnCopy() { + testFlakyView({ + onView(withText(R.string.copy)).perform(click()) + }) + } + + fun clickOnMove() { + testFlakyView({ + onView(withText(R.string.move)).perform(click()) + }) + } + + fun assertZimFileCopiedAndShowingIntoTheReader() { + pauseForBetterTestPerformance() + isVisible(Findable.ViewId(id.readerFragment)) + testFlakyView({ + Web.onWebView() + .withElement( + DriverAtoms.findElement( + Locator.XPATH, + "//*[contains(text(), 'Android_(operating_system)')]" + ) + ) + }) + } + + fun assertZimFileAddedInTheLocalLibrary() { + try { + onView(ViewMatchers.withId(id.file_management_no_files)).check( + ViewAssertions.matches( + ViewMatchers.isDisplayed() + ) + ) + throw RuntimeException("ZimFile not added in the local library") + } catch (e: AssertionFailedError) { + // do nothing zim file is added in the local library + } + } + + fun pauseForBetterTestPerformance() { + BaristaSleepInteractions.sleep(TestUtils.TEST_PAUSE_MS_FOR_SEARCH_TEST.toLong()) + } +} diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt new file mode 100644 index 0000000000..a7f7b14a79 --- /dev/null +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt @@ -0,0 +1,278 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.localLibrary + +import android.net.Uri +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.lifecycle.Lifecycle +import androidx.navigation.fragment.NavHostFragment +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.internal.runner.junit4.statement.UiThreadStatement +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.kiwix.kiwixmobile.BaseActivityTest +import org.kiwix.kiwixmobile.R +import org.kiwix.kiwixmobile.core.extensions.deleteFile +import org.kiwix.kiwixmobile.core.extensions.isFileExist +import org.kiwix.kiwixmobile.core.settings.StorageCalculator +import org.kiwix.kiwixmobile.core.utils.LanguageUtils +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower +import org.kiwix.kiwixmobile.main.KiwixMainActivity +import org.kiwix.kiwixmobile.nav.destination.library.CopyMoveFileHandler +import org.kiwix.kiwixmobile.nav.destination.library.LocalLibraryFragment +import org.kiwix.kiwixmobile.testutils.RetryRule +import org.kiwix.kiwixmobile.testutils.TestUtils +import org.kiwix.kiwixmobile.zimManager.Fat32Checker +import org.kiwix.kiwixmobile.zimManager.FileWritingFileSystemChecker +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + +class CopyMoveFileHandlerTest : BaseActivityTest() { + @Rule + @JvmField + var retryRule = RetryRule() + + private lateinit var sharedPreferenceUtil: SharedPreferenceUtil + private lateinit var kiwixMainActivity: KiwixMainActivity + private lateinit var selectedFile: File + private lateinit var destinationFile: File + private lateinit var parentFile: File + + @Before + override fun waitForIdle() { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).apply { + if (TestUtils.isSystemUINotRespondingDialogVisible(this)) { + TestUtils.closeSystemDialogs(context, this) + } + waitForIdle() + } + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(SharedPreferenceUtil.PREF_SHOW_INTRO, false) + putBoolean(SharedPreferenceUtil.PREF_WIFI_ONLY, false) + putBoolean(SharedPreferenceUtil.PREF_IS_TEST, true) + putBoolean(SharedPreferenceUtil.PREF_PLAY_STORE_RESTRICTION, false) + putString(SharedPreferenceUtil.PREF_LANG, "en") + } + activityScenario = ActivityScenario.launch(KiwixMainActivity::class.java).apply { + moveToState(Lifecycle.State.RESUMED) + sharedPreferenceUtil = SharedPreferenceUtil(context) + onActivity { + LanguageUtils.handleLocaleChange( + it, + "en", + sharedPreferenceUtil + ) + parentFile = File(sharedPreferenceUtil.prefStorage) + } + } + } + + @Test + fun testCopyingZimFileIntoPublicStorage() { + deleteAllFilesInDirectory(parentFile) + // Test the scenario in playStore build on Android 11 and above. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + selectedFile = getSelectedFile() + activityScenario.onActivity { + kiwixMainActivity = it + kiwixMainActivity.navigate(R.id.libraryFragment) + } + copyMoveFileHandler(CopyMoveFileHandlerRobot::pauseForBetterTestPerformance) + // test with first launch + sharedPreferenceUtil.copyMoveZimFilePermissionDialog = false + showMoveFileToPublicDirectoryDialog() + // should show the permission dialog. + copyMoveFileHandler { + assertCopyMovePermissionDialogDisplayed() + clickOnCopy() + assertZimFileCopiedAndShowingIntoTheReader() + } + assertZimFileAddedInTheLocalLibrary() + + // Test with second launch, this time permission dialog should not show. + // delete the parent directory so that all the previous file will be deleted. + deleteAllFilesInDirectory(parentFile) + showMoveFileToPublicDirectoryDialog() + // should show the copyMove dialog. + copyMoveFileHandler { + assertCopyMoveDialogDisplayed() + clickOnCopy() + assertZimFileCopiedAndShowingIntoTheReader() + } + assertZimFileAddedInTheLocalLibrary() + deleteAllFilesInDirectory(parentFile) + } + } + + @Test + fun testMovingZimFileIntoPublicDirectory() { + deleteAllFilesInDirectory(parentFile) + // Test the scenario in playStore build on Android 11 and above. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + selectedFile = getSelectedFile() + activityScenario.onActivity { + kiwixMainActivity = it + kiwixMainActivity.navigate(R.id.libraryFragment) + } + copyMoveFileHandler(CopyMoveFileHandlerRobot::pauseForBetterTestPerformance) + // test with first launch + sharedPreferenceUtil.copyMoveZimFilePermissionDialog = false + showMoveFileToPublicDirectoryDialog() + // should show the permission dialog. + copyMoveFileHandler { + assertCopyMovePermissionDialogDisplayed() + clickOnMove() + assertZimFileCopiedAndShowingIntoTheReader() + } + assertZimFileAddedInTheLocalLibrary() + // Test with second launch, this time permission dialog should not show. + // delete the parent directory so that all the previous file will be deleted. + deleteAllFilesInDirectory(parentFile) + selectedFile = getSelectedFile() + showMoveFileToPublicDirectoryDialog() + // should show the copyMove dialog. + copyMoveFileHandler { + assertCopyMoveDialogDisplayed() + clickOnMove() + assertZimFileCopiedAndShowingIntoTheReader() + } + assertZimFileAddedInTheLocalLibrary() + assertSelectedZimFileIsDeletedFromTheStorage(selectedFile) + deleteAllFilesInDirectory(parentFile) + } + } + + private fun assertSelectedZimFileIsDeletedFromTheStorage(selectedZimFile: File) { + if (selectedZimFile.isFileExist()) { + throw RuntimeException("Selected zim file is not deleted from the storage") + } + } + + private fun assertZimFileAddedInTheLocalLibrary() { + UiThreadStatement.runOnUiThread { + kiwixMainActivity.navigate(R.id.libraryFragment) + } + copyMoveFileHandler(CopyMoveFileHandlerRobot::assertZimFileAddedInTheLocalLibrary) + } + + private fun showMoveFileToPublicDirectoryDialog() { + UiThreadStatement.runOnUiThread { + val navHostFragment: NavHostFragment = + kiwixMainActivity.supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment + val localLibraryFragment = + navHostFragment.childFragmentManager.fragments[0] as LocalLibraryFragment + localLibraryFragment.copyMoveFileHandler?.showMoveFileToPublicDirectoryDialog( + Uri.fromFile(selectedFile), + selectedFile + ) + } + } + + private fun getSelectedFile(): File { + val loadFileStream = + CopyMoveFileHandlerTest::class.java.classLoader.getResourceAsStream("testzim.zim") + val zimFile = File( + ContextCompat.getExternalFilesDirs(context, null)[0], + "testzim.zim" + ) + if (zimFile.exists()) zimFile.delete() + zimFile.createNewFile() + loadFileStream.use { inputStream -> + val outputStream: OutputStream = FileOutputStream(zimFile) + outputStream.use { it -> + val buffer = ByteArray(inputStream.available()) + var length: Int + while (inputStream.read(buffer).also { length = it } > 0) { + it.write(buffer, 0, length) + } + } + } + return zimFile + } + + @Test + fun testGetDestinationFile() { + activityScenario.onActivity { + kiwixMainActivity = it + kiwixMainActivity.navigate(R.id.libraryFragment) + } + val selectedFileName = "testCopyMove.zim" + deleteAllFilesInDirectory(parentFile) + val copyMoveFileHandler = CopyMoveFileHandler( + kiwixMainActivity, + sharedPreferenceUtil, + AlertDialogShower(kiwixMainActivity), + StorageCalculator(sharedPreferenceUtil), + Fat32Checker(sharedPreferenceUtil, listOf(FileWritingFileSystemChecker())) + ) + // test fileName when there is already a file available with same name. + // it should return different name + selectedFile = File(parentFile, selectedFileName).apply { + if (!isFileExist()) createNewFile() + } + copyMoveFileHandler.setSelectedFileAndUri(null, selectedFile) + destinationFile = copyMoveFileHandler.getDestinationFile() + Assert.assertNotEquals( + destinationFile.name, + selectedFile.name + ) + Assert.assertEquals( + destinationFile.name, + "testCopyMove_1.zim" + ) + deleteBothPreviousFiles() + + // test when there is no zim file available in the storage it should return the same fileName + selectedFile = File(parentFile, selectedFileName) + copyMoveFileHandler.setSelectedFileAndUri(null, selectedFile) + destinationFile = copyMoveFileHandler.getDestinationFile() + Assert.assertEquals( + destinationFile.name, + selectedFile.name + ) + deleteBothPreviousFiles() + } + + private fun deleteBothPreviousFiles() { + selectedFile.deleteFile() + destinationFile.deleteFile() + } + + private fun deleteAllFilesInDirectory(directory: File) { + if (directory.isDirectory) { + directory.listFiles()?.forEach { file -> + if (file.isDirectory) { + // Recursively delete files in subdirectories + deleteAllFilesInDirectory(file) + } + file.delete() + } + } + } +} diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/CopyMoveFileHandler.kt similarity index 91% rename from app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt rename to app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/CopyMoveFileHandler.kt index 8f5a73bbe9..96450f574d 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/CopyMoveFileHandler.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/CopyMoveFileHandler.kt @@ -16,7 +16,7 @@ * */ -package org.kiwix.kiwixmobile.nav.destination.reader +package org.kiwix.kiwixmobile.nav.destination.library import android.annotation.SuppressLint import android.app.Activity @@ -61,16 +61,16 @@ class CopyMoveFileHandler @Inject constructor( private val storageCalculator: StorageCalculator, private val fat32Checker: Fat32Checker ) { - var fileCopyMoveCallback: FileCopyMoveCallback? = null + private var fileCopyMoveCallback: FileCopyMoveCallback? = null private var selectedFileUri: Uri? = null private var selectedFile: File? = null private var copyMovePreparingDialog: Dialog? = null - var progressBarDialog: AlertDialog? = null - var lifecycleScope: CoroutineScope? = null + private var progressBarDialog: AlertDialog? = null + private var lifecycleScope: CoroutineScope? = null private var progressBar: ProgressBar? = null private var progressBarTextView: TextView? = null private var isMoveOperation = false - var fileSystemDisposable: Disposable? = null + private var fileSystemDisposable: Disposable? = null private val copyMoveTitle: String by lazy { if (isMoveOperation) { @@ -103,6 +103,14 @@ class CopyMoveFileHandler @Inject constructor( selectedFile = file } + fun setFileCopyMoveCallback(fileCopyMoveCallback: FileCopyMoveCallback?) { + this.fileCopyMoveCallback = fileCopyMoveCallback + } + + fun setLifeCycleScope(coroutineScope: CoroutineScope?) { + lifecycleScope = coroutineScope + } + private fun showMoveToPublicDirectoryPermissionDialog() { alertDialogShower.show( KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, @@ -121,7 +129,7 @@ class CopyMoveFileHandler @Inject constructor( ) } - private fun isBookLessThan4GB(): Boolean = + fun isBookLessThan4GB(): Boolean = (selectedFile?.length() ?: 0L) < FOUR_GIGABYTES_IN_KILOBYTES fun validateZimFileCanCopyOrMove(file: File = File(sharedPreferenceUtil.prefStorage)): Boolean { @@ -146,7 +154,7 @@ class CopyMoveFileHandler @Inject constructor( } } - private fun handleDetectingFileSystemState() { + fun handleDetectingFileSystemState() { if (isBookLessThan4GB()) { showCopyMoveDialog() } else { @@ -155,7 +163,7 @@ class CopyMoveFileHandler @Inject constructor( } } - private fun handleCannotWrite4GbFileState() { + fun handleCannotWrite4GbFileState() { if (isBookLessThan4GB()) { showCopyMoveDialog() } else { @@ -164,7 +172,7 @@ class CopyMoveFileHandler @Inject constructor( } } - private fun observeFileSystemState() { + fun observeFileSystemState() { if (fileSystemDisposable?.isDisposed == false) return fileSystemDisposable = fat32Checker.fileSystemStates .observeOn(AndroidSchedulers.mainThread()) @@ -176,7 +184,7 @@ class CopyMoveFileHandler @Inject constructor( } } - private fun showCopyMoveDialog() { + fun showCopyMoveDialog() { alertDialogShower.show( KiwixDialog.CopyMoveFileToPublicDirectoryDialog, ::performCopyOperation, @@ -184,17 +192,17 @@ class CopyMoveFileHandler @Inject constructor( ) } - private fun performCopyOperation() { + fun performCopyOperation() { isMoveOperation = false copyZimFileToPublicAppDirectory() } - private fun performMoveOperation() { + fun performMoveOperation() { isMoveOperation = true moveZimFileToPublicAppDirectory() } - fun copyZimFileToPublicAppDirectory() { + private fun copyZimFileToPublicAppDirectory() { lifecycleScope?.launch { val destinationFile = getDestinationFile() try { @@ -320,11 +328,10 @@ class CopyMoveFileHandler @Inject constructor( } @Suppress("MagicNumber") - suspend fun copyFile(sourceUri: Uri, destinationFile: File) = + private suspend fun copyFile(sourceUri: Uri, destinationFile: File) = withContext(Dispatchers.IO) { val contentResolver = activity.contentResolver - // Open the ParcelFileDescriptor from the Uri val parcelFileDescriptor = contentResolver.openFileDescriptor(sourceUri, "r") val fileSize = parcelFileDescriptor?.fileDescriptor?.let { FileInputStream(it).channel.size() } ?: 0L @@ -354,7 +361,8 @@ class CopyMoveFileHandler @Inject constructor( } ?: throw FileNotFoundException("The selected file could not be opened") } - fun getDestinationFile(root: File = File(sharedPreferenceUtil.prefStorage)): File { + fun getDestinationFile(): File { + val root = File(sharedPreferenceUtil.prefStorage) val fileName = selectedFile?.name ?: "" val destinationFile = sequence { @@ -373,8 +381,7 @@ class CopyMoveFileHandler @Inject constructor( private fun hasNotSufficientStorageSpace(availableSpace: Long): Boolean = availableSpace < (selectedFile?.length() ?: 0L) - @SuppressLint("InflateParams") - private fun showPreparingCopyMoveDialog() { + @SuppressLint("InflateParams") fun showPreparingCopyMoveDialog() { if (copyMovePreparingDialog == null) { val dialogView: View = activity.layoutInflater.inflate(R.layout.item_custom_spinner, null) @@ -389,7 +396,7 @@ class CopyMoveFileHandler @Inject constructor( } @SuppressLint("InflateParams") - fun showProgressDialog() { + private fun showProgressDialog() { val dialogView = activity.layoutInflater.inflate(layout.copy_move_progress_bar, null) progressBar = @@ -414,6 +421,12 @@ class CopyMoveFileHandler @Inject constructor( } } + fun dispose() { + fileSystemDisposable?.dispose() + setFileCopyMoveCallback(null) + setLifeCycleScope(null) + } + interface FileCopyMoveCallback { fun onFileCopied(file: File) fun onFileMoved(file: File) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt index 1af021240f..56cb3bf6ef 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/LocalLibraryFragment.kt @@ -98,7 +98,6 @@ import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDis import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem import org.kiwix.kiwixmobile.core.zim_manager.fileselect_view.adapter.BooksOnDiskListItem.BookOnDisk import org.kiwix.kiwixmobile.databinding.FragmentDestinationLibraryBinding -import org.kiwix.kiwixmobile.nav.destination.reader.CopyMoveFileHandler import org.kiwix.kiwixmobile.zimManager.MAX_PROGRESS import org.kiwix.kiwixmobile.zimManager.ZimManageViewModel import org.kiwix.kiwixmobile.zimManager.ZimManageViewModel.FileSelectActions @@ -244,8 +243,10 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpSwipeRefreshLayout() - copyMoveFileHandler?.fileCopyMoveCallback = this - copyMoveFileHandler?.lifecycleScope = lifecycleScope + copyMoveFileHandler?.apply { + setFileCopyMoveCallback(this@LocalLibraryFragment) + setLifeCycleScope(lifecycleScope) + } fragmentDestinationLibraryBinding?.zimfilelist?.run { adapter = booksOnDiskAdapter layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) @@ -486,9 +487,7 @@ class LocalLibraryFragment : BaseFragment(), CopyMoveFileHandler.FileCopyMoveCal disposable.clear() storagePermissionLauncher?.unregister() storagePermissionLauncher = null - copyMoveFileHandler?.fileCopyMoveCallback = null - copyMoveFileHandler?.lifecycleScope = null - copyMoveFileHandler?.fileSystemDisposable?.dispose() + copyMoveFileHandler?.dispose() copyMoveFileHandler = null } diff --git a/app/src/test/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt b/app/src/test/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt new file mode 100644 index 0000000000..057c122060 --- /dev/null +++ b/app/src/test/java/org/kiwix/kiwixmobile/localLibrary/CopyMoveFileHandlerTest.kt @@ -0,0 +1,298 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package org.kiwix.kiwixmobile.localLibrary + +import android.app.Activity +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.kiwix.kiwixmobile.core.settings.StorageCalculator +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower +import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog +import org.kiwix.kiwixmobile.nav.destination.library.CopyMoveFileHandler +import org.kiwix.kiwixmobile.nav.destination.library.CopyMoveFileHandler.FileCopyMoveCallback +import org.kiwix.kiwixmobile.zimManager.Fat32Checker +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile +import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.DetectingFileSystem +import java.io.File + +class CopyMoveFileHandlerTest { + private lateinit var fileHandler: CopyMoveFileHandler + + private val activity: Activity = mockk(relaxed = true) + private val sharedPreferenceUtil: SharedPreferenceUtil = mockk(relaxed = true) + private val alertDialogShower: AlertDialogShower = mockk(relaxed = true) + private val storageCalculator: StorageCalculator = mockk(relaxed = true) + private val fat32Checker: Fat32Checker = mockk(relaxed = true) + private val fileCopyMoveCallback: FileCopyMoveCallback = mockk(relaxed = true) + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val storageFile: File = mockk(relaxed = true) + private val selectedFile: File = mockk(relaxed = true) + private val storagePath = "storage/0/emulated/Android/media/org.kiwix.kiwixmobile" + + @BeforeEach + fun setup() { + clearAllMocks() + fileHandler = CopyMoveFileHandler( + activity, + sharedPreferenceUtil, + alertDialogShower, + storageCalculator, + fat32Checker + ).apply { + setSelectedFileAndUri(null, selectedFile) + setLifeCycleScope(testScope) + setFileCopyMoveCallback(this@CopyMoveFileHandlerTest.fileCopyMoveCallback) + } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnTrueWhenSufficientSpaceAndValidFileSystem() { + prepareFileSystemAndFileForMockk() + + val result = fileHandler.validateZimFileCanCopyOrMove(storageFile) + + assertTrue(result) + // check insufficientSpaceInStorage callback should not call. + verify(exactly = 0) { fileCopyMoveCallback.insufficientSpaceInStorage(any()) } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnFalseAndCallCallbackWhenInsufficientSpace() { + prepareFileSystemAndFileForMockk( + selectedFileLength = 2000L, + fileSystemState = CanWrite4GbFile + ) + val result = fileHandler.validateZimFileCanCopyOrMove(storageFile) + + assertFalse(result) + verify { fileCopyMoveCallback.insufficientSpaceInStorage(any()) } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnFalseWhenDetectingAndCanNotWrite4GBFiles() { + prepareFileSystemAndFileForMockk(fileSystemState = DetectingFileSystem) + // check when detecting the fileSystem + assertFalse(fileHandler.validateZimFileCanCopyOrMove(storageFile)) + + prepareFileSystemAndFileForMockk(fileSystemState = CannotWrite4GbFile) + + // check when Can not write 4GB files on the fileSystem + assertFalse(fileHandler.validateZimFileCanCopyOrMove()) + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnFalseWhenDetectingFileSystem() { + every { fileHandler.isBookLessThan4GB() } returns true + every { fileHandler.showCopyMoveDialog() } just Runs + prepareFileSystemAndFileForMockk(fileSystemState = DetectingFileSystem) + + val result = fileHandler.validateZimFileCanCopyOrMove(storageFile) + + assertFalse(result) + verify { fileHandler.handleDetectingFileSystemState() } + } + + @Test + fun validateZimFileCanCopyOrMoveShouldReturnFalseWhenCannotWrite4GbFile() { + every { fileHandler.isBookLessThan4GB() } returns true + every { fileHandler.showCopyMoveDialog() } just Runs + every { + fileCopyMoveCallback.filesystemDoesNotSupportedCopyMoveFilesOver4GB() + } just Runs + prepareFileSystemAndFileForMockk(fileSystemState = CannotWrite4GbFile) + + val result = fileHandler.validateZimFileCanCopyOrMove(storageFile) + + assertFalse(result) + verify { fileHandler.handleCannotWrite4GbFileState() } + } + + @Test + fun handleDetectingFileSystemStateShouldShowCopyMoveDialogIfBookLessThan4GB() { + fileHandler = spyk(fileHandler) + prepareFileSystemAndFileForMockk() + every { fileHandler.isBookLessThan4GB() } returns true + every { fileHandler.showCopyMoveDialog() } just Runs + + fileHandler.handleDetectingFileSystemState() + + verify { fileHandler.showCopyMoveDialog() } + } + + @Test + fun handleDetectingFileSystemStateShouldObserveFileSystemStateIfBookGreaterThan4GB() { + fileHandler = spyk(fileHandler) + prepareFileSystemAndFileForMockk(fileSystemState = DetectingFileSystem) + every { fileHandler.isBookLessThan4GB() } returns false + every { fileHandler.observeFileSystemState() } just Runs + + fileHandler.handleDetectingFileSystemState() + verify { fileHandler.observeFileSystemState() } + } + + @Test + fun handleCannotWrite4GbFileStateShouldShowCopyMoveDialogIfBookLessThan4GB() { + fileHandler = spyk(fileHandler) + prepareFileSystemAndFileForMockk() + every { fileHandler.isBookLessThan4GB() } returns true + every { fileHandler.showCopyMoveDialog() } just Runs + + fileHandler.handleCannotWrite4GbFileState() + + verify { fileHandler.showCopyMoveDialog() } + } + + @Test + fun handleCannotWrite4GbFileStateShouldCallCallbackIfBookGreaterThan4GB() { + fileHandler = spyk(fileHandler) + prepareFileSystemAndFileForMockk() + every { fileHandler.isBookLessThan4GB() } returns false + every { + fileCopyMoveCallback.filesystemDoesNotSupportedCopyMoveFilesOver4GB() + } just Runs + + fileHandler.handleCannotWrite4GbFileState() + + verify { + fileCopyMoveCallback.filesystemDoesNotSupportedCopyMoveFilesOver4GB() + } + } + + @Test + fun showMoveToPublicDirectoryPermissionDialogShouldShowPermissionDialogAtFirstLaunch() { + every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns false + every { alertDialogShower.show(any(), any(), any()) } just Runs + fileHandler.showMoveFileToPublicDirectoryDialog() + + verify { + alertDialogShower.show( + KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, + any(), + any() + ) + } + } + + @Test + fun copyMoveFunctionsShouldCallWhenClickingOnButtonsInPermissionDialog() { + val positiveButtonClickSlot = slot<() -> Unit>() + val negativeButtonClickSlot = slot<() -> Unit>() + fileHandler = spyk(fileHandler) + every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns false + every { + alertDialogShower.show( + KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, + capture(positiveButtonClickSlot), + capture(negativeButtonClickSlot) + ) + } just Runs + + fileHandler.showMoveFileToPublicDirectoryDialog() + every { fileHandler.validateZimFileCanCopyOrMove() } returns true + every { fileHandler.performCopyOperation() } just Runs + + positiveButtonClickSlot.captured.invoke() + verify { fileHandler.performCopyOperation() } + every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns false + every { fileHandler.performMoveOperation() } just Runs + negativeButtonClickSlot.captured.invoke() + + verify { fileHandler.performMoveOperation() } + + verify { sharedPreferenceUtil.copyMoveZimFilePermissionDialog = true } + } + + @Test + fun showCopyMoveDialog() { + every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns true + prepareFileSystemAndFileForMockk() + every { alertDialogShower.show(any(), any(), any()) } just Runs + fileHandler.showMoveFileToPublicDirectoryDialog() + + verify { + alertDialogShower.show( + KiwixDialog.CopyMoveFileToPublicDirectoryDialog, + any(), + any() + ) + } + } + + @Test + fun copyMoveFunctionsShouldCallWhenClickingOnButtonsInCopyMoveDialog() { + val positiveButtonClickSlot = slot<() -> Unit>() + val negativeButtonClickSlot = slot<() -> Unit>() + fileHandler = spyk(fileHandler) + every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns true + every { + alertDialogShower.show( + KiwixDialog.CopyMoveFileToPublicDirectoryDialog, + capture(positiveButtonClickSlot), + capture(negativeButtonClickSlot) + ) + } just Runs + + every { fileHandler.validateZimFileCanCopyOrMove() } returns true + fileHandler.showMoveFileToPublicDirectoryDialog() + every { fileHandler.performCopyOperation() } just Runs + + positiveButtonClickSlot.captured.invoke() + verify { fileHandler.performCopyOperation() } + every { fileHandler.performMoveOperation() } just Runs + negativeButtonClickSlot.captured.invoke() + + verify { fileHandler.performMoveOperation() } + } + + private fun prepareFileSystemAndFileForMockk( + storageFileExist: Boolean = true, + freeSpaceInStorage: Long = 1000L, + selectedFileLength: Long = 100L, + availableStorageSize: Long = 1000L, + fileSystemState: Fat32Checker.FileSystemState = CanWrite4GbFile + ) { + every { storageFile.exists() } returns storageFileExist + every { storageFile.freeSpace } returns freeSpaceInStorage + every { storageFile.path } returns storagePath + every { selectedFile.length() } returns selectedFileLength + every { storageCalculator.availableBytes(storageFile) } returns availableStorageSize + every { fat32Checker.fileSystemStates.value } returns fileSystemState + } + + @AfterEach + fun dispose() { + fileHandler.dispose() + } +} diff --git a/app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt b/app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt deleted file mode 100644 index d24af3dc98..0000000000 --- a/app/src/test/java/org/kiwix/kiwixmobile/reader/CopyMoveFileHandlerTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2024 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package org.kiwix.kiwixmobile.reader - -import android.app.Activity -import android.app.AlertDialog -import android.content.ContentResolver -import android.net.Uri -import android.os.ParcelFileDescriptor -import android.view.View -import android.widget.ProgressBar -import android.widget.TextView -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.kiwix.kiwixmobile.R -import org.kiwix.kiwixmobile.core.settings.StorageCalculator -import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil -import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower -import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog -import org.kiwix.kiwixmobile.nav.destination.reader.CopyMoveFileHandler -import org.kiwix.kiwixmobile.nav.destination.reader.CopyMoveFileHandler.FileCopyMoveCallback -import org.kiwix.kiwixmobile.zimManager.Fat32Checker -import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CanWrite4GbFile -import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.CannotWrite4GbFile -import org.kiwix.kiwixmobile.zimManager.Fat32Checker.FileSystemState.DetectingFileSystem -import java.io.File -import java.io.FileDescriptor -import java.io.FileNotFoundException - -class CopyMoveFileHandlerTest { - private lateinit var fileHandler: CopyMoveFileHandler - - private val activity: Activity = mockk(relaxed = true) - private val sharedPreferenceUtil: SharedPreferenceUtil = mockk(relaxed = true) - private val alertDialogShower: AlertDialogShower = mockk(relaxed = true) - private val storageCalculator: StorageCalculator = mockk(relaxed = true) - private val fat32Checker: Fat32Checker = mockk(relaxed = true) - private val fileCopyMoveCallback: FileCopyMoveCallback = mockk(relaxed = true) - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - private val progressBarDialog: AlertDialog = mockk(relaxed = true) - private val destinationFile: File = mockk(relaxed = true) - private val parcelFileDescriptor: ParcelFileDescriptor = mockk(relaxed = true) - private val storageFile: File = mockk(relaxed = true) - private val selectedFile: File = mockk(relaxed = true) - private val storagePath = "storage/0/emulated/Android/media/org.kiwix.kiwixmobile" - - @BeforeEach - fun setup() { - fileHandler = CopyMoveFileHandler( - activity, - sharedPreferenceUtil, - alertDialogShower, - storageCalculator, - fat32Checker - ).apply { - setSelectedFileAndUri(null, selectedFile) - lifecycleScope = testScope - this.fileCopyMoveCallback = this@CopyMoveFileHandlerTest.fileCopyMoveCallback - } - } - - @Test - fun validateZimFileCanCopyOrMoveShouldReturnTrueWhenSufficientSpaceAndValidFileSystem() { - every { storageFile.exists() } returns true - every { storageFile.freeSpace } returns 1000L - every { storageFile.path } returns storagePath - every { selectedFile.length() } returns 100L - every { storageCalculator.availableBytes(storageFile) } returns 1000L - every { fat32Checker.fileSystemStates.value } returns CanWrite4GbFile - - val result = fileHandler.validateZimFileCanCopyOrMove(storageFile) - - assertTrue(result) - // check insufficientSpaceInStorage callback should not call. - verify(exactly = 0) { fileCopyMoveCallback.insufficientSpaceInStorage(any()) } - } - - @Test - fun validateZimFileCanCopyOrMoveShouldReturnFalseAndCallCallbackWhenInsufficientSpace() { - every { selectedFile.length() } returns 2000L - every { storageFile.exists() } returns true - every { storageFile.freeSpace } returns 1000L - every { storageFile.path } returns storagePath - every { storageCalculator.availableBytes(storageFile) } returns 1000L - every { fat32Checker.fileSystemStates.value } returns CanWrite4GbFile - - val result = fileHandler.validateZimFileCanCopyOrMove() - - assertFalse(result) - verify { fileCopyMoveCallback.insufficientSpaceInStorage(any()) } - } - - @Test - fun validateZimFileCanCopyOrMoveShouldReturnFalseWhenDetectingAndCanNotWrite4GBFiles() { - every { selectedFile.length() } returns 1000L - every { storageFile.exists() } returns true - every { storageFile.freeSpace } returns 2000L - every { storageFile.path } returns storagePath - every { storageCalculator.availableBytes(storageFile) } returns 2000L - every { fat32Checker.fileSystemStates.value } returns DetectingFileSystem - - // check when detecting the fileSystem - assertFalse(fileHandler.validateZimFileCanCopyOrMove()) - - every { fat32Checker.fileSystemStates.value } returns CannotWrite4GbFile - - // check when Can not write 4GB files on the fileSystem - assertFalse(fileHandler.validateZimFileCanCopyOrMove()) - } - - @Test - fun showMoveToPublicDirectoryPermissionDialogShouldShowPermissionDialogAtFirstLaunch() { - every { sharedPreferenceUtil.copyMoveZimFilePermissionDialog } returns false - every { alertDialogShower.show(any(), any(), any()) } just Runs - fileHandler.showMoveFileToPublicDirectoryDialog() - - verify { - alertDialogShower.show( - KiwixDialog.MoveFileToPublicDirectoryPermissionDialog, - any(), - any() - ) - } - } - - @Test - fun showProgressDialogShouldDisplayProgressDialog() { - val progressBar: ProgressBar = mockk(relaxed = true) - val progressTextView: TextView = mockk(relaxed = true) - val inflatedView: View = mockk() - val alertDialogBuilder: AlertDialog.Builder = mockk(relaxed = true) - - every { - activity.layoutInflater.inflate( - R.layout.copy_move_progress_bar, - null - ) - } returns inflatedView - every { inflatedView.findViewById(R.id.progressBar) } returns progressBar - every { inflatedView.findViewById(R.id.progressTextView) } returns progressTextView - - every { AlertDialog.Builder(activity) } returns alertDialogBuilder - every { alertDialogBuilder.setTitle(any()) } returns alertDialogBuilder - every { alertDialogBuilder.setView(inflatedView) } returns alertDialogBuilder - every { alertDialogBuilder.setCancelable(any()) } returns alertDialogBuilder - every { alertDialogBuilder.create() } returns progressBarDialog - every { progressBarDialog.show() } just Runs - - fileHandler.showProgressDialog() - - assertTrue(fileHandler.progressBarDialog?.isShowing == true) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun copyZimFileToPublicAppDirectory() = testScope.runTest { - val sourceUri: Uri = mockk() - val mockFileDescriptor = mockk(relaxed = true) - val contentResolver: ContentResolver = mockk() - every { activity.contentResolver } returns contentResolver - every { - contentResolver.openFileDescriptor( - sourceUri, - "r" - ) - } returns parcelFileDescriptor - every { parcelFileDescriptor.fileDescriptor } returns mockFileDescriptor - every { destinationFile.createNewFile() } returns true - every { destinationFile.name } returns "demo.zim" - every { sharedPreferenceUtil.prefStorage } returns storagePath - fileHandler = spyk(fileHandler) - every { fileHandler.getDestinationFile() } returns destinationFile - - // test when selected file is not found - fileHandler.setSelectedFileAndUri(null, null) - fileHandler.copyZimFileToPublicAppDirectory() - verify { fileCopyMoveCallback.onError(any()) } - verify { destinationFile.delete() } - - // test when selected file found - fileHandler.setSelectedFileAndUri(sourceUri, selectedFile) - fileHandler.copyZimFileToPublicAppDirectory() - verify { fileCopyMoveCallback.onFileCopied(destinationFile) } - - // test when there is an error in copying file - coEvery { - fileHandler.copyFile( - sourceUri, - destinationFile - ) - } throws FileNotFoundException("Test Exception") - - fileHandler.copyZimFileToPublicAppDirectory() - - advanceUntilIdle() - - verify(exactly = 0) { fileCopyMoveCallback.onFileCopied(destinationFile) } - verify { fileCopyMoveCallback.onError(any()) } - } - - @Test - fun getDestinationFile() { - val fileName = "test.txt" - val rootFile: File = mockk(relaxed = true) - val newFile: File = mockk(relaxed = true) - every { newFile.name } returns fileName - every { rootFile.path } returns storagePath - - every { selectedFile.name } returns fileName - every { File(rootFile, fileName).exists() } returns false - every { File(rootFile, fileName).createNewFile() } returns true - fileHandler = spyk(fileHandler) - every { fileHandler.getDestinationFile(rootFile) } returns newFile - - // Run the test - val resultFile = fileHandler.getDestinationFile(rootFile) - - assertEquals(newFile, resultFile) - verify { File(rootFile, fileName).createNewFile() } - } -}