Modernize file listing and search in AutofillFilterActivity (#683)

* WIP: Modernize file listing and search

* Refactor

* Implement fuzzy search

* Improve ViewModel API and introduce Adapter

* Integrate new search into AutofillFilterActivity and dedebounce

* Improve no results layout

* Reformat

* Highlight origin in FileBased directory structure

* Extract highlighting logic into DirectoryStructure

* Trim whitespace before searching

* Remove debug logging

* Remove more debug logging

* Organize imports

* Remove imports

* Update app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt

Co-Authored-By: Harsh Shandilya <me@msfjarvis.dev>

* Address review comments

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Fabian Henneke 2020-04-06 22:56:52 +02:00 committed by GitHub
parent 034babcbf4
commit e3a49e2632
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 393 additions and 95 deletions

View File

@ -78,11 +78,14 @@ android {
dependencies {
implementation deps.androidx.annotation
implementation deps.androidx.activity_ktx
implementation deps.androidx.appcompat
implementation deps.androidx.biometric
implementation deps.androidx.constraint_layout
implementation deps.androidx.core_ktx
implementation deps.androidx.documentfile
implementation deps.androidx.fragment_ktx
implementation deps.androidx.lifecycle_livedata_ktx
implementation deps.androidx.lifecycle_runtime_ktx
implementation deps.androidx.local_broadcast_manager
implementation deps.androidx.material
@ -102,7 +105,6 @@ dependencies {
implementation deps.third_party.jsch
implementation deps.third_party.openpgp_ktx
implementation deps.third_party.publicsuffixlist
implementation deps.third_party.recyclical
implementation deps.third_party.ssh_auth
implementation deps.third_party.timber
implementation deps.third_party.timberkt

View File

@ -159,11 +159,12 @@ class PasswordFragment : Fragment() {
/** refreshes the adapter with the latest opened category */
fun refreshAdapter() {
recyclerAdapter.clear()
val currentDir = if (pathStack.isEmpty()) getRepositoryDirectory(requireContext()) else pathStack.peek()
recyclerAdapter.addAll(
if (pathStack.isEmpty())
getPasswords(getRepositoryDirectory(requireContext()), sortOrder)
getPasswords(currentDir, sortOrder)
else
getPasswords(pathStack.peek(), getRepositoryDirectory(requireContext()), sortOrder)
getPasswords(currentDir, getRepositoryDirectory(requireContext()), sortOrder)
)
}

View File

@ -0,0 +1,274 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore
import android.app.Application
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
import java.text.Collator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.yield
private fun File.toPasswordItem(root: File) = if (isFile)
PasswordItem.newPassword(name, this, root)
else
PasswordItem.newCategory(name, this, root)
private fun PasswordItem.fuzzyMatch(filter: String): Int {
var i = 0
var j = 0
var score = 0
var bonus = 0
var bonusIncrement = 0
val toMatch = longName
while (i < filter.length && j < toMatch.length) {
when {
filter[i].isWhitespace() -> i++
filter[i].toLowerCase() == toMatch[j].toLowerCase() -> {
i++
bonusIncrement += 1
bonus += bonusIncrement
score += bonus
}
else -> {
bonus = 0
bonusIncrement = 0
}
}
j++
}
return if (i == filter.length) score else 0
}
private val CaseInsensitiveComparator = Collator.getInstance().apply {
strength = Collator.PRIMARY
}
private fun PasswordItem.Companion.makeComparator(
typeSortOrder: PasswordRepository.PasswordSortOrder,
directoryStructure: DirectoryStructure
): Comparator<PasswordItem> {
return when (typeSortOrder) {
PasswordRepository.PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
PasswordRepository.PasswordSortOrder.INDEPENDENT -> compareBy<PasswordItem>()
PasswordRepository.PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
}
.then(compareBy(nullsLast(CaseInsensitiveComparator)) {
directoryStructure.getIdentifierFor(
it.file
)
})
.then(compareBy(CaseInsensitiveComparator) { directoryStructure.getUsernameFor(it.file) })
}
enum class FilterMode {
ListOnly,
StrictDomain,
Fuzzy
}
enum class SearchMode {
RecursivelyInSubdirectories,
InCurrentDirectoryOnly
}
private data class SearchAction(
val currentDir: File,
val filter: String,
val filterMode: FilterMode,
val searchMode: SearchMode,
val listFilesOnly: Boolean
)
@ExperimentalCoroutinesApi
@FlowPreview
class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) {
private val root = PasswordRepository.getRepositoryDirectory(application)
private val settings = PreferenceManager.getDefaultSharedPreferences(getApplication())
private val showHiddenDirs = settings.getBoolean("show_hidden_folders", false)
private val searchFromRoot = settings.getBoolean("search_from_root", false)
private val defaultSearchMode = if (settings.getBoolean("filter_recursively", true)) {
SearchMode.RecursivelyInSubdirectories
} else {
SearchMode.InCurrentDirectoryOnly
}
private val typeSortOrder = PasswordRepository.PasswordSortOrder.getSortOrder(settings)
private val directoryStructure = AutofillPreferences.directoryStructure(application)
private val itemComparator = PasswordItem.makeComparator(typeSortOrder, directoryStructure)
private val searchAction = MutableLiveData(
SearchAction(
currentDir = root,
filter = "",
filterMode = FilterMode.ListOnly,
searchMode = SearchMode.InCurrentDirectoryOnly,
listFilesOnly = true
)
)
private val searchActionFlow = searchAction.asFlow()
.map { it.copy(filter = it.filter.trim()) }
.distinctUntilChanged()
private val passwordItemsFlow = searchActionFlow
.mapLatest { searchAction ->
val dirToSearch =
if (searchFromRoot && searchAction.filterMode != FilterMode.ListOnly) root else searchAction.currentDir
val listResultFlow = when (searchAction.searchMode) {
SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(dirToSearch)
SearchMode.InCurrentDirectoryOnly -> listFiles(dirToSearch)
}
val prefilteredResultFlow =
if (searchAction.listFilesOnly) listResultFlow.filter { it.isFile } else listResultFlow
val filterModeToUse =
if (searchAction.filter == "") FilterMode.ListOnly else searchAction.filterMode
when (filterModeToUse) {
FilterMode.ListOnly -> {
prefilteredResultFlow
.map { it.toPasswordItem(root) }
.toList()
.sortedWith(itemComparator)
}
FilterMode.StrictDomain -> {
check(searchAction.listFilesOnly) { "Searches with StrictDomain search mode can only list files" }
prefilteredResultFlow
.filter { file ->
val toMatch =
directoryStructure.getIdentifierFor(file) ?: return@filter false
// In strict domain mode, we match
// * the search term exactly,
// * subdomains of the search term,
// * or the search term plus an arbitrary protocol.
toMatch == searchAction.filter ||
toMatch.endsWith(".${searchAction.filter}") ||
toMatch.endsWith("://${searchAction.filter}")
}
.map { it.toPasswordItem(root) }
.toList()
.sortedWith(itemComparator)
}
FilterMode.Fuzzy -> {
prefilteredResultFlow
.map {
val item = it.toPasswordItem(root)
Pair(item.fuzzyMatch(searchAction.filter), item)
}
.filter { it.first > 0 }
.toList()
.sortedWith(
compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(
itemComparator
) { it.second })
.map { it.second }
}
}
}
val passwordItemsList = passwordItemsFlow.asLiveData(Dispatchers.IO)
fun list(currentDir: File) {
require(currentDir.isDirectory) { "Can only list files in a directory" }
searchAction.postValue(
SearchAction(
filter = "",
currentDir = currentDir,
filterMode = FilterMode.ListOnly,
searchMode = SearchMode.InCurrentDirectoryOnly,
listFilesOnly = false
)
)
}
fun search(
filter: String,
currentDir: File? = null,
filterMode: FilterMode = FilterMode.Fuzzy,
searchMode: SearchMode? = null,
listFilesOnly: Boolean = false
) {
require(currentDir?.isDirectory != false) { "Can only search in a directory" }
val action = SearchAction(
filter = filter.trim(),
currentDir = currentDir ?: searchAction.value!!.currentDir,
filterMode = filterMode,
searchMode = searchMode ?: defaultSearchMode,
listFilesOnly = listFilesOnly
)
searchAction.postValue(action)
}
private fun shouldTake(file: File) = with(file) {
if (isDirectory) {
!isHidden || showHiddenDirs
} else {
!isHidden && file.extension == "gpg"
}
}
private fun listFiles(dir: File): Flow<File> {
return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow()
}
private fun listFilesRecursively(dir: File): Flow<File> {
return dir
.walkTopDown().onEnter { file -> shouldTake(file) }
.asFlow()
.map {
yield()
it
}
.filter { file -> shouldTake(file) }
}
}
private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
oldItem.file.absolutePath == newItem.file.absolutePath
override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
}
class DelegatedSearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
private val layoutRes: Int,
private val viewHolderCreator: (view: View) -> T,
private val viewHolderBinder: T.(item: PasswordItem) -> Unit
) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
val view = LayoutInflater.from(parent.context)
.inflate(layoutRes, parent, false)
return viewHolderCreator(view)
}
override fun onBindViewHolder(holder: T, position: Int) {
viewHolderBinder.invoke(holder, getItem(position))
}
}

View File

@ -25,8 +25,26 @@ enum class DirectoryStructure(val value: String) {
}
fun getIdentifierFor(file: File) = when (this) {
FileBased -> file.parentFile?.name
DirectoryBased -> file.parentFile?.parentFile?.name
FileBased -> file.parentFile.name
DirectoryBased -> file.parentFile.parentFile?.name
}
/**
* Returns the path components of [file] until right before the component that contains the
* origin identifier according to the current [DirectoryStructure].
*
* Examples:
* - /work/example.org/john@doe.org --> /work (FileBased)
* - /work/example.org/john@doe.org/password --> /work (DirectoryBased)
*/
fun getPathToIdentifierFor(file: File) = when (this) {
FileBased -> file.parentFile.parent
DirectoryBased -> file.parentFile.parentFile?.parent
}
fun getAccountPartFor(file: File) = when (this) {
FileBased -> file.nameWithoutExtension
DirectoryBased -> "${file.parentFile.name}/${file.nameWithoutExtension}"
}
@RequiresApi(Build.VERSION_CODES.O)

View File

@ -13,26 +13,33 @@ import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.underline
import androidx.core.widget.addTextChangedListener
import androidx.preference.PreferenceManager
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DividerItemDecoration
import com.afollestad.recyclical.datasource.dataSourceOf
import com.afollestad.recyclical.setup
import com.afollestad.recyclical.withItem
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.DelegatedSearchableRepositoryAdapter
import com.zeapo.pwdstore.FilterMode
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.SearchMode
import com.zeapo.pwdstore.SearchableRepositoryViewModel
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
import java.nio.file.Paths
import java.util.Locale
import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@FlowPreview
@ExperimentalCoroutinesApi
@TargetApi(Build.VERSION_CODES.O)
class AutofillFilterView : AppCompatActivity() {
@ -66,15 +73,13 @@ class AutofillFilterView : AppCompatActivity() {
}
}
private val dataSource = dataSourceOf()
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val sortOrder
get() = PasswordRepository.PasswordSortOrder.getSortOrder(preferences)
private lateinit var formOrigin: FormOrigin
private lateinit var repositoryRoot: File
private lateinit var directoryStructure: DirectoryStructure
private val model: SearchableRepositoryViewModel by viewModels {
ViewModelProvider.AndroidViewModelFactory(application)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_oreo_autofill_filter)
@ -103,7 +108,6 @@ class AutofillFilterView : AppCompatActivity() {
return
}
}
repositoryRoot = PasswordRepository.getRepositoryDirectory(this)
directoryStructure = AutofillPreferences.directoryStructure(this)
supportActionBar?.hide()
@ -112,35 +116,55 @@ class AutofillFilterView : AppCompatActivity() {
}
private fun bindUI() {
// setup is an extension method provided by recyclical
rvPassword.setup {
withDataSource(dataSource)
withItem<PasswordItem, PasswordViewHolder>(R.layout.oreo_autofill_filter_row) {
onBind(::PasswordViewHolder) { _, item ->
when (directoryStructure) {
DirectoryStructure.FileBased -> {
title.text = item.file.relativeTo(item.rootDir).parent
subtitle.text = item.file.nameWithoutExtension
val searchableAdapter = DelegatedSearchableRepositoryAdapter(
R.layout.oreo_autofill_filter_row,
::PasswordViewHolder
) { item ->
val file = item.file.relativeTo(item.rootDir)
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
val identifier = directoryStructure.getIdentifierFor(file) ?: "INVALID"
val accountPart = directoryStructure.getAccountPartFor(file)
title.text = buildSpannedString {
pathToIdentifier?.let { append("$it/") }
bold { underline { append(identifier) } }
}
DirectoryStructure.DirectoryBased -> {
title.text =
item.file.relativeTo(item.rootDir).parentFile?.parent ?: "/INVALID"
subtitle.text =
Paths.get(item.file.parentFile.name, item.file.nameWithoutExtension)
.toString()
subtitle.text = accountPart
itemView.setOnClickListener { decryptAndFill(item) }
}
rvPassword.apply {
adapter = searchableAdapter
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
}
onClick { decryptAndFill(item) }
}
}
rvPassword.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
search.addTextChangedListener { recursiveFilter(it.toString(), strict = false) }
val initialFilter =
formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
val initialFilter = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
search.setText(initialFilter, TextView.BufferType.EDITABLE)
recursiveFilter(initialFilter, strict = formOrigin is FormOrigin.Web)
val filterMode =
if (formOrigin is FormOrigin.Web) FilterMode.StrictDomain else FilterMode.Fuzzy
model.search(
initialFilter,
filterMode = filterMode,
searchMode = SearchMode.RecursivelyInSubdirectories,
listFilesOnly = true
)
search.addTextChangedListener {
model.search(
it.toString(),
filterMode = FilterMode.Fuzzy,
searchMode = SearchMode.RecursivelyInSubdirectories,
listFilesOnly = true
)
}
model.passwordItemsList.observe(
this,
Observer { list ->
searchableAdapter.submitList(list)
// Switch RecyclerView out for a "no results" message if the new list is empty and
// the message is not yet shown (and vice versa).
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id))
rvPasswordSwitcher.showNext()
})
shouldMatch.text = getString(
R.string.oreo_autofill_match_with,
@ -172,43 +196,4 @@ class AutofillFilterView : AppCompatActivity() {
finish()
}
}
private fun File.matches(filter: String, strict: Boolean): Boolean {
return if (strict) {
val toMatch = directoryStructure.getIdentifierFor(this) ?: return false
// In strict mode, we match
// * the search term exactly,
// * subdomains of the search term,
// * or the search term plus an arbitrary protocol.
toMatch == filter || toMatch.endsWith(".$filter") || toMatch.endsWith("://$filter")
} else {
val toMatch =
"${relativeTo(repositoryRoot).path}/$nameWithoutExtension".toLowerCase(Locale.getDefault())
toMatch.contains(filter.toLowerCase(Locale.getDefault()))
}
}
private fun recursiveFilter(filter: String, dir: File? = null, strict: Boolean = true) {
// on the root the pathStack is empty
val passwordItems = if (dir == null) {
PasswordRepository.getPasswords(repositoryRoot, sortOrder)
} else {
PasswordRepository.getPasswords(dir, repositoryRoot, sortOrder)
}
for (item in passwordItems) {
if (item.type == PasswordItem.TYPE_CATEGORY) {
recursiveFilter(filter, item.file, strict = strict)
} else {
// TODO: Implement fuzzy search if strict == false?
val matches = item.file.matches(filter, strict = strict)
val inAdapter = dataSource.contains(item)
if (matches && !inAdapter) {
dataSource.add(item)
} else if (!matches && inAdapter) {
dataSource.remove(item)
}
}
}
}
}

View File

@ -9,7 +9,7 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.zeapo.pwdstore.R
class PasswordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = itemView.findViewById(R.id.title)
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
}

View File

@ -28,7 +28,7 @@
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginEnd="@dimen/activity_horizontal_margin"
app:endIconMode="clear_text"
app:layout_constraintBottom_toTopOf="@id/rvPassword"
app:layout_constraintBottom_toTopOf="@id/rvPasswordSwitcher"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cover">
@ -42,20 +42,35 @@
android:inputType="text"
tools:text="example.com" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvPassword"
<ViewSwitcher
android:id="@+id/rvPasswordSwitcher"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:scrollbars="vertical"
app:layout_constraintBottom_toTopOf="@id/shouldMatch"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchLayout"
android:layout_height="0dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvPassword"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
tools:itemCount="5"
tools:listitem="@layout/password_row_layout" />
<TextView
android:id="@+id/rvPasswordEmpty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:text="@string/oreo_autofill_filter_no_results"
android:textSize="18sp" />
</ViewSwitcher>
<Switch
android:id="@+id/shouldMatch"
android:layout_width="0dp"
@ -67,7 +82,7 @@
app:layout_constraintBottom_toTopOf="@id/shouldClear"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rvPassword"
app:layout_constraintTop_toBottomOf="@id/rvPasswordSwitcher"
app:layout_constraintVertical_bias="1.0"
tools:text="Match with example.org" />

View File

@ -247,6 +247,7 @@
<!-- Oreo Autofill -->
<string name="oreo_autofill_match_with">Match with %1$s</string>
<string name="oreo_autofill_matches_clear_existing">Clear existing matches</string>
<string name="oreo_autofill_filter_no_results">No results.</string>
<string name="oreo_autofill_search_in_store">Search in store…</string>
<string name="oreo_autofill_save_internal_error">Save failed due to an internal error</string>
<string name="oreo_autofill_save_app_not_supported">This app is currently not supported</string>

View File

@ -29,11 +29,14 @@ ext.deps = [
androidx: [
annotation: 'androidx.annotation:annotation:1.1.0',
activity_ktx: 'androidx.activity:activity-ktx:1.1.0',
appcompat: 'androidx.appcompat:appcompat:1.2.0-alpha03',
biometric: 'androidx.biometric:biometric:1.0.1',
constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta4',
core_ktx: 'androidx.core:core-ktx:1.3.0-alpha02',
documentfile: 'androidx.documentfile:documentfile:1.0.1',
fragment_ktx: 'androidx.fragment:fragment-ktx:1.1.0',
lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01',
lifecycle_runtime_ktx: 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha01',
local_broadcast_manager: 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01',
material: 'com.google.android.material:material:1.2.0-alpha05',
@ -55,7 +58,6 @@ ext.deps = [
// a reference to the latest version is warranted.
// See: https://github.com/mozilla-mobile/android-components/blob/master/components/lib/publicsuffixlist/README.md
publicsuffixlist: 'org.mozilla.components:lib-publicsuffixlist:+',
recyclical: 'com.afollestad:recyclical:1.1.1',
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
timber: 'com.jakewharton.timber:timber:4.7.1',
timberkt: 'com.github.ajalt:timberkt:1.5.1',