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:

Bookmarker, Showing a Few Bookmarks
Bookmarker, Showing a Few Bookmarks

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:

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:

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.