From 1094b8ed9faadf14218675dd94c1c31d397c6b77 Mon Sep 17 00:00:00 2001 From: Galiev Bulat Date: Wed, 20 Mar 2024 13:54:47 +0300 Subject: [PATCH 1/6] Added import/export to html functionality --- app/build.gradle | 3 + .../linkhub/data/source/FolderDataSource.kt | 2 + .../linkhub/data/source/FolderRepository.kt | 3 + .../linkhub/data/source/local/FolderDao.kt | 3 + .../source/local/FolderLocalDataSource.kt | 7 + .../ImportExportFragmentHtml.kt | 131 ++++++++++++++ .../ImportExportHtmlViewModel.kt | 162 ++++++++++++++++++ .../linkhub/ui/setting/SettingFragment.kt | 4 + app/src/main/res/layout/fragment_setting.xml | 17 ++ app/src/main/res/navigation/nav_graph.xml | 7 + app/src/main/res/values/strings.xml | 1 + build.gradle | 1 + 12 files changed, 341 insertions(+) create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index d00357b..117bb49 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,4 +101,7 @@ dependencies { androidTestImplementation "androidx.arch.core:core-testing:$rootProject.archCoreTestVersion" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$rootProject.coroutinesTestVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$rootProject.espressoVersion" + + // Html parsing lib + implementation "org.jsoup:jsoup:$rootProject.jsoupVersion" } \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderDataSource.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderDataSource.kt index 8468cb3..a2beba9 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderDataSource.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderDataSource.kt @@ -10,6 +10,8 @@ interface FolderDataSource { suspend fun getFolderById(id: Int): Result + suspend fun getFolderByName(name: String): Result + suspend fun getFolderList(): Result> suspend fun getSortedFolderList(): Result> diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderRepository.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderRepository.kt index 58f0bb6..4193223 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderRepository.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/source/FolderRepository.kt @@ -15,6 +15,9 @@ class FolderRepository(private val dataSource: FolderDataSource) { suspend fun getFolderById(folderId : Int) : Result { return dataSource.getFolderById(folderId) } + suspend fun getFolderByName(name : String) : Result { + return dataSource.getFolderByName(name) + } suspend fun getFolderList(): Result> { return dataSource.getFolderList() diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderDao.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderDao.kt index 2ba8401..3281b33 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderDao.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderDao.kt @@ -11,6 +11,9 @@ interface FolderDao : BaseDao { @Query("SELECT * FROM folder WHERE id = :id LIMIT 1") suspend fun getFolderById(id : Int) : Folder + @Query("SELECT * FROM folder WHERE name = :name LIMIT 1") + suspend fun getFolderByName(name : String) : Folder + @Query("SELECT * FROM folder") suspend fun getFolderList(): List diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt index 8261597..afe3483 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt @@ -34,6 +34,13 @@ class FolderLocalDataSource internal constructor( Result.failure(e) } } + override suspend fun getFolderByName(name : String): Result = withContext(ioDispatcher) { + return@withContext try { + Result.success(folderDao.getFolderByName(name)) + } catch (e: Exception) { + Result.failure(e) + } + } override suspend fun getFolderList(): Result> = withContext(ioDispatcher) { return@withContext try { diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt new file mode 100644 index 0000000..4537e95 --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt @@ -0,0 +1,131 @@ +package com.amrdeveloper.linkhub.ui.importexport + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat.checkSelfPermission +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding +import com.amrdeveloper.linkhub.util.getFileName +import com.amrdeveloper.linkhub.util.getFileText +import com.amrdeveloper.linkhub.util.showSnackBar +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ImportExportFragmentHtml : Fragment() { + + private var _binding: FragmentImportExportBinding? = null + private val binding get() = _binding!! + + private val importExportViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentImportExportBinding.inflate(inflater, container, false) + + setupListeners() + setupObservers() + + return binding.root + } + + private fun setupListeners() { + binding.importAction.setOnClickListener { + importDataFile() + } + + binding.exportAction.setOnClickListener { + exportDataFile() + } + } + + private fun setupObservers() { + importExportViewModel.stateMessages.observe(viewLifecycleOwner) { messageId -> + activity.showSnackBar(messageId) + } + } + + private fun importDataFile() { + importFileFromDeviceWithPermission() + } + + private fun exportDataFile() { + exportFileFromDeviceWthPermission() + } + + private fun importFileFromDeviceWithPermission() { + // From Android 33 no need for READ_EXTERNAL_STORAGE permission for non media files + if (Build.VERSION_CODES.TIRAMISU > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.READ_EXTERNAL_STORAGE) + if (readPermissionState == PackageManager.PERMISSION_GRANTED) launchFileChooserIntent() + else permissionLauncher.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)) + } else { + launchFileChooserIntent() + } + } + + private fun exportFileFromDeviceWthPermission() { + if (Build.VERSION_CODES.R > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) + if (readPermissionState == PackageManager.PERMISSION_GRANTED) importExportViewModel.exportDataFile(requireContext()) + else permissionLauncher.launch(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) + } else { + importExportViewModel.exportDataFile(requireContext()) + } + } + + private fun launchFileChooserIntent() { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.type = "text/html" + intent.addCategory(Intent.CATEGORY_OPENABLE) + val chooserIntent = Intent.createChooser(intent, "Select a html file File to import") + loadFileActivityResult.launch(chooserIntent) + } + + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) { + importExportViewModel.exportDataFile(requireContext()) + } + else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) { + launchFileChooserIntent() + } + } + + private val loadFileActivityResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val resultIntent = result.data + if(resultIntent != null) { + val fileUri = resultIntent.data + if(fileUri != null) { + val contentResolver = requireActivity().contentResolver + val fileName = contentResolver.getFileName(fileUri) + val extension = fileName.substring(fileName.lastIndexOf('.') + 1) + if (extension != "html") { + activity?.showSnackBar(R.string.message_invalid_extension) + return@registerForActivityResult + } + val fileContent = contentResolver.getFileText(fileUri) + importExportViewModel.importDataFile(fileContent) + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt new file mode 100644 index 0000000..a213626 --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt @@ -0,0 +1,162 @@ +package com.amrdeveloper.linkhub.ui.importexport + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.data.Folder +import com.amrdeveloper.linkhub.data.FolderColor +import com.amrdeveloper.linkhub.data.Link +import com.amrdeveloper.linkhub.data.source.FolderRepository +import com.amrdeveloper.linkhub.data.source.LinkRepository +import com.google.gson.JsonSyntaxException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class ImportExportHtmlViewModel @Inject constructor ( + private val folderRepository: FolderRepository, + private val linkRepository: LinkRepository, +) : ViewModel() { + + private val _stateMessages = MutableLiveData() + val stateMessages = _stateMessages + + fun importDataFile(data : String) { + viewModelScope.launch { + try { + val doc: Document = Jsoup.parse(data) + val folders = doc.select("h3") + for (i in folders.indices) { + val folder = Folder(folders[i].text()).apply { + folderColor = FolderColor.BLUE + } + //the default case - no folder id + var folderId = -1 + val getFolderRes = folderRepository.getFolderByName(folder.name) + //a case when a folder does already exists + if (getFolderRes.isSuccess && getFolderRes.getOrNull() != null) { + val existingFolder = getFolderRes.getOrNull()!! + folderId = existingFolder.id + } else { + //a case when a folder does not exists + val addFolderRes = folderRepository.insertFolder(folder) + if (addFolderRes.isSuccess) { + folderId = addFolderRes.getOrDefault(-1).toInt() + } + } + + val folderLinks = mutableListOf() + val nextDL = folders[i].nextElementSibling() + val links = nextDL.select("a") + for (j in links.indices) { + val link = links[j] + val title = link.text() + val url = link.attr("href") + //subtitle = title = link name + folderLinks.add(Link(title, title, url, folderId = folderId)) + } + linkRepository.insertLinks(folderLinks) + } + // If there are bookmarks without a folder, add then individually + val rootDL = doc.select("dl").firstOrNull() + val folderLinks = mutableListOf() + if (rootDL != null) { + val individualBookmarks = rootDL.select("> dt > a") + if (individualBookmarks.isNotEmpty()) { + for (bookmarkElement in individualBookmarks) { + val title = bookmarkElement.text() + val url = bookmarkElement.attr("href") + folderLinks.add(Link(title, title, url)) + } + } + } + linkRepository.insertLinks(folderLinks) + + _stateMessages.value = R.string.message_data_imported + } catch (e : JsonSyntaxException) { + _stateMessages.value = R.string.message_invalid_data_format + } + } + } + + fun exportDataFile(context: Context) { + viewModelScope.launch { + val foldersResult = folderRepository.getFolderList() + if (foldersResult.isSuccess) { + val folders = foldersResult.getOrDefault(listOf()) + createdExportedHtmlFile(context, folders) + _stateMessages.value = R.string.message_data_exported + } else { + _stateMessages.value = R.string.message_invalid_export + } + } + } + private fun createdExportedHtmlFile(context: Context, folders: List) { + viewModelScope.launch { + val fileName = System.currentTimeMillis().toString() + ".html" + val htmlString = buildString { + appendLine("\n") + appendLine("\n") + appendLine("Bookmarks\n") + appendLine("

Bookmarks

\n") + appendLine("

\n") + + folders.forEach { + val res = linkRepository.getSortedFolderLinkList(it.id) + appendLine("

${it.name}

\n") + if (res.isSuccess) { + val links = res.getOrDefault(listOf()) + appendLine("

\n") + links.forEach { link -> + appendLine("

${link.title}\n") + } + appendLine("

\n") + } + + } + + val bookmarks: List = linkRepository.getSortedFolderLinkList(-1).getOrDefault( + listOf() + ) + if(bookmarks.isNotEmpty()) { + bookmarks.forEach { link -> + appendLine("

${link.title}\n") + } + } + appendLine("

\n") + + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues() + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + values.put(MediaStore.MediaColumns.MIME_TYPE, "text/html") + values.put( + MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_DOWNLOADS + ) + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + val outputStream = uri?.let { resolver.openOutputStream(it) } + outputStream?.write(htmlString.toByteArray()) + } else { + val downloadDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + val dataFile = File(downloadDir, fileName) + dataFile.writeText(htmlString) + } + + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt index ccba653..ae99cee 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt @@ -74,6 +74,10 @@ class SettingFragment : Fragment() { findNavController().navigate(R.id.action_settingFragment_to_importExportFragment) } + binding.importExportHtmlTxt.setOnClickListener { + findNavController().navigate(R.id.action_settingFragment_to_importExportFragmentHtml) + } + binding.contributorsTxt.setOnClickListener { openLinkIntent(requireContext(), REPOSITORY_CONTRIBUTORS_URL) } diff --git a/app/src/main/res/layout/fragment_setting.xml b/app/src/main/res/layout/fragment_setting.xml index ca9baee..f448a42 100644 --- a/app/src/main/res/layout/fragment_setting.xml +++ b/app/src/main/res/layout/fragment_setting.xml @@ -153,6 +153,23 @@ android:layout_height="0.2dp" android:background="@android:color/darker_gray" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe2da49..22710af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ Next Folder Color Previous Folder Color Import and Export + Import and Export HTML file Password Enable password diff --git a/build.gradle b/build.gradle index 7bdf30d..a2e087c 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,7 @@ ext { archCoreTestVersion = '2.1.0' coroutinesTestVersion = '1.2.1' espressoVersion = '3.5.1' + jsoupVersion = '1.13.1' } tasks.register('clean', Delete) { From 98cb753325c78335689f06481c04e750806aceca Mon Sep 17 00:00:00 2001 From: Galiev Bulat Date: Sun, 24 Mar 2024 10:22:58 +0300 Subject: [PATCH 2/6] moved import/export functionality into a single fragment --- .../linkhub/data/ImportExportFileType.kt | 7 + .../data/parser/HtmlImportExportFileParser.kt | 117 +++++++++++++ .../data/parser/ImportExportFileParser.kt | 17 ++ .../data/parser/JsonImportExportFileParser.kt | 54 ++++++ .../ui/importexport/ImportExportFragment.kt | 25 ++- .../ui/importexport/ImportExportViewModel.kt | 78 ++++----- .../ImportExportFragmentHtml.kt | 131 -------------- .../ImportExportHtmlViewModel.kt | 162 ------------------ .../linkhub/ui/setting/SettingFragment.kt | 16 +- app/src/main/res/navigation/nav_graph.xml | 14 +- 10 files changed, 264 insertions(+), 357 deletions(-) create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/data/parser/HtmlImportExportFileParser.kt create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/data/parser/JsonImportExportFileParser.kt delete mode 100644 app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt delete mode 100644 app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt new file mode 100644 index 0000000..17077ca --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt @@ -0,0 +1,7 @@ +package com.amrdeveloper.linkhub.data + +enum class ImportExportFileType(val mimeType: String, val extension: String) { + + JSON( "application/json",".json"), + HTML("text/html", ".html") +} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/HtmlImportExportFileParser.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/HtmlImportExportFileParser.kt new file mode 100644 index 0000000..5b08de1 --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/HtmlImportExportFileParser.kt @@ -0,0 +1,117 @@ +package com.amrdeveloper.linkhub.data.parser + +import com.amrdeveloper.linkhub.data.DataPackage +import com.amrdeveloper.linkhub.data.Folder +import com.amrdeveloper.linkhub.data.FolderColor +import com.amrdeveloper.linkhub.data.ImportExportFileType +import com.amrdeveloper.linkhub.data.Link +import com.amrdeveloper.linkhub.data.source.FolderRepository +import com.amrdeveloper.linkhub.data.source.LinkRepository +import com.amrdeveloper.linkhub.util.UiPreferences +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.select.Selector + +class HtmlImportExportFileParser: ImportExportFileParser { + override fun getFileType(): ImportExportFileType = ImportExportFileType.HTML + + override suspend fun importData(data: String, folderRepository: FolderRepository, linkRepository: LinkRepository): Result { + try { + val doc: Document = Jsoup.parse(data) + val folders = doc.select("h3") + for (i in folders.indices) { + val folder = Folder(folders[i].text()).apply { + folderColor = FolderColor.BLUE + } + //the default case - no folder id + var folderId = -1 + val getFolderRes = folderRepository.getFolderByName(folder.name) + //a case when a folder does already exists + if (getFolderRes.isSuccess && getFolderRes.getOrNull() != null) { + val existingFolder = getFolderRes.getOrNull()!! + folderId = existingFolder.id + } else { + //a case when a folder does not exists + val addFolderRes = folderRepository.insertFolder(folder) + if (addFolderRes.isSuccess) { + folderId = addFolderRes.getOrDefault(-1).toInt() + } + } + + val folderLinks = mutableListOf() + val nextDL = folders[i].nextElementSibling() + val links = nextDL.select("a") + for (j in links.indices) { + val link = links[j] + val title = link.text() + val url = link.attr("href") + //subtitle = title = link name + folderLinks.add(Link(title, title, url, folderId = folderId)) + } + linkRepository.insertLinks(folderLinks) + } + // If there are bookmarks without a folder, add then individually + val rootDL = doc.select("dl").firstOrNull() + val folderLinks = mutableListOf() + if (rootDL != null) { + val individualBookmarks = rootDL.select("> dt > a") + if (individualBookmarks.isNotEmpty()) { + for (bookmarkElement in individualBookmarks) { + val title = bookmarkElement.text() + val url = bookmarkElement.attr("href") + folderLinks.add(Link(title, title, url)) + } + } + } + linkRepository.insertLinks(folderLinks) + return Result.success(null) + } catch (e: Selector.SelectorParseException){ + return Result.failure(e) + } + } + override suspend fun exportData( + folderRepository: FolderRepository, + linkRepository: LinkRepository, + uiPreferences: UiPreferences + ): Result { + val foldersResult = folderRepository.getFolderList() + if (foldersResult.isSuccess) { + val folders = foldersResult.getOrDefault(listOf()) + val htmlString = buildString { + appendLine("\n") + appendLine("\n") + appendLine("Bookmarks\n") + appendLine("

Bookmarks

\n") + appendLine("

\n") + + folders.forEach { + val linksGetResult = linkRepository.getSortedFolderLinkList(it.id) + appendLine("

${it.name}

\n") + if (linksGetResult.isSuccess) { + val links = linksGetResult.getOrDefault(listOf()) + appendLine("

\n") + links.forEach { link -> + appendLine("

${link.title}\n") + } + appendLine("

\n") + } + + } + + val bookmarks: List = linkRepository.getSortedFolderLinkList(-1).getOrDefault( + listOf() + ) + if (bookmarks.isNotEmpty()) { + bookmarks.forEach { link -> + appendLine("

${link.title}\n") + } + } + appendLine("

\n") + + } + return Result.success(htmlString) + } + return Result.failure(Throwable()) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt new file mode 100644 index 0000000..14ce7db --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt @@ -0,0 +1,17 @@ +package com.amrdeveloper.linkhub.data.parser + +import com.amrdeveloper.linkhub.data.DataPackage +import com.amrdeveloper.linkhub.data.ImportExportFileType +import com.amrdeveloper.linkhub.data.source.FolderRepository +import com.amrdeveloper.linkhub.data.source.LinkRepository +import com.amrdeveloper.linkhub.util.UiPreferences + +interface ImportExportFileParser { + suspend fun importData(data: String, folderRepository: FolderRepository, linkRepository: LinkRepository): Result + suspend fun exportData( + folderRepository: FolderRepository, + linkRepository: LinkRepository, + uiPreferences: UiPreferences + ): Result + fun getFileType(): ImportExportFileType +} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/JsonImportExportFileParser.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/JsonImportExportFileParser.kt new file mode 100644 index 0000000..2fca7ce --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/JsonImportExportFileParser.kt @@ -0,0 +1,54 @@ +package com.amrdeveloper.linkhub.data.parser + +import com.amrdeveloper.linkhub.data.DataPackage +import com.amrdeveloper.linkhub.data.FolderColor +import com.amrdeveloper.linkhub.data.ImportExportFileType +import com.amrdeveloper.linkhub.data.source.FolderRepository +import com.amrdeveloper.linkhub.data.source.LinkRepository +import com.amrdeveloper.linkhub.util.UiPreferences +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException + +class JsonImportExportFileParser: ImportExportFileParser { + override fun getFileType(): ImportExportFileType = ImportExportFileType.JSON + override suspend fun importData( + data: String, + folderRepository: FolderRepository, + linkRepository: LinkRepository + ): Result { + try{ + val dataPackage = Gson().fromJson(data, DataPackage::class.java) + + val folders = dataPackage.folders + // This code should be removed after found why it not serialized on some devices (see Issue #23) + // folderColor field is declared as non nullable type but in this case GSON will break the null safty feature + folders.forEach { if (it.folderColor == null) it.folderColor = FolderColor.BLUE } + folderRepository.insertFolders(folders) + + linkRepository.insertLinks(dataPackage.links) + return Result.success(dataPackage) + } catch (e : JsonSyntaxException) { + return Result.failure(e) + } + } + override suspend fun exportData( + folderRepository: FolderRepository, + linkRepository: LinkRepository, + uiPreferences: UiPreferences + ): Result{ + val foldersResult = folderRepository.getFolderList() + val linksResult = linkRepository.getLinkList() + if (foldersResult.isSuccess && linksResult.isSuccess) { + val folders = foldersResult.getOrDefault(listOf()) + val links = linksResult.getOrDefault(listOf()) + val showClickCounter = uiPreferences.isClickCounterEnabled() + val autoSaving = uiPreferences.isAutoSavingEnabled() + val lastTheme = uiPreferences.getThemeType() + val dataPackage = DataPackage(folders, links, showClickCounter, autoSaving, lastTheme) + return Result.success(Gson().toJson(dataPackage)) + } else { + return Result.failure(Throwable()); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt index 6812a6a..4dd6d3a 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt @@ -14,6 +14,10 @@ import androidx.core.content.ContextCompat.checkSelfPermission import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.data.ImportExportFileType +import com.amrdeveloper.linkhub.data.parser.HtmlImportExportFileParser +import com.amrdeveloper.linkhub.data.parser.ImportExportFileParser +import com.amrdeveloper.linkhub.data.parser.JsonImportExportFileParser import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding import com.amrdeveloper.linkhub.util.getFileName import com.amrdeveloper.linkhub.util.getFileText @@ -25,6 +29,7 @@ class ImportExportFragment : Fragment() { private var _binding: FragmentImportExportBinding? = null private val binding get() = _binding!! + private lateinit var importExportFileParser: ImportExportFileParser private val importExportViewModel by viewModels() @@ -36,6 +41,13 @@ class ImportExportFragment : Fragment() { setupListeners() setupObservers() + //Initialization in real time, because the file type is chosen by a user + val args = ImportExportFragmentArgs.fromBundle(requireArguments()) + val importExportFileType = args.importExportFileType + importExportFileParser = when(importExportFileType){ + ImportExportFileType.JSON -> JsonImportExportFileParser() + ImportExportFileType.HTML -> HtmlImportExportFileParser() + } return binding.root } @@ -78,16 +90,17 @@ class ImportExportFragment : Fragment() { private fun exportFileFromDeviceWthPermission() { if (Build.VERSION_CODES.R > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) - if (readPermissionState == PackageManager.PERMISSION_GRANTED) importExportViewModel.exportDataFile(requireContext()) + if (readPermissionState == PackageManager.PERMISSION_GRANTED) + importExportViewModel.exportDataFile(requireContext(), importExportFileParser) else permissionLauncher.launch(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) } else { - importExportViewModel.exportDataFile(requireContext()) + importExportViewModel.exportDataFile(requireContext(), importExportFileParser) } } private fun launchFileChooserIntent() { val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "application/json" + intent.type = importExportFileParser.getFileType().mimeType intent.addCategory(Intent.CATEGORY_OPENABLE) val chooserIntent = Intent.createChooser(intent, "Select a File to import") loadFileActivityResult.launch(chooserIntent) @@ -96,7 +109,7 @@ class ImportExportFragment : Fragment() { private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) { - importExportViewModel.exportDataFile(requireContext()) + importExportViewModel.exportDataFile(requireContext(), importExportFileParser) } else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) { launchFileChooserIntent() @@ -113,12 +126,12 @@ class ImportExportFragment : Fragment() { val contentResolver = requireActivity().contentResolver val fileName = contentResolver.getFileName(fileUri) val extension = fileName.substring(fileName.lastIndexOf('.') + 1) - if (extension != "json") { + if ((".$extension") != importExportFileParser.getFileType().extension) { activity?.showSnackBar(R.string.message_invalid_extension) return@registerForActivityResult } val fileContent = contentResolver.getFileText(fileUri) - importExportViewModel.importDataFile(fileContent) + importExportViewModel.importDataFile(fileContent, importExportFileParser) } } } diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt index d8e449a..9962457 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt @@ -9,13 +9,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.amrdeveloper.linkhub.R -import com.amrdeveloper.linkhub.data.DataPackage -import com.amrdeveloper.linkhub.data.FolderColor +import com.amrdeveloper.linkhub.data.parser.ImportExportFileParser import com.amrdeveloper.linkhub.data.source.FolderRepository import com.amrdeveloper.linkhub.data.source.LinkRepository import com.amrdeveloper.linkhub.util.UiPreferences -import com.google.gson.Gson -import com.google.gson.JsonSyntaxException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import java.io.File @@ -31,65 +28,52 @@ class ImportExportViewModel @Inject constructor ( private val _stateMessages = MutableLiveData() val stateMessages = _stateMessages - fun importDataFile(data : String) { + fun importDataFile(data : String, importExportFileParser: ImportExportFileParser) { viewModelScope.launch { - try { - val dataPackage = Gson().fromJson(data, DataPackage::class.java) - - val folders = dataPackage.folders - // This code should be removed after found why it not serialized on some devices (see Issue #23) - // folderColor field is declared as non nullable type but in this case GSON will break the null safty feature - folders.forEach { if (it.folderColor == null) it.folderColor = FolderColor.BLUE } - folderRepository.insertFolders(folders) - - linkRepository.insertLinks(dataPackage.links) - - // Import show click count flag if it available - val lastShowClickCountConfig = uiPreferences.isClickCounterEnabled() - uiPreferences.setEnableClickCounter(dataPackage.showClickCounter ?: lastShowClickCountConfig) - - // Import enabling auto saving - val lastAutoSavingEnabled = uiPreferences.isAutoSavingEnabled() - uiPreferences.setEnableAutoSave(dataPackage.enableAutoSaving ?: lastAutoSavingEnabled) - - // Import theme flag if it available - val lastThemeOption = uiPreferences.getThemeType() - uiPreferences.setThemeType(dataPackage.theme ?: lastThemeOption) - - _stateMessages.value = R.string.message_data_imported - } catch (e : JsonSyntaxException) { - _stateMessages.value = R.string.message_invalid_data_format - } + val dataPackageResult = importExportFileParser.importData(data, folderRepository, linkRepository) + //dataPackage is null in case of non-configuration import + if(dataPackageResult.isSuccess) { + dataPackageResult.getOrNull()?.let { + // Import show click count flag if it available + val lastShowClickCountConfig = uiPreferences.isClickCounterEnabled() + uiPreferences.setEnableClickCounter( + it.showClickCounter ?: lastShowClickCountConfig + ) + // Import enabling auto saving + val lastAutoSavingEnabled = uiPreferences.isAutoSavingEnabled() + uiPreferences.setEnableAutoSave( + it.enableAutoSaving ?: lastAutoSavingEnabled + ) + // Import theme flag if it available + val lastThemeOption = uiPreferences.getThemeType() + uiPreferences.setThemeType(it.theme ?: lastThemeOption) + } + _stateMessages.value = R.string.message_data_imported + } else { + _stateMessages.value = R.string.message_invalid_data_format + } } } - fun exportDataFile(context: Context) { + fun exportDataFile(context: Context, importExportFileParser: ImportExportFileParser) { viewModelScope.launch { - val foldersResult = folderRepository.getFolderList() - val linksResult = linkRepository.getLinkList() - if (foldersResult.isSuccess && linksResult.isSuccess) { - val folders = foldersResult.getOrDefault(listOf()) - val links = linksResult.getOrDefault(listOf()) - val showClickCounter = uiPreferences.isClickCounterEnabled() - val autoSaving = uiPreferences.isAutoSavingEnabled() - val lastTheme = uiPreferences.getThemeType() - val dataPackage = DataPackage(folders, links, showClickCounter, autoSaving, lastTheme) - val jsonDataPackage = Gson().toJson(dataPackage) - createdExportedFile(context, jsonDataPackage) + val exportResult = importExportFileParser.exportData(folderRepository, linkRepository, uiPreferences) + if (exportResult.isSuccess) { + createdExportedFile(context, exportResult.getOrDefault(""), importExportFileParser) } else { _stateMessages.value = R.string.message_invalid_export } } } - private fun createdExportedFile(context: Context, data : String) { - val fileName = System.currentTimeMillis().toString() + ".json" + private fun createdExportedFile(context: Context, data : String, importExportFileParser: ImportExportFileParser) { + val fileName = System.currentTimeMillis().toString() + importExportFileParser.getFileType().extension if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val resolver = context.contentResolver val values = ContentValues() values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - values.put(MediaStore.MediaColumns.MIME_TYPE, "application/json") + values.put(MediaStore.MediaColumns.MIME_TYPE, importExportFileParser.getFileType().mimeType) values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) val outputStream = uri?.let { resolver.openOutputStream(it) } diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt deleted file mode 100644 index 4537e95..0000000 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportFragmentHtml.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.amrdeveloper.linkhub.ui.importexport - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat.checkSelfPermission -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.amrdeveloper.linkhub.R -import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding -import com.amrdeveloper.linkhub.util.getFileName -import com.amrdeveloper.linkhub.util.getFileText -import com.amrdeveloper.linkhub.util.showSnackBar -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class ImportExportFragmentHtml : Fragment() { - - private var _binding: FragmentImportExportBinding? = null - private val binding get() = _binding!! - - private val importExportViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentImportExportBinding.inflate(inflater, container, false) - - setupListeners() - setupObservers() - - return binding.root - } - - private fun setupListeners() { - binding.importAction.setOnClickListener { - importDataFile() - } - - binding.exportAction.setOnClickListener { - exportDataFile() - } - } - - private fun setupObservers() { - importExportViewModel.stateMessages.observe(viewLifecycleOwner) { messageId -> - activity.showSnackBar(messageId) - } - } - - private fun importDataFile() { - importFileFromDeviceWithPermission() - } - - private fun exportDataFile() { - exportFileFromDeviceWthPermission() - } - - private fun importFileFromDeviceWithPermission() { - // From Android 33 no need for READ_EXTERNAL_STORAGE permission for non media files - if (Build.VERSION_CODES.TIRAMISU > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.READ_EXTERNAL_STORAGE) - if (readPermissionState == PackageManager.PERMISSION_GRANTED) launchFileChooserIntent() - else permissionLauncher.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)) - } else { - launchFileChooserIntent() - } - } - - private fun exportFileFromDeviceWthPermission() { - if (Build.VERSION_CODES.R > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) - if (readPermissionState == PackageManager.PERMISSION_GRANTED) importExportViewModel.exportDataFile(requireContext()) - else permissionLauncher.launch(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) - } else { - importExportViewModel.exportDataFile(requireContext()) - } - } - - private fun launchFileChooserIntent() { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = "text/html" - intent.addCategory(Intent.CATEGORY_OPENABLE) - val chooserIntent = Intent.createChooser(intent, "Select a html file File to import") - loadFileActivityResult.launch(chooserIntent) - } - - private val permissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) { - importExportViewModel.exportDataFile(requireContext()) - } - else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) { - launchFileChooserIntent() - } - } - - private val loadFileActivityResult = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val resultIntent = result.data - if(resultIntent != null) { - val fileUri = resultIntent.data - if(fileUri != null) { - val contentResolver = requireActivity().contentResolver - val fileName = contentResolver.getFileName(fileUri) - val extension = fileName.substring(fileName.lastIndexOf('.') + 1) - if (extension != "html") { - activity?.showSnackBar(R.string.message_invalid_extension) - return@registerForActivityResult - } - val fileContent = contentResolver.getFileText(fileUri) - importExportViewModel.importDataFile(fileContent) - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt deleted file mode 100644 index a213626..0000000 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexporthtml/ImportExportHtmlViewModel.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.amrdeveloper.linkhub.ui.importexport - -import android.content.ContentValues -import android.content.Context -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.amrdeveloper.linkhub.R -import com.amrdeveloper.linkhub.data.Folder -import com.amrdeveloper.linkhub.data.FolderColor -import com.amrdeveloper.linkhub.data.Link -import com.amrdeveloper.linkhub.data.source.FolderRepository -import com.amrdeveloper.linkhub.data.source.LinkRepository -import com.google.gson.JsonSyntaxException -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import java.io.File -import javax.inject.Inject - -@HiltViewModel -class ImportExportHtmlViewModel @Inject constructor ( - private val folderRepository: FolderRepository, - private val linkRepository: LinkRepository, -) : ViewModel() { - - private val _stateMessages = MutableLiveData() - val stateMessages = _stateMessages - - fun importDataFile(data : String) { - viewModelScope.launch { - try { - val doc: Document = Jsoup.parse(data) - val folders = doc.select("h3") - for (i in folders.indices) { - val folder = Folder(folders[i].text()).apply { - folderColor = FolderColor.BLUE - } - //the default case - no folder id - var folderId = -1 - val getFolderRes = folderRepository.getFolderByName(folder.name) - //a case when a folder does already exists - if (getFolderRes.isSuccess && getFolderRes.getOrNull() != null) { - val existingFolder = getFolderRes.getOrNull()!! - folderId = existingFolder.id - } else { - //a case when a folder does not exists - val addFolderRes = folderRepository.insertFolder(folder) - if (addFolderRes.isSuccess) { - folderId = addFolderRes.getOrDefault(-1).toInt() - } - } - - val folderLinks = mutableListOf() - val nextDL = folders[i].nextElementSibling() - val links = nextDL.select("a") - for (j in links.indices) { - val link = links[j] - val title = link.text() - val url = link.attr("href") - //subtitle = title = link name - folderLinks.add(Link(title, title, url, folderId = folderId)) - } - linkRepository.insertLinks(folderLinks) - } - // If there are bookmarks without a folder, add then individually - val rootDL = doc.select("dl").firstOrNull() - val folderLinks = mutableListOf() - if (rootDL != null) { - val individualBookmarks = rootDL.select("> dt > a") - if (individualBookmarks.isNotEmpty()) { - for (bookmarkElement in individualBookmarks) { - val title = bookmarkElement.text() - val url = bookmarkElement.attr("href") - folderLinks.add(Link(title, title, url)) - } - } - } - linkRepository.insertLinks(folderLinks) - - _stateMessages.value = R.string.message_data_imported - } catch (e : JsonSyntaxException) { - _stateMessages.value = R.string.message_invalid_data_format - } - } - } - - fun exportDataFile(context: Context) { - viewModelScope.launch { - val foldersResult = folderRepository.getFolderList() - if (foldersResult.isSuccess) { - val folders = foldersResult.getOrDefault(listOf()) - createdExportedHtmlFile(context, folders) - _stateMessages.value = R.string.message_data_exported - } else { - _stateMessages.value = R.string.message_invalid_export - } - } - } - private fun createdExportedHtmlFile(context: Context, folders: List) { - viewModelScope.launch { - val fileName = System.currentTimeMillis().toString() + ".html" - val htmlString = buildString { - appendLine("\n") - appendLine("\n") - appendLine("Bookmarks\n") - appendLine("

Bookmarks

\n") - appendLine("

\n") - - folders.forEach { - val res = linkRepository.getSortedFolderLinkList(it.id) - appendLine("

${it.name}

\n") - if (res.isSuccess) { - val links = res.getOrDefault(listOf()) - appendLine("

\n") - links.forEach { link -> - appendLine("

${link.title}\n") - } - appendLine("

\n") - } - - } - - val bookmarks: List = linkRepository.getSortedFolderLinkList(-1).getOrDefault( - listOf() - ) - if(bookmarks.isNotEmpty()) { - bookmarks.forEach { link -> - appendLine("

${link.title}\n") - } - } - appendLine("

\n") - - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val resolver = context.contentResolver - val values = ContentValues() - values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - values.put(MediaStore.MediaColumns.MIME_TYPE, "text/html") - values.put( - MediaStore.MediaColumns.RELATIVE_PATH, - Environment.DIRECTORY_DOWNLOADS - ) - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) - val outputStream = uri?.let { resolver.openOutputStream(it) } - outputStream?.write(htmlString.toByteArray()) - } else { - val downloadDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - val dataFile = File(downloadDir, fileName) - dataFile.writeText(htmlString) - } - - } - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt index ae99cee..23c9d88 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt @@ -9,9 +9,17 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.amrdeveloper.linkhub.BuildConfig import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.data.ImportExportFileType import com.amrdeveloper.linkhub.data.Theme import com.amrdeveloper.linkhub.databinding.FragmentSettingBinding -import com.amrdeveloper.linkhub.util.* +import com.amrdeveloper.linkhub.util.PLAY_STORE_URL +import com.amrdeveloper.linkhub.util.REPOSITORY_CONTRIBUTORS_URL +import com.amrdeveloper.linkhub.util.REPOSITORY_ISSUES_URL +import com.amrdeveloper.linkhub.util.REPOSITORY_SPONSORSHIP_URL +import com.amrdeveloper.linkhub.util.REPOSITORY_URL +import com.amrdeveloper.linkhub.util.UiPreferences +import com.amrdeveloper.linkhub.util.openLinkIntent +import com.amrdeveloper.linkhub.util.shareTextIntent import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -71,11 +79,13 @@ class SettingFragment : Fragment() { } binding.importExportTxt.setOnClickListener { - findNavController().navigate(R.id.action_settingFragment_to_importExportFragment) + val action = SettingFragmentDirections.actionSettingFragmentToImportExportFragment( ImportExportFileType.JSON) + findNavController().navigate(action) } binding.importExportHtmlTxt.setOnClickListener { - findNavController().navigate(R.id.action_settingFragment_to_importExportFragmentHtml) + val action = SettingFragmentDirections.actionSettingFragmentToImportExportFragment( ImportExportFileType.HTML) + findNavController().navigate(action) } binding.contributorsTxt.setOnClickListener { diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 1de6581..b3815ec 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -159,16 +159,18 @@ - + tools:layout="@layout/fragment_import_export" > + + - \ No newline at end of file From e73d21a80f35ed9227a99deb0bc202cd5dc01425 Mon Sep 17 00:00:00 2001 From: Galiev Bulat Date: Mon, 25 Mar 2024 16:19:18 +0300 Subject: [PATCH 3/6] added import/export file picking dialog moved import/export data handling logic to view model --- .../linkhub/data/ImportExportFileType.kt | 6 ++-- .../ui/importexport/ImportExportFragment.kt | 31 +++++++------------ .../ui/importexport/ImportExportViewModel.kt | 28 ++++++++++++++--- .../linkhub/ui/setting/SettingFragment.kt | 9 +----- .../util/ImportExportFileTypePickerDialog.kt | 22 +++++++++++++ app/src/main/res/layout/fragment_setting.xml | 17 ---------- app/src/main/res/values/strings.xml | 2 +- 7 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt index 17077ca..392d6f8 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt @@ -1,7 +1,7 @@ package com.amrdeveloper.linkhub.data -enum class ImportExportFileType(val mimeType: String, val extension: String) { +enum class ImportExportFileType(val mimeType: String, val extension: String, val fileTypeName: String) { - JSON( "application/json",".json"), - HTML("text/html", ".html") + JSON( "application/json",".json", "Json"), + HTML("text/html", ".html", "HTML") } \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt index 4dd6d3a..eb3682d 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt @@ -14,11 +14,8 @@ import androidx.core.content.ContextCompat.checkSelfPermission import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.amrdeveloper.linkhub.R -import com.amrdeveloper.linkhub.data.ImportExportFileType -import com.amrdeveloper.linkhub.data.parser.HtmlImportExportFileParser -import com.amrdeveloper.linkhub.data.parser.ImportExportFileParser -import com.amrdeveloper.linkhub.data.parser.JsonImportExportFileParser import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding +import com.amrdeveloper.linkhub.util.ImportExportFileTypePickerDialog import com.amrdeveloper.linkhub.util.getFileName import com.amrdeveloper.linkhub.util.getFileText import com.amrdeveloper.linkhub.util.showSnackBar @@ -29,7 +26,6 @@ class ImportExportFragment : Fragment() { private var _binding: FragmentImportExportBinding? = null private val binding get() = _binding!! - private lateinit var importExportFileParser: ImportExportFileParser private val importExportViewModel by viewModels() @@ -41,20 +37,17 @@ class ImportExportFragment : Fragment() { setupListeners() setupObservers() - //Initialization in real time, because the file type is chosen by a user - val args = ImportExportFragmentArgs.fromBundle(requireArguments()) - val importExportFileType = args.importExportFileType - importExportFileParser = when(importExportFileType){ - ImportExportFileType.JSON -> JsonImportExportFileParser() - ImportExportFileType.HTML -> HtmlImportExportFileParser() - } return binding.root } private fun setupListeners() { binding.importAction.setOnClickListener { - importDataFile() + context?.let { ImportExportFileTypePickerDialog.launch(it) { fileType -> + importExportViewModel.setFileType(fileType) + importDataFile() + } + } } binding.exportAction.setOnClickListener { @@ -91,16 +84,16 @@ class ImportExportFragment : Fragment() { if (Build.VERSION_CODES.R > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) if (readPermissionState == PackageManager.PERMISSION_GRANTED) - importExportViewModel.exportDataFile(requireContext(), importExportFileParser) + importExportViewModel.exportDataFile(requireContext()) else permissionLauncher.launch(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) } else { - importExportViewModel.exportDataFile(requireContext(), importExportFileParser) + importExportViewModel.exportDataFile(requireContext()) } } private fun launchFileChooserIntent() { val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = importExportFileParser.getFileType().mimeType + intent.type = importExportViewModel.mimeType intent.addCategory(Intent.CATEGORY_OPENABLE) val chooserIntent = Intent.createChooser(intent, "Select a File to import") loadFileActivityResult.launch(chooserIntent) @@ -109,7 +102,7 @@ class ImportExportFragment : Fragment() { private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) { - importExportViewModel.exportDataFile(requireContext(), importExportFileParser) + importExportViewModel.exportDataFile(requireContext()) } else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) { launchFileChooserIntent() @@ -126,12 +119,12 @@ class ImportExportFragment : Fragment() { val contentResolver = requireActivity().contentResolver val fileName = contentResolver.getFileName(fileUri) val extension = fileName.substring(fileName.lastIndexOf('.') + 1) - if ((".$extension") != importExportFileParser.getFileType().extension) { + if (".$extension" != importExportViewModel.extension) { activity?.showSnackBar(R.string.message_invalid_extension) return@registerForActivityResult } val fileContent = contentResolver.getFileText(fileUri) - importExportViewModel.importDataFile(fileContent, importExportFileParser) + importExportViewModel.importDataFile(fileContent) } } } diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt index 9962457..e2a0392 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt @@ -9,7 +9,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.data.ImportExportFileType +import com.amrdeveloper.linkhub.data.parser.HtmlImportExportFileParser import com.amrdeveloper.linkhub.data.parser.ImportExportFileParser +import com.amrdeveloper.linkhub.data.parser.JsonImportExportFileParser import com.amrdeveloper.linkhub.data.source.FolderRepository import com.amrdeveloper.linkhub.data.source.LinkRepository import com.amrdeveloper.linkhub.util.UiPreferences @@ -26,9 +29,24 @@ class ImportExportViewModel @Inject constructor ( ) : ViewModel() { private val _stateMessages = MutableLiveData() + private var importExportFileParser: ImportExportFileParser = JsonImportExportFileParser() val stateMessages = _stateMessages - fun importDataFile(data : String, importExportFileParser: ImportExportFileParser) { + val mimeType: String + get() = + importExportFileParser.getFileType().mimeType + val extension: String + get() = + importExportFileParser.getFileType().extension + + fun setFileType(fileType: ImportExportFileType){ + importExportFileParser = when(fileType){ + ImportExportFileType.JSON-> JsonImportExportFileParser() + ImportExportFileType.HTML-> HtmlImportExportFileParser() + } + } + + fun importDataFile(data : String) { viewModelScope.launch { val dataPackageResult = importExportFileParser.importData(data, folderRepository, linkRepository) //dataPackage is null in case of non-configuration import @@ -55,18 +73,18 @@ class ImportExportViewModel @Inject constructor ( } } - fun exportDataFile(context: Context, importExportFileParser: ImportExportFileParser) { + fun exportDataFile(context: Context) { viewModelScope.launch { val exportResult = importExportFileParser.exportData(folderRepository, linkRepository, uiPreferences) if (exportResult.isSuccess) { - createdExportedFile(context, exportResult.getOrDefault(""), importExportFileParser) + createdExportedFile(context, exportResult.getOrDefault("")) } else { _stateMessages.value = R.string.message_invalid_export } } } - private fun createdExportedFile(context: Context, data : String, importExportFileParser: ImportExportFileParser) { + private fun createdExportedFile(context: Context, data : String) { val fileName = System.currentTimeMillis().toString() + importExportFileParser.getFileType().extension if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -79,7 +97,7 @@ class ImportExportViewModel @Inject constructor ( val outputStream = uri?.let { resolver.openOutputStream(it) } outputStream?.write(data.toByteArray()) } else { - val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val dataFile = File(downloadDir, fileName) dataFile.writeText(data) } diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt index 23c9d88..82abc01 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/setting/SettingFragment.kt @@ -9,7 +9,6 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.amrdeveloper.linkhub.BuildConfig import com.amrdeveloper.linkhub.R -import com.amrdeveloper.linkhub.data.ImportExportFileType import com.amrdeveloper.linkhub.data.Theme import com.amrdeveloper.linkhub.databinding.FragmentSettingBinding import com.amrdeveloper.linkhub.util.PLAY_STORE_URL @@ -79,13 +78,7 @@ class SettingFragment : Fragment() { } binding.importExportTxt.setOnClickListener { - val action = SettingFragmentDirections.actionSettingFragmentToImportExportFragment( ImportExportFileType.JSON) - findNavController().navigate(action) - } - - binding.importExportHtmlTxt.setOnClickListener { - val action = SettingFragmentDirections.actionSettingFragmentToImportExportFragment( ImportExportFileType.HTML) - findNavController().navigate(action) + findNavController().navigate(R.id.action_settingFragment_to_importExportFragment) } binding.contributorsTxt.setOnClickListener { diff --git a/app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt b/app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt new file mode 100644 index 0000000..3f39592 --- /dev/null +++ b/app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt @@ -0,0 +1,22 @@ +package com.amrdeveloper.linkhub.util + +import android.app.AlertDialog +import android.content.Context +import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.data.ImportExportFileType + +object ImportExportFileTypePickerDialog { + + fun launch(context: Context, onFileTypeSelected: (ImportExportFileType)->Unit) { + val fileTypes = ImportExportFileType.entries.map { it.fileTypeName } + val builder = AlertDialog.Builder(context) + builder.setTitle(context.getString(R.string.import_export_choose_file_type)) + builder.setItems(fileTypes.toTypedArray()) { dialog, which -> + val selectedFileType = ImportExportFileType.entries[which] + onFileTypeSelected(selectedFileType) + dialog.dismiss() + } + val dialog = builder.create() + dialog.show() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_setting.xml b/app/src/main/res/layout/fragment_setting.xml index f448a42..ca9baee 100644 --- a/app/src/main/res/layout/fragment_setting.xml +++ b/app/src/main/res/layout/fragment_setting.xml @@ -153,23 +153,6 @@ android:layout_height="0.2dp" android:background="@android:color/darker_gray" /> - - - - Next Folder Color Previous Folder Color Import and Export - Import and Export HTML file + Choose a file type Password Enable password From 98a068413f4664015be6621e3b0e8c8524bd0eb5 Mon Sep 17 00:00:00 2001 From: Galiev Bulat Date: Mon, 25 Mar 2024 16:23:31 +0300 Subject: [PATCH 4/6] added import/export file picking dialog on an export button click --- .../linkhub/ui/importexport/ImportExportFragment.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt index eb3682d..eb08dee 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt @@ -51,7 +51,11 @@ class ImportExportFragment : Fragment() { } binding.exportAction.setOnClickListener { - exportDataFile() + context?.let { ImportExportFileTypePickerDialog.launch(it) { fileType -> + importExportViewModel.setFileType(fileType) + exportDataFile() + } + } } } From be3d0fbf03c30cd8c8c2cfe9292647c0ccd4db04 Mon Sep 17 00:00:00 2001 From: Galiev Bulat Date: Wed, 27 Mar 2024 12:29:57 +0300 Subject: [PATCH 5/6] refactored the code design --- .../linkhub/data/ImportExportFileType.kt | 1 - .../data/parser/ImportExportFileParser.kt | 8 +++ .../source/local/FolderLocalDataSource.kt | 1 + .../ui/importexport/ImportExportFragment.kt | 70 +++++++++++-------- .../ui/importexport/ImportExportViewModel.kt | 68 ++++++++---------- 5 files changed, 79 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt index 392d6f8..39fe126 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/ImportExportFileType.kt @@ -1,7 +1,6 @@ package com.amrdeveloper.linkhub.data enum class ImportExportFileType(val mimeType: String, val extension: String, val fileTypeName: String) { - JSON( "application/json",".json", "Json"), HTML("text/html", ".html", "HTML") } \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt index 14ce7db..e59d173 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt @@ -7,6 +7,14 @@ import com.amrdeveloper.linkhub.data.source.LinkRepository import com.amrdeveloper.linkhub.util.UiPreferences interface ImportExportFileParser { + object ImportExportFileParserFactory { + fun getInstance(fileType: ImportExportFileType): ImportExportFileParser { + return when (fileType) { + ImportExportFileType.JSON -> JsonImportExportFileParser() + ImportExportFileType.HTML -> HtmlImportExportFileParser() + } + } + } suspend fun importData(data: String, folderRepository: FolderRepository, linkRepository: LinkRepository): Result suspend fun exportData( folderRepository: FolderRepository, diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt index afe3483..94b73a5 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/source/local/FolderLocalDataSource.kt @@ -34,6 +34,7 @@ class FolderLocalDataSource internal constructor( Result.failure(e) } } + override suspend fun getFolderByName(name : String): Result = withContext(ioDispatcher) { return@withContext try { Result.success(folderDao.getFolderByName(name)) diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt index eb08dee..6513c8b 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat.checkSelfPermission import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.amrdeveloper.linkhub.R +import com.amrdeveloper.linkhub.data.ImportExportFileType import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding import com.amrdeveloper.linkhub.util.ImportExportFileTypePickerDialog import com.amrdeveloper.linkhub.util.getFileName @@ -24,6 +25,7 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ImportExportFragment : Fragment() { + private var importExportFileType: ImportExportFileType? = null private var _binding: FragmentImportExportBinding? = null private val binding get() = _binding!! @@ -43,17 +45,19 @@ class ImportExportFragment : Fragment() { private fun setupListeners() { binding.importAction.setOnClickListener { - context?.let { ImportExportFileTypePickerDialog.launch(it) { fileType -> - importExportViewModel.setFileType(fileType) - importDataFile() + context?.let { + ImportExportFileTypePickerDialog.launch(it) { fileType -> + importExportFileType = fileType + importDataFile(fileType) } } } binding.exportAction.setOnClickListener { - context?.let { ImportExportFileTypePickerDialog.launch(it) { fileType -> - importExportViewModel.setFileType(fileType) - exportDataFile() + context?.let { + ImportExportFileTypePickerDialog.launch(it) { fileType -> + importExportFileType = fileType + exportDataFile(fileType) } } } @@ -65,39 +69,39 @@ class ImportExportFragment : Fragment() { } } - private fun importDataFile() { - importFileFromDeviceWithPermission() + private fun importDataFile(fileType: ImportExportFileType) { + importFileFromDeviceWithPermission(fileType) } - private fun exportDataFile() { - exportFileFromDeviceWthPermission() + private fun exportDataFile(fileType: ImportExportFileType) { + exportFileFromDeviceWthPermission(fileType) } - private fun importFileFromDeviceWithPermission() { + private fun importFileFromDeviceWithPermission(fileType: ImportExportFileType) { // From Android 33 no need for READ_EXTERNAL_STORAGE permission for non media files if (Build.VERSION_CODES.TIRAMISU > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.READ_EXTERNAL_STORAGE) - if (readPermissionState == PackageManager.PERMISSION_GRANTED) launchFileChooserIntent() + if (readPermissionState == PackageManager.PERMISSION_GRANTED) launchFileChooserIntent(fileType) else permissionLauncher.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)) } else { - launchFileChooserIntent() + launchFileChooserIntent(fileType) } } - private fun exportFileFromDeviceWthPermission() { + private fun exportFileFromDeviceWthPermission(fileType: ImportExportFileType) { if (Build.VERSION_CODES.R > Build.VERSION.SDK_INT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val readPermissionState = checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) if (readPermissionState == PackageManager.PERMISSION_GRANTED) - importExportViewModel.exportDataFile(requireContext()) + importExportViewModel.exportDataFile(requireContext(), fileType) else permissionLauncher.launch(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) } else { - importExportViewModel.exportDataFile(requireContext()) + importExportViewModel.exportDataFile(requireContext(), fileType) } } - private fun launchFileChooserIntent() { + private fun launchFileChooserIntent(fileType: ImportExportFileType) { val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.type = importExportViewModel.mimeType + intent.type = fileType.mimeType intent.addCategory(Intent.CATEGORY_OPENABLE) val chooserIntent = Intent.createChooser(intent, "Select a File to import") loadFileActivityResult.launch(chooserIntent) @@ -105,11 +109,13 @@ class ImportExportFragment : Fragment() { private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) { - importExportViewModel.exportDataFile(requireContext()) - } - else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) { - launchFileChooserIntent() + importExportFileType?.let { fileType-> + if(result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) { + importExportViewModel.exportDataFile(requireContext(), fileType) + } + else if(result[Manifest.permission.READ_EXTERNAL_STORAGE] == true) { + launchFileChooserIntent(fileType) + } } } @@ -120,15 +126,17 @@ class ImportExportFragment : Fragment() { if(resultIntent != null) { val fileUri = resultIntent.data if(fileUri != null) { - val contentResolver = requireActivity().contentResolver - val fileName = contentResolver.getFileName(fileUri) - val extension = fileName.substring(fileName.lastIndexOf('.') + 1) - if (".$extension" != importExportViewModel.extension) { - activity?.showSnackBar(R.string.message_invalid_extension) - return@registerForActivityResult + importExportFileType?.let { fileType-> + val contentResolver = requireActivity().contentResolver + val fileName = contentResolver.getFileName(fileUri) + val extension = fileName.substring(fileName.lastIndexOf('.') + 1) + if (".$extension" != fileType.extension) { + activity?.showSnackBar(R.string.message_invalid_extension) + return@registerForActivityResult + } + val fileContent = contentResolver.getFileText(fileUri) + importExportViewModel.importDataFile(fileContent, fileType) } - val fileContent = contentResolver.getFileText(fileUri) - importExportViewModel.importDataFile(fileContent) } } } diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt index e2a0392..252a7e3 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt @@ -10,9 +10,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.amrdeveloper.linkhub.R import com.amrdeveloper.linkhub.data.ImportExportFileType -import com.amrdeveloper.linkhub.data.parser.HtmlImportExportFileParser import com.amrdeveloper.linkhub.data.parser.ImportExportFileParser -import com.amrdeveloper.linkhub.data.parser.JsonImportExportFileParser import com.amrdeveloper.linkhub.data.source.FolderRepository import com.amrdeveloper.linkhub.data.source.LinkRepository import com.amrdeveloper.linkhub.util.UiPreferences @@ -28,27 +26,15 @@ class ImportExportViewModel @Inject constructor ( private val uiPreferences: UiPreferences, ) : ViewModel() { + private var importExportFileType: ImportExportFileType? = null private val _stateMessages = MutableLiveData() - private var importExportFileParser: ImportExportFileParser = JsonImportExportFileParser() val stateMessages = _stateMessages - val mimeType: String - get() = - importExportFileParser.getFileType().mimeType - val extension: String - get() = - importExportFileParser.getFileType().extension - - fun setFileType(fileType: ImportExportFileType){ - importExportFileParser = when(fileType){ - ImportExportFileType.JSON-> JsonImportExportFileParser() - ImportExportFileType.HTML-> HtmlImportExportFileParser() - } - } - - fun importDataFile(data : String) { + fun importDataFile(data : String, fileType: ImportExportFileType) { viewModelScope.launch { - val dataPackageResult = importExportFileParser.importData(data, folderRepository, linkRepository) + importExportFileType = fileType + val parser = ImportExportFileParser.ImportExportFileParserFactory.getInstance(fileType) + val dataPackageResult = parser.importData(data, folderRepository, linkRepository) //dataPackage is null in case of non-configuration import if(dataPackageResult.isSuccess) { dataPackageResult.getOrNull()?.let { @@ -73,9 +59,11 @@ class ImportExportViewModel @Inject constructor ( } } - fun exportDataFile(context: Context) { + fun exportDataFile(context: Context, fileType: ImportExportFileType) { viewModelScope.launch { - val exportResult = importExportFileParser.exportData(folderRepository, linkRepository, uiPreferences) + importExportFileType = fileType + val parser = ImportExportFileParser.ImportExportFileParserFactory.getInstance(fileType) + val exportResult = parser.exportData(folderRepository, linkRepository, uiPreferences) if (exportResult.isSuccess) { createdExportedFile(context, exportResult.getOrDefault("")) } else { @@ -85,24 +73,30 @@ class ImportExportViewModel @Inject constructor ( } private fun createdExportedFile(context: Context, data : String) { - val fileName = System.currentTimeMillis().toString() + importExportFileParser.getFileType().extension + importExportFileType?.let { fileType-> + val fileName = System.currentTimeMillis().toString() + fileType.extension - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val resolver = context.contentResolver - val values = ContentValues() - values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - values.put(MediaStore.MediaColumns.MIME_TYPE, importExportFileParser.getFileType().mimeType) - values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) - val outputStream = uri?.let { resolver.openOutputStream(it) } - outputStream?.write(data.toByteArray()) - } else { - val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val dataFile = File(downloadDir, fileName) - dataFile.writeText(data) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues() + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + values.put( + MediaStore.MediaColumns.MIME_TYPE, + fileType.mimeType + ) + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + val outputStream = uri?.let { resolver.openOutputStream(it) } + outputStream?.write(data.toByteArray()) + } else { + val downloadDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val dataFile = File(downloadDir, fileName) + dataFile.writeText(data) + } - _stateMessages.value = R.string.message_data_exported + _stateMessages.value = R.string.message_data_exported + } } } \ No newline at end of file From f882898e6f4286372846cf5358db395c94e740f4 Mon Sep 17 00:00:00 2001 From: Galiev Bulat Date: Wed, 27 Mar 2024 14:49:10 +0300 Subject: [PATCH 6/6] refactored the code design --- .../data/parser/ImportExportFileParser.kt | 5 +- .../ui/importexport/ImportExportFragment.kt | 32 ++++--- .../ui/importexport/ImportExportViewModel.kt | 93 +++++++++---------- .../util/ImportExportFileTypePickerDialog.kt | 22 ----- 4 files changed, 67 insertions(+), 85 deletions(-) delete mode 100644 app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt diff --git a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt index e59d173..6af4dfc 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/data/parser/ImportExportFileParser.kt @@ -7,8 +7,9 @@ import com.amrdeveloper.linkhub.data.source.LinkRepository import com.amrdeveloper.linkhub.util.UiPreferences interface ImportExportFileParser { - object ImportExportFileParserFactory { - fun getInstance(fileType: ImportExportFileType): ImportExportFileParser { + + companion object { + fun getDataParser(fileType: ImportExportFileType): ImportExportFileParser { return when (fileType) { ImportExportFileType.JSON -> JsonImportExportFileParser() ImportExportFileType.HTML -> HtmlImportExportFileParser() diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt index 6513c8b..c632e5e 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportFragment.kt @@ -2,6 +2,8 @@ package com.amrdeveloper.linkhub.ui.importexport import android.Manifest import android.app.Activity +import android.app.AlertDialog +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build @@ -16,7 +18,6 @@ import androidx.fragment.app.viewModels import com.amrdeveloper.linkhub.R import com.amrdeveloper.linkhub.data.ImportExportFileType import com.amrdeveloper.linkhub.databinding.FragmentImportExportBinding -import com.amrdeveloper.linkhub.util.ImportExportFileTypePickerDialog import com.amrdeveloper.linkhub.util.getFileName import com.amrdeveloper.linkhub.util.getFileText import com.amrdeveloper.linkhub.util.showSnackBar @@ -45,24 +46,33 @@ class ImportExportFragment : Fragment() { private fun setupListeners() { binding.importAction.setOnClickListener { - context?.let { - ImportExportFileTypePickerDialog.launch(it) { fileType -> - importExportFileType = fileType - importDataFile(fileType) - } + launchFileTypePickerDialog(requireContext()) { fileType -> + importExportFileType = fileType + importDataFile(fileType) } } binding.exportAction.setOnClickListener { - context?.let { - ImportExportFileTypePickerDialog.launch(it) { fileType -> - importExportFileType = fileType - exportDataFile(fileType) - } + launchFileTypePickerDialog(requireContext()) { fileType -> + importExportFileType = fileType + exportDataFile(fileType) } } } + private fun launchFileTypePickerDialog(context: Context, onFileTypeSelected: (ImportExportFileType)->Unit) { + val fileTypes = ImportExportFileType.entries.map { it.fileTypeName } + val builder = AlertDialog.Builder(context) + builder.setTitle(context.getString(R.string.import_export_choose_file_type)) + builder.setItems(fileTypes.toTypedArray()) { dialog, which -> + val selectedFileType = ImportExportFileType.entries[which] + onFileTypeSelected(selectedFileType) + dialog.dismiss() + } + val dialog = builder.create() + dialog.show() + } + private fun setupObservers() { importExportViewModel.stateMessages.observe(viewLifecycleOwner) { messageId -> activity.showSnackBar(messageId) diff --git a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt index 252a7e3..823f528 100644 --- a/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt +++ b/app/src/main/java/com/amrdeveloper/linkhub/ui/importexport/ImportExportViewModel.kt @@ -26,77 +26,70 @@ class ImportExportViewModel @Inject constructor ( private val uiPreferences: UiPreferences, ) : ViewModel() { - private var importExportFileType: ImportExportFileType? = null private val _stateMessages = MutableLiveData() val stateMessages = _stateMessages fun importDataFile(data : String, fileType: ImportExportFileType) { viewModelScope.launch { - importExportFileType = fileType - val parser = ImportExportFileParser.ImportExportFileParserFactory.getInstance(fileType) + val parser = ImportExportFileParser.getDataParser(fileType) val dataPackageResult = parser.importData(data, folderRepository, linkRepository) - //dataPackage is null in case of non-configuration import - if(dataPackageResult.isSuccess) { - dataPackageResult.getOrNull()?.let { - // Import show click count flag if it available - val lastShowClickCountConfig = uiPreferences.isClickCounterEnabled() - uiPreferences.setEnableClickCounter( - it.showClickCounter ?: lastShowClickCountConfig - ) - // Import enabling auto saving - val lastAutoSavingEnabled = uiPreferences.isAutoSavingEnabled() - uiPreferences.setEnableAutoSave( - it.enableAutoSaving ?: lastAutoSavingEnabled - ) - // Import theme flag if it available - val lastThemeOption = uiPreferences.getThemeType() - uiPreferences.setThemeType(it.theme ?: lastThemeOption) - } - _stateMessages.value = R.string.message_data_imported - } else { - _stateMessages.value = R.string.message_invalid_data_format + //dataPackage is null in case of non-configuration import + if(dataPackageResult.isSuccess) { + dataPackageResult.getOrNull()?.let { + // Import show click count flag if it available + val lastShowClickCountConfig = uiPreferences.isClickCounterEnabled() + uiPreferences.setEnableClickCounter( + it.showClickCounter ?: lastShowClickCountConfig + ) + // Import enabling auto saving + val lastAutoSavingEnabled = uiPreferences.isAutoSavingEnabled() + uiPreferences.setEnableAutoSave( + it.enableAutoSaving ?: lastAutoSavingEnabled + ) + // Import theme flag if it available + val lastThemeOption = uiPreferences.getThemeType() + uiPreferences.setThemeType(it.theme ?: lastThemeOption) } + _stateMessages.value = R.string.message_data_imported + } else { + _stateMessages.value = R.string.message_invalid_data_format + } } } fun exportDataFile(context: Context, fileType: ImportExportFileType) { viewModelScope.launch { - importExportFileType = fileType - val parser = ImportExportFileParser.ImportExportFileParserFactory.getInstance(fileType) + val parser = ImportExportFileParser.getDataParser(fileType) val exportResult = parser.exportData(folderRepository, linkRepository, uiPreferences) if (exportResult.isSuccess) { - createdExportedFile(context, exportResult.getOrDefault("")) + createdExportedFile(context, fileType, exportResult.getOrDefault("")) } else { _stateMessages.value = R.string.message_invalid_export } } } - private fun createdExportedFile(context: Context, data : String) { - importExportFileType?.let { fileType-> - val fileName = System.currentTimeMillis().toString() + fileType.extension - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val resolver = context.contentResolver - val values = ContentValues() - values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - values.put( - MediaStore.MediaColumns.MIME_TYPE, - fileType.mimeType - ) - values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) - val outputStream = uri?.let { resolver.openOutputStream(it) } - outputStream?.write(data.toByteArray()) - } else { - val downloadDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val dataFile = File(downloadDir, fileName) - dataFile.writeText(data) - } - - _stateMessages.value = R.string.message_data_exported + private fun createdExportedFile(context: Context, fileType: ImportExportFileType, data : String) { + val fileName = System.currentTimeMillis().toString() + fileType.extension + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues() + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + values.put( + MediaStore.MediaColumns.MIME_TYPE, + fileType.mimeType + ) + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + val outputStream = uri?.let { resolver.openOutputStream(it) } + outputStream?.write(data.toByteArray()) + } else { + val downloadDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val dataFile = File(downloadDir, fileName) + dataFile.writeText(data) } + _stateMessages.value = R.string.message_data_exported } } \ No newline at end of file diff --git a/app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt b/app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt deleted file mode 100644 index 3f39592..0000000 --- a/app/src/main/java/com/amrdeveloper/linkhub/util/ImportExportFileTypePickerDialog.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.amrdeveloper.linkhub.util - -import android.app.AlertDialog -import android.content.Context -import com.amrdeveloper.linkhub.R -import com.amrdeveloper.linkhub.data.ImportExportFileType - -object ImportExportFileTypePickerDialog { - - fun launch(context: Context, onFileTypeSelected: (ImportExportFileType)->Unit) { - val fileTypes = ImportExportFileType.entries.map { it.fileTypeName } - val builder = AlertDialog.Builder(context) - builder.setTitle(context.getString(R.string.import_export_choose_file_type)) - builder.setItems(fileTypes.toTypedArray()) { dialog, which -> - val selectedFileType = ImportExportFileType.entries[which] - onFileTypeSelected(selectedFileType) - dialog.dismiss() - } - val dialog = builder.create() - dialog.show() - } -} \ No newline at end of file