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:
Implement getting jokes.
Implement saving favorites.
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 JokeAdapter
which 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 ListAdapter
which 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 LoadHolder
which 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 BestListViewModel
The that will be used to get the data. repository
represents an instance JokeRepository
provided via dependency injection. isLoading
indicates whether data is currently being loaded.
adapter
represents an instance JokeAdapter
which 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 LiveData
which 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 _isLoadingLiveData
then 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.