Parsing a small application with jokes

Introduction

In general, the idea was born from the fact that my friends and I sent gifts to each other in VK (a little Old Believer) and attached jokes to them. One of the sources of jokes for me personally was the site https://baneks.ru/. But copying jokes from him was terribly inconvenient, plus there was no way to save the ones you liked normally. This is where the task comes in:

  1. Implement getting jokes.

  2. Implement saving favorites.

  3. implement convenient copying.

Libraries used

For implementation, both standard libraries and a couple of rather specific ones were used:

network

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.13.1'

Because there is no access to the site API, to get jokes, the HTML code obtained using the library was parsed jsoup. For the rest of the interaction, we used retrofit.

database

implementation "androidx.room:room-runtime:2.4.3"
kapt "androidx.room:room-compiler:2.4.3"

Room was used to save favorite jokes by writing them to a local database on the device

DI

implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"

For the implementation of DI, Hilt was taken as the most simple and suitable library for the tasks.

design

implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.fragment:fragment-ktx:1.5.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'

implementation('com.mikepenz.materialdrawer:library:0.9.5@aar') {
    transitive = true
}

Here, in addition to the Jetpack libraries, there is also a third-party library by Mike Penza for drawing the Material Drawer, which was used as a side menu for switching between fragments.

Code parsing

We will not analyze the entire code, only some interesting points.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val (toolbar: Toolbar, fragmentContainer) = createToolbar()
        createDrawer(toolbar, fragmentContainer)
        initialize(savedInstanceState, fragmentContainer)
    }

    private fun initialize(savedInstanceState: Bundle?, fragmentContainer: Int) {
        val isFragmentContainerEmpty = (savedInstanceState == null)
        if (isFragmentContainerEmpty) {
            supportFragmentManager.commit {
                setReorderingAllowed(true)
                add(fragmentContainer, JokeListFragment.get())
                addToBackStack("joke_list")
            }
        }
    }

    private fun createToolbar(): Pair<Toolbar, Int> {
        val toolbar: Toolbar = findViewById<View>(R.id.toolbar) as Toolbar
        toolbar.setTitle(R.string.drawer_item_random_joke)
        setSupportActionBar(toolbar)
        supportActionBar!!.setDisplayHomeAsUpEnabled(true)
        val fragmentContainer = R.id.fragmentContainer
        return Pair(toolbar, fragmentContainer)
    }

    private fun createDrawer(toolbar: Toolbar, fragmentContainer: Int) {
        Drawer()
            .withActivity(this)
            .withToolbar(toolbar)
            .withActionBarDrawerToggle(true)
            .withHeader(R.layout.drawer_header)
            .addDrawerItems(
                PrimaryDrawerItem().withName(R.string.drawer_item_random_joke)
                    .withIcon(getDrawable(R.drawable.random)),
                PrimaryDrawerItem().withName(R.string.drawer_item_like)
                    .withIcon(getDrawable(R.drawable.heart)),
                PrimaryDrawerItem().withName(R.string.drawer_item_best_joke)
                    .withIcon(getDrawable(R.drawable.crown))
            )
            .withOnDrawerItemClickListener { _, _, position, _, _ ->
                when (position) {
                    1 -> {
                        supportFragmentManager.commit {
                            setReorderingAllowed(true)
                            replace(fragmentContainer, JokeListFragment.get())
                            addToBackStack("joke_list")
                            toolbar.setTitle(R.string.drawer_item_random_joke)
                        }
                    }
                    2 -> {
                        supportFragmentManager.commit {
                            setReorderingAllowed(true)
                            replace(fragmentContainer, LikeListFragment.newInstance())
                            addToBackStack("like_list")
                        }
                        toolbar.setTitle(R.string.drawer_item_like)
                    }
                    3 -> {
                        supportFragmentManager.commit {
                            setReorderingAllowed(true)
                            replace(fragmentContainer, BestListFragment.get())
                            addToBackStack("best_list")
                        }
                        toolbar.setTitle(R.string.drawer_item_best_joke)
                    }
                }
            }
            .build()
    }
}

So the code creates an activity MainActivity with toolbar (Toolbar) and sidebar (Navigation Drawer). When selecting sidebar items, the current fragment in the container is replaced fragmentContainer to the corresponding fragment, as well as changing the title of the toolbar. This allows the user to switch between different parts of the application using the sidebar and the home button on the back navigation toolbar.

private const val VIEW_TYPE_ITEM = 0
private const val VIEW_TYPE_LOAD = 1

class JokeAdapter(private val onItemClicked: (Joke, ToggleButton) -> Unit) :
    ListAdapter<Joke, ViewHolder>(JokeDiffCallback()) {

    private class LoadHolder(view: View) : ViewHolder(view)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == VIEW_TYPE_ITEM) {
            val view =
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.joke_list_item, parent, false)
            JokeHolder(view){
                onItemClicked(currentList[it], view.findViewById(R.id.like_button))
            }
        } else {
            val view =
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.progress_loading, parent, false)
            LoadHolder(view)
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (holder is JokeHolder) {
            val joke = getItem(position)
            holder.bind(joke)
        }

    }

    fun addLoadingView() {
        submitList(ArrayList(currentList + null))
        notifyItemInserted(currentList.lastIndex)
    }

    fun deleteLoadingView() {
        if (currentList.size != 0) {
            submitList(ArrayList(currentList - currentList.last()))
            notifyItemRemoved(currentList.size)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return if (getItem(position) == null) {
            VIEW_TYPE_LOAD
        } else {
            VIEW_TYPE_ITEM
        }
    }
}

This code represents the adapter JokeAdapterwhich is used to associate the list of jokes data with RecyclerView in the application.

Two constants are declared at the beginning of the file: VIEW_TYPE_ITEM And VIEW_TYPE_LOAD. They are used to determine the type of view that will be created in the adapter. VIEW_TYPE_ITEM represents an element of the list of jokes, and VIEW_TYPE_LOAD represents the download view.

Class JokeAdapter inherited from ListAdapterwhich is a subclass RecyclerView.Adapter and ensures that the list of jokes is automatically updated when the data changes.

The adapter has an inner class LoadHolderwhich is inherited from ViewHolder and represents an empty view for loading data.

A lamb is passed to the class constructor, because clicking on the button is handled a little differently in other classes.

class JokeHolder(view: View, onItemClicked: (Int) -> Unit) : RecyclerView.ViewHolder(view),
    View.OnLongClickListener {

    val context = view.context
    @Inject
    lateinit var repository: JokeRepository
    private var clipboard: ClipboardManager =
        context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
    private val jokeText: TextView = view.findViewById(R.id.joke_text)
    private val likeButton: ToggleButton = view.findViewById(R.id.like_button)
    private lateinit var joke: Joke

    init {
        jokeText.setOnLongClickListener(this)
        likeButton.setOnClickListener {
            onItemClicked(bindingAdapterPosition)
        }
    }

    fun bind(joke: Joke) {
        this.joke = joke
        jokeText.text = joke.text
        likeButton.isChecked = joke.isLiked
    }

    override fun onLongClick(p0: View?): Boolean {
        val clip = ClipData.newPlainText("Text of joke", jokeText.text)
        clipboard.setPrimaryClip(clip)
        Toast.makeText(context, "Text copied", Toast.LENGTH_SHORT).show()
        return true
    }
}

Class JokeHolder is a custom ViewHolder for the list of jokes element in the adapter JokeAdapter. It is inherited from RecyclerView.ViewHolder and implements the interface View.OnLongClickListener to handle a long press on a list item.

Method onLongClick invoked by long pressing jokeText. He creates ClipData with the text of the joke and copies it to the clipboard. Then a pop-up message about copying the text is displayed.

@Singleton
class JokeFetcher @Inject constructor(private val jokeApi: JokeApi) {
    suspend fun fetchJokeByNumberAsync(numberOfJoke: Int): Deferred<Joke> {
        return CoroutineScope(Dispatchers.IO).async {
            try {
                val textOfJoke =
                    jokeApi.getRandomJoke(numberOfJoke).body().toString().getJokeFromResponse()
                Joke(textOfJoke, numberOfJoke)
            } catch (e: Exception) {
                Joke("Default", 0)
            }
        }
    }

    private fun String.getJokeFromResponse(): String {
        return this
            .substringAfter("""name="description" content="""")
            .substringBefore("""">""")
    }

    fun getBestJokesListAsync(): Deferred<List<Int>> {
        return CoroutineScope(Dispatchers.IO).async {
            try {
                val response = Jsoup.connect("https://baneks.ru/top").get()
                val list = response.select("article").select("a")
                    .map { it.attr("href")}
                    .map { it.substringAfter("/").toInt()}
                list
            } catch (e: Exception) {
                Log.d(TAG, "getBestJokesListAsync error",e)
                MutableList(0) { 0 }
            }
        }
    }
}

Method fetchJokeByNumberAsync performs getting a joke by the specified number. It uses coroutines to execute a query on a separate thread and returns a delayed result (Deferred<Joke>). Inside the coroutine, a request is made to jokeApi to get a random joke using jokeApi.getRandomJoke(numberOfJoke). Then the text of the joke is extracted from the response using the function getJokeFromResponse()and an object is created Joke with the received text and the joke number. On error, if the request fails, an object is returned Joke with default values.

Method getBestJokesListAsync performs web scraping using the Jsoup library to get a list of links to jokes from a site. Then the list of links is processed, the numeric identifiers of the jokes are extracted, and the list of these identifiers is returned as a delayed result (Deferred<List<Int>>). On error, if scraping fails, an empty list is returned.

private method getJokeFromResponse used to extract the joke text from the API response. It processes the response string with string operations to get the text of the joke.

private const val TAG = "BestListFragment"
private const val LOAD_THRESHOLD = 3

@AndroidEntryPoint
class BestListFragment : Fragment() {
    private val bestListViewModel: BestListViewModel by viewModels()
    private lateinit var recyclerView: RecyclerView
    @Inject
    lateinit var repository: JokeRepository
    private var isLoading = false

    private var adapter: JokeAdapter = JokeAdapter { joke, likeBtn ->
        val newJoke = Joke(joke.text, joke.number, isLiked = true)
        repository.likeJoke(newJoke)
        if (likeBtn.isChecked) {
            repository.likeJoke(joke)
            joke.isLiked = true
            adapterChange(joke)
        } else {
            repository.dislikeJoke(joke)
            joke.isLiked = false
            adapterChange(joke)
        }
    }

    fun adapterChange(joke: Joke) {
        adapter.notifyItemChanged(adapter.currentList.indexOf(joke))
    }

    private lateinit var layoutManager: LinearLayoutManager

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_best_list, container, false)
        createRecyclerView(view)
        return view
    }

    private fun createRecyclerView(view: View) {
        recyclerView = view.findViewById(R.id.best_joke_recycler_view)
        layoutManager = LinearLayoutManager(context)
        recyclerView.layoutManager = layoutManager
        recyclerView.adapter = adapter
        addScrollerListener()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        createListeners()
    }

    private fun createListeners() {
        bestListViewModel.jokeLiveData.observe(
            viewLifecycleOwner
        ) { jokes ->
            adapter.submitList(ArrayList(jokes))
            Log.d(TAG, jokes.size.toString())
        }

        bestListViewModel.isLoadingLiveData.observe(
            viewLifecycleOwner
        ) { isLoading ->
            this.isLoading = isLoading
            if (isLoading) {
                adapter.addLoadingView()
            } else {
                adapter.deleteLoadingView()
            }
        }

        bestListViewModel.jokesFromDBLiveData.observe(
            viewLifecycleOwner
        ) { list ->
            if (list.isNotEmpty() && adapter.currentList.isNotEmpty()) {
                adapter.currentList.forEach {
                    if (it != null) it.isLiked = it in list
                }
            } else {
                adapter.currentList.forEach {
                    if (it != null) it.isLiked = false
                }
            }
            adapter.notifyDataSetChanged()
        }
    }

    private fun addScrollerListener() {
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                if (!isLoading) {
                    if (layoutManager.findLastVisibleItemPosition() ==
                        adapter.currentList.size - LOAD_THRESHOLD
                    ) {
                        bestListViewModel.getBestJokeList()
                    }
                }
            }
        })
    }

    companion object {
        private var INSTANCE: BestListFragment? = null

        fun initialize() {
            if (INSTANCE == null) {
                INSTANCE = BestListFragment()
            }
        }

        fun get(): BestListFragment {
            return INSTANCE ?: throw IllegalStateException("JokeRepository must be initialized")
        }
    }
}

Class BestListFragment presents a snippet that displays a list of the best jokes.

Constant TAG defines a label to use in logging. Constant LOAD_THRESHOLD defines the scroll threshold at which the next set of jokes will be loaded.

Inside BestListFragment required properties are declared. bestListViewModel represents an instance BestListViewModelThe that will be used to get the data. repository represents an instance JokeRepositoryprovided via dependency injection. isLoading indicates whether data is currently being loaded.

adapter represents an instance JokeAdapterwhich will be used to associate joke data with RecyclerView. It defines a function onItemClicked, which is called when clicking on the “like” button for a joke. Inside this function, a new instance of the joke is created with the updated state isLiked and the corresponding methods are called likeJoke or dislikeJoke repository. The adapter is then updated by calling the function adapterChange.

createRecyclerView creates RecyclerView and sets it up. He uses LinearLayoutManager as a layout manager, installs an adapter and adds a scroll listener.

In method onViewCreated listeners are set for jokeLiveData, isLoadingLiveData And jokesFromDBLiveData from bestListViewModel. When the data changes, the appropriate updates are made in the adapter.

addScrollerListener adds a scroll listener to RecyclerView. When the scroll threshold is reached and if there is no current data load, a request is made to get the next set of jokes via bestListViewModel.

Companion declares static methods initialize And get to create and get an instance BestListFragment. This is done to ensure a single fragment instance.

private const val TAG = "BestListViewModel"

@HiltViewModel
class BestListViewModel @Inject constructor(
    private val repository: JokeRepository,
    private val jokeFetcher: JokeFetcher
) : ViewModel() {
    private val _jokeLiveData: MutableLiveData<MutableList<Joke>> = MutableLiveData()
    val jokeLiveData: LiveData<MutableList<Joke>> = _jokeLiveData

    private val _isLoadingLiveData: MutableLiveData<Boolean> = MutableLiveData()
    val isLoadingLiveData: LiveData<Boolean> = _isLoadingLiveData

    val jokesFromDBLiveData: LiveData<List<Joke>> = repository.getLikeJokes()

    var currentJokeList = mutableListOf<Joke>()

    private var alreadyLoaded = 0
    private var buffer = 7

    init {
        getBestJokeList()
    }

    fun getBestJokeList() {
        MainScope().launch {
            _isLoadingLiveData.value = true
            var listOfBestJokeNumbers = jokeFetcher.getBestJokesListAsync().await()
            listOfBestJokeNumbers = listOfBestJokeNumbers
                .filter { it !in listOfBestJokeNumbers.take(alreadyLoaded) }
            alreadyLoaded += buffer
            for (currentNumber in listOfBestJokeNumbers.take(buffer)) {
                val newJoke = jokeFetcher.fetchJokeByNumberAsync(currentNumber).await()
                currentJokeList.add(newJoke)
            }
            _isLoadingLiveData.value = false
            _jokeLiveData.value = currentJokeList
        }
    }
}

Property jokesFromDBLiveData is LiveDatawhich gets a list of jokes from the database using the method getLikeJokes() from repository.

Property currentJokeList represents the current list of jokes.

In the block init a method call is made getBestJokeList() to download the initial set of jokes.

Inside the coroutine, the loading state is first set via _isLoadingLiveDatathen a request is made to jokeFetcher for a list of the best jokes through jokeFetcher.getBestJokesListAsync().await(). The list is then filtered to exclude jokes already loaded, and it cycles through the remaining numbers of jokes. For each joke number, a query is made to jokeFetcher to get an anecdote using jokeFetcher.fetchJokeByNumberAsync(currentNumber).await()and the resulting anecdote is added to currentJokeList. At the end of the cycle, the loading state is set and updated jokeLiveData With currentJokeList.

Conclusion

Here is such a brief (maybe not quite an overview of the application). The full version can be found on GitHub.

https://github.com/K0RGA/Aneckdoter

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *