Other Fun Stuff in the App
Of course, this app has more to it than just a Room database, because that would not be sufficient to meet our needs. Plus, it would be really boring.
The Activity and ACTION_SEND
If you launch the app from the launcher icon (or your IDE), you will see a list of bookmarks. Initially, that list will be empty, and there is no obvious way to add bookmarks to it.
However, if you open up a Web browser on the device, browse to a page, and choose the browser’s “Share” option (e.g., in an overflow menu), Bookmarker
should show up as an option. If you choose it, our activity will pop up, and you should see your bookmark:
Our activity, in the manifest, has two <intent-filter>
elements. One is the standard one to get the launcher icon. The other is for a different Intent
action: ACTION_SEND
.
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
Here we are saying that we want our activity to be started either for the standard launcher icon Intent
(ACTION_MAIN
and CATEGORY_LAUNCHER
), but also for an Intent
that has:
-
ACTION_SEND
as the action -
CATEGORY_DEFAULT
as the category -
text/plain
for the MIME type
Browsers that implement a “share” option often use that particular Intent
structure, and from there, we can get the URL of the active Web page.
In our MainActivity
, as part of onCreate()
processing, we call a saveBookmark()
method:
saveBookmark(getIntent());
saveBookmark(intent)
getIntent()
is how we get the Intent
that was used to create this activity instance — we pass that along to saveBookmark()
for processing.
saveBookmark()
will try to get the URL, and if it finds one, it will pass it along to save()
on our MainMotor
:
private void saveBookmark(Intent intent) {
if (Intent.ACTION_SEND.equals(intent.getAction())) {
String pageUrl = getIntent().getStringExtra(Intent.EXTRA_STREAM);
if (pageUrl == null) {
pageUrl = getIntent().getStringExtra(Intent.EXTRA_TEXT);
}
if (pageUrl != null &&
Uri.parse(pageUrl).getScheme().startsWith("http")) {
motor.save(pageUrl);
}
else {
Toast.makeText(this, R.string.msg_invalid_url,
Toast.LENGTH_LONG).show();
finish();
}
}
}
private fun saveBookmark(intent: Intent) {
if (Intent.ACTION_SEND == intent.action) {
val pageUrl = getIntent().getStringExtra(Intent.EXTRA_STREAM)
?: getIntent().getStringExtra(Intent.EXTRA_TEXT)
if (pageUrl != null && Uri.parse(pageUrl).scheme!!.startsWith("http")) {
motor.save(pageUrl)
} else {
Toast.makeText(this, R.string.msg_invalid_url, Toast.LENGTH_LONG).show()
finish()
}
}
}
First, we check the action of the Intent
and see if it is ACTION_SEND
. If it is, we look in two “extras” of the Intent
to try to find the URL:
- First, we check
EXTRA_STREAM
, which if it exists is always supposed to be a string representation of aUri
- If we did not find one there, we then check
EXTRA_TEXT
, which could be any sort of text
In principle, for a text/plain
ACTION_SEND
request, we should get one of those. We then see if it looks plausible as a URL (starts with http
) — if it is, we forward it to the MainMotor
via save()
, which in turn forwards it to a BookmarkRepository
and its save()
function.
The Repository
We seem to have a slight gap in our data, though. What we get from ACTION_SEND
is a URL. What we have in our BookmarkEntity
is a pageUrl
, but also a title
and an iconUrl
. We need to derive values for title
and iconUrl
given the page URL.
Making Some (J)Soup
The way a Web browser gets the title and icon for a Web page is by parsing the HTML and looking for things like a <title>
element in the <head>
element. We will need to do the same thing. Fortunately, JSoup can help.
JSoup is an HTML parser for Java (and, by extension, for Kotlin/JVM). It deals with a lot of the quirks with HTML and gives us a way to navigate the document contents to find things of interest.
This app pulls in JSoup as a dependency:
implementation 'org.jsoup:jsoup:1.13.1'
We then use JSoup to attempt to read in this Web page and find our title and icon. In the case of the Java implementation of BookmarkRepository
, that is part of a SaveLiveData
that does the work on a background thread supplied by an Executor
:
private static class SaveLiveData extends LiveData<BookmarkResult> {
private final String pageUrl;
private final Executor executor;
private final BookmarkStore store;
SaveLiveData(String pageUrl, Executor executor, BookmarkStore store) {
this.pageUrl = pageUrl;
this.executor = executor;
this.store = store;
}
@Override
protected void onActive() {
super.onActive();
executor.execute(() -> {
try {
BookmarkEntity entity = new BookmarkEntity();
Document doc = Jsoup.connect(pageUrl).get();
entity.pageUrl = pageUrl;
entity.title = doc.title();
// based on https://www.mkyong.com/java/jsoup-get-favicon-from-html-page/
String iconUrl = null;
Element candidate = doc.head().select("link[href~=.*\.(ico|png)]").first();
if (candidate == null) {
candidate = doc.head().select("meta[itemprop=image]").first();
if (candidate != null) {
iconUrl = candidate.attr("content");
}
}
else {
iconUrl = candidate.attr("href");
}
if (iconUrl != null) {
URI uri = new URI(pageUrl);
entity.iconUrl = uri.resolve(iconUrl).toString();
}
store.save(entity);
postValue(new BookmarkResult(new BookmarkModel(entity), null));
}
catch (Throwable t) {
postValue(new BookmarkResult(null, t));
}
});
}
}
In the case of Kotlin, the save()
function on BookmarkRepository
handles it directly in a coroutine:
suspend fun save(pageUrl: String) =
withContext(Dispatchers.IO) {
val db: BookmarkDatabase = BookmarkDatabase[context]
val entity = BookmarkEntity()
val doc = Jsoup.connect(pageUrl).get()
entity.pageUrl = pageUrl
entity.title = doc.title()
// based on https://www.mkyong.com/java/jsoup-get-favicon-from-html-page/
val iconUrl: String? =
doc.head().select("link[href~=.*\.(ico|png)]").first()?.attr("href")
?: doc.head().select("meta[itemprop=image]").first().attr("content")
if (iconUrl != null) {
val uri = URI(pageUrl)
entity.iconUrl = uri.resolve(iconUrl).toString()
}
db.bookmarkStore().save(entity)
BookmarkModel(entity)
}
JSoup.connect(pageUrl).get()
will synchronously retrieve the HTML page and parse it, throwing an exception if there is some sort of problem (e.g., cannot retrieve the page). The Document
object that we get back has a title()
function to get the page title, which is nice and easy. To get the icon URL, we have to use some funky code, owing to multiple standards. In general, we use head()
to get to the <head>
section of the Web page, then use select()
to use a CSS selector to find a particular element in that page, then use attr()
to retrieve an attribute from the first()
element matching that CSS selector. If we get a value, it could be an absolute URL (e.g., https://somebody.com/favicon.png
) or a relative URL (e.g., /favicon.png
). So we use java.net.URI
to get an absolute URL given the page URL and the raw icon URL.
We then put the two URLs and the title into a BookmarkEntity
and save()
it using our BookmarkStore
.
Listing the Bookmarks
MainMotor
has a corresponding MainViewState
that follows the loading/content/error pattern seen elsewhere in the book:
package com.commonsware.jetpack.bookmarker;
import java.util.List;
class MainViewState {
final List<RowState> content;
MainViewState(List<RowState> content) {
this.content = content;
}
}
package com.commonsware.jetpack.bookmarker
sealed class MainViewState {
data class Content(val rows: List<RowState>) : MainViewState()
data class Error(val throwable: Throwable) : MainViewState()
}
MainMotor
exposes a LiveData
of MainViewState
that MainActivity
uses to render the RecyclerView
of existing bookmarks. MainMotor
uses load()
on BookmarkRepository
to get the bookmarks… but this returns a list of BookmarkModel
objects. Our MainViewState
uses a list of RowState
objects to represent the data needed to render the UI for the RecyclerView
rows:
package com.commonsware.jetpack.bookmarker;
import android.text.Spanned;
import androidx.annotation.NonNull;
import androidx.core.text.HtmlCompat;
import androidx.recyclerview.widget.DiffUtil;
public class RowState {
public final Spanned title;
public final String iconUrl;
final String pageUrl;
RowState(BookmarkModel model) {
this.title =
HtmlCompat.fromHtml(model.title, HtmlCompat.FROM_HTML_MODE_COMPACT);
this.iconUrl = model.iconUrl;
this.pageUrl = model.pageUrl;
}
final static DiffUtil.ItemCallback<RowState> DIFFER =
new DiffUtil.ItemCallback<RowState>() {
@Override
public boolean areItemsTheSame(@NonNull RowState oldItem,
@NonNull RowState newItem) {
return oldItem == newItem;
}
@Override
public boolean areContentsTheSame(@NonNull RowState oldItem,
@NonNull RowState newItem) {
return oldItem.title.toString().equals(newItem.title.toString());
}
};
}
package com.commonsware.jetpack.bookmarker
import android.text.Spanned
import androidx.core.text.HtmlCompat
import androidx.recyclerview.widget.DiffUtil
class RowState(model: BookmarkModel) {
val title: Spanned =
HtmlCompat.fromHtml(model.title ?: "", HtmlCompat.FROM_HTML_MODE_COMPACT)
val iconUrl = model.iconUrl
val pageUrl = model.pageUrl
companion object {
val DIFFER: DiffUtil.ItemCallback<RowState> =
object : DiffUtil.ItemCallback<RowState>() {
override fun areItemsTheSame(
oldItem: RowState,
newItem: RowState
): Boolean {
return oldItem === newItem
}
override fun areContentsTheSame(
oldItem: RowState,
newItem: RowState
): Boolean {
return oldItem.title.toString() == newItem.title.toString()
}
}
}
}
To map from BookmarkModel
to RowState
, in Java, MainMotor
uses Transformations.map()
. This takes a LiveData
and a transformation lambda expression, one that can convert between two objects types (e.g., from a list of BookmarkModel
to a list of RowState
). map()
returns another LiveData
that emits the converted objects. In our case, we use it to map between the list of BookmarkModel
objects and a MainViewState
wrapping our list of RowState
objects:
package com.commonsware.jetpack.bookmarker;
import android.app.Application;
import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.Transformations;
public class MainMotor extends AndroidViewModel {
private final BookmarkRepository repo;
private MediatorLiveData<Event<BookmarkResult>> saveEvents = new MediatorLiveData<>();
private LiveData<Event<BookmarkResult>> lastSave;
final LiveData<MainViewState> states;
public MainMotor(@NonNull Application application) {
super(application);
repo = BookmarkRepository.get(application);
states = Transformations.map(repo.load(),
models -> {
ArrayList<RowState> content = new ArrayList<>();
for (BookmarkModel model : models) {
content.add(new RowState(model));
}
return new MainViewState(content);
});
}
LiveData<Event<BookmarkResult>> getSaveEvents() {
return saveEvents;
}
void save(String pageUrl) {
saveEvents.removeSource(lastSave);
lastSave = Transformations.map(repo.save(pageUrl), Event::new);
saveEvents.addSource(lastSave, event -> saveEvents.setValue(event));
}
}
In Kotlin, BookmarkRepository
is exposing a Flow
rather than a LiveData
. So, we can use the standard map()
operator on Flow
to convert our list of models into a MainViewState.Content
, then use an asLiveData()
extension function supplied by the Jetpack to convert that Flow
into a LiveData
:
package com.commonsware.jetpack.bookmarker
import androidx.lifecycle.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainMotor(private val repo: BookmarkRepository) : ViewModel() {
private val _saveEvents = MutableLiveData<Event<BookmarkResult>>()
val saveEvents: LiveData<Event<BookmarkResult>> = _saveEvents
val states: LiveData<MainViewState> = repo.load()
.map { models -> MainViewState.Content(models.map { RowState(it) }) }
.asLiveData()
fun save(pageUrl: String) {
viewModelScope.launch(Dispatchers.Main) {
_saveEvents.value = try {
val model = repo.save(pageUrl)
Event(BookmarkResult(model, null))
} catch (t: Throwable) {
Event(BookmarkResult(null, t))
}
}
}
}
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.