Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added import/export to html functionality #54

Merged
merged 6 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface FolderDataSource {

suspend fun getFolderById(id: Int): Result<Folder>

suspend fun getFolderByName(name: String): Result<Folder>

suspend fun getFolderList(): Result<List<Folder>>

suspend fun getSortedFolderList(): Result<List<Folder>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class FolderRepository(private val dataSource: FolderDataSource) {
suspend fun getFolderById(folderId : Int) : Result<Folder> {
return dataSource.getFolderById(folderId)
}
suspend fun getFolderByName(name : String) : Result<Folder> {
return dataSource.getFolderByName(name)
}

suspend fun getFolderList(): Result<List<Folder>> {
return dataSource.getFolderList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ interface FolderDao : BaseDao<Folder> {
@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<Folder>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class FolderLocalDataSource internal constructor(
Result.failure(e)
}
}
override suspend fun getFolderByName(name : String): Result<Folder> = withContext(ioDispatcher) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add new line before method declaration

return@withContext try {
Result.success(folderDao.getFolderByName(name))
} catch (e: Exception) {
Result.failure(e)
}
}

override suspend fun getFolderList(): Result<List<Folder>> = withContext(ioDispatcher) {
return@withContext try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
AmrDeveloper marked this conversation as resolved.
Show resolved Hide resolved

private var _binding: FragmentImportExportBinding? = null
private val binding get() = _binding!!

private val importExportViewModel by viewModels<ImportExportHtmlViewModel>()

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
}
}
Original file line number Diff line number Diff line change
@@ -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 (
AmrDeveloper marked this conversation as resolved.
Show resolved Hide resolved
private val folderRepository: FolderRepository,
private val linkRepository: LinkRepository,
) : ViewModel() {

private val _stateMessages = MutableLiveData<Int>()
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<Link>()
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<Link>()
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<Folder>) {
viewModelScope.launch {
val fileName = System.currentTimeMillis().toString() + ".html"
val htmlString = buildString {
appendLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n")
appendLine("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n")
appendLine("<TITLE>Bookmarks</TITLE>\n")
appendLine("<H1>Bookmarks</H1>\n")
appendLine("<DL><p>\n")

folders.forEach {
val res = linkRepository.getSortedFolderLinkList(it.id)
appendLine("<DT><H3>${it.name}</H3>\n")
if (res.isSuccess) {
val links = res.getOrDefault(listOf())
appendLine("<DL><p>\n")
links.forEach { link ->
appendLine("<DT><A HREF=\"${link.url}\">${link.title}</A>\n")
}
appendLine("</DL><p>\n")
}

}

val bookmarks: List<Link> = linkRepository.getSortedFolderLinkList(-1).getOrDefault(
listOf()
)
if(bookmarks.isNotEmpty()) {
bookmarks.forEach { link ->
appendLine("<DT><A HREF=\"${link.url}\">${link.title}</A>\n")
}
}
appendLine("</DL><p>\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)
}

}
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/res/layout/fragment_setting.xml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@
android:layout_height="0.2dp"
android:background="@android:color/darker_gray" />

<TextView
AmrDeveloper marked this conversation as resolved.
Show resolved Hide resolved
android:id="@+id/import_export_html_txt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="@dimen/dimen10dp"
android:fontFamily="serif"
android:padding="@dimen/dimen10dp"
android:text="@string/import_export_html"
android:textColor="@color/dark_sky"
android:textSize="@dimen/dimen20sp"
app:drawableStartCompat="@drawable/ic_import_export" />

<View
AmrDeveloper marked this conversation as resolved.
Show resolved Hide resolved
android:layout_width="match_parent"
android:layout_height="0.2dp"
android:background="@android:color/darker_gray" />

<TextView
android:id="@+id/contributors_txt"
android:layout_width="match_parent"
Expand Down
Loading