Add biometric lock times

This commit is contained in:
Jobobby04 2020-09-06 21:30:22 -04:00
parent 86defec57c
commit 035fb9e755
12 changed files with 702 additions and 2 deletions

View File

@ -310,4 +310,6 @@ object PreferenceKeys {
const val saveChaptersAsCBZLevel = "save_chapter_as_cbz_level"
const val allowLocalSourceHiddenFolders = "allow_local_source_hidden_folders"
const val biometricTimeRanges = "biometric_time_ranges"
}

View File

@ -415,4 +415,6 @@ class PreferencesHelper(val context: Context) {
fun saveChaptersAsCBZLevel() = flowPrefs.getInt(Keys.saveChaptersAsCBZLevel, 0)
fun allowLocalSourceHiddenFolders() = flowPrefs.getBoolean(Keys.allowLocalSourceHiddenFolders, false)
fun biometricTimeRanges() = flowPrefs.getStringSet(Keys.biometricTimeRanges, mutableSetOf())
}

View File

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.ui.category.biometric
import eu.davidea.flexibleadapter.FlexibleAdapter
/**
* Custom adapter for categories.
*
* @param controller The containing controller.
*/
class BiometricTimesAdapter(controller: BiometricTimesController) :
FlexibleAdapter<BiometricTimesItem>(null, controller, true) {
/**
* Clears the active selections from the list and the model.
*/
override fun clearSelection() {
super.clearSelection()
(0 until itemCount).forEach { getItem(it)?.isSelected = false }
}
/**
* Toggles the selection of the given position.
*
* @param position The position to toggle.
*/
override fun toggleSelection(position: Int) {
super.toggleSelection(position)
getItem(position)?.isSelected = isSelected(position)
}
}

View File

@ -0,0 +1,316 @@
package eu.kanade.tachiyomi.ui.category.biometric
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import eu.davidea.flexibleadapter.helpers.UndoHelper
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.shrinkOnScroll
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlinx.android.synthetic.main.main_activity.root_coordinator
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
/**
* Controller to manage the lock times for the biometric lock.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesController :
NucleusController<CategoriesControllerBinding, BiometricTimesPresenter>(),
FabController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
BiometricTimesCreateDialog.Listener,
UndoHelper.OnActionListener {
/**
* Object used to show ActionMode toolbar.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing biometric lock time items.
*/
private var adapter: BiometricTimesAdapter? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
/**
* Undo helper used for restoring a deleted lock time.
*/
private var undoHelper: UndoHelper? = null
/**
* Creates the presenter for this controller. Not to be manually called.
*/
override fun createPresenter() = BiometricTimesPresenter()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.biometric_lock_times)
}
/**
* Returns the view of this controller.
*
* @param inflater The layout inflater to create the view from XML.
* @param container The parent view for this one.
*/
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
binding = CategoriesControllerBinding.inflate(inflater)
return binding.root
}
/**
* Called after view inflation. Used to initialize the view.
*
* @param view The view of this controller.
*/
override fun onViewCreated(view: View) {
super.onViewCreated(view)
adapter = BiometricTimesAdapter(this@BiometricTimesController)
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.setHasFixedSize(true)
binding.recycler.adapter = adapter
adapter?.isPermanentDelete = false
actionFabScrollListener = actionFab?.shrinkOnScroll(binding.recycler)
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
actionFab = fab
fab.setText(R.string.action_add)
fab.setIconResource(R.drawable.ic_add_24dp)
fab.clicks()
.onEach {
BiometricTimesCreateDialog(this@BiometricTimesController).showDialog(router, null)
}
.launchIn(scope)
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
actionFabScrollListener?.let { binding.recycler.removeOnScrollListener(it) }
actionFab = null
}
/**
* Called when the view is being destroyed. Used to release references and remove callbacks.
*
* @param view The view of this controller.
*/
override fun onDestroyView(view: View) {
// Manually call callback to delete lock times if required
undoHelper?.onDeleteConfirmed(Snackbar.Callback.DISMISS_EVENT_MANUAL)
undoHelper = null
actionMode = null
adapter = null
super.onDestroyView(view)
}
/**
* Called from the presenter when the biometric lock times are updated.
*
* @param biometricTimeItems The new list of lock times to display.
*/
fun setBiometricTimeItems(biometricTimeItems: List<BiometricTimesItem>) {
actionMode?.finish()
adapter?.updateDataSet(biometricTimeItems)
if (biometricTimeItems.isNotEmpty()) {
binding.emptyView.hide()
val selected = biometricTimeItems.filter { it.isSelected }
if (selected.isNotEmpty()) {
selected.forEach { onItemLongClick(biometricTimeItems.indexOf(it)) }
}
} else {
binding.emptyView.show(R.string.biometric_lock_times_empty)
}
}
/**
* Called when action mode is first created. The menu supplied will be used to generate action
* buttons for the action mode.
*
* @param mode ActionMode being created.
* @param menu Menu used to populate action buttons.
* @return true if the action mode should be created, false if entering this mode should be
* aborted.
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
// Inflate menu.
mode.menuInflater.inflate(R.menu.category_selection, menu)
// Enable adapter multi selection.
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
/**
* Called to refresh an action mode's action menu whenever it is invalidated.
*
* @param mode ActionMode being prepared.
* @param menu Menu used to populate action buttons.
* @return true if the menu or action mode was updated, false otherwise.
*/
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val adapter = adapter ?: return false
val count = adapter.selectedItemCount
mode.title = count.toString()
// Show edit button only when one item is selected
val editItem = mode.menu.findItem(R.id.action_edit)
editItem.isVisible = false
return true
}
/**
* Called to report a user click on an action button.
*
* @param mode The current ActionMode.
* @param item The item that was clicked.
* @return true if this callback handled the event, false if the standard MenuItem invocation
* should continue.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val adapter = adapter ?: return false
when (item.itemId) {
R.id.action_delete -> {
undoHelper = UndoHelper(adapter, this)
undoHelper?.start(
adapter.selectedPositions, activity!!.root_coordinator,
R.string.biometric_lock_time_deleted_snack, R.string.action_undo, 3000
)
mode.finish()
}
else -> return false
}
return true
}
/**
* Called when an action mode is about to be exited and destroyed.
*
* @param mode The current ActionMode being destroyed.
*/
override fun onDestroyActionMode(mode: ActionMode) {
// Reset adapter to single selection
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
actionMode = null
}
/**
* Called when an item in the list is clicked.
*
* @param position The position of the clicked item.
* @return true if this click should enable selection mode.
*/
override fun onItemClick(view: View, position: Int): Boolean {
// Check if action mode is initialized and selected item exist.
return if (actionMode != null && position != RecyclerView.NO_POSITION) {
toggleSelection(position)
true
} else {
false
}
}
/**
* Called when an item in the list is long clicked.
*
* @param position The position of the clicked item.
*/
override fun onItemLongClick(position: Int) {
val activity = activity as? AppCompatActivity ?: return
// Check if action mode is initialized.
if (actionMode == null) {
// Initialize action mode
actionMode = activity.startSupportActionMode(this)
}
// Set item as selected
toggleSelection(position)
}
/**
* Toggle the selection state of an item.
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
*
* @param position The position of the item to toggle.
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
// Mark the position selected
adapter.toggleSelection(position)
if (adapter.selectedItemCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
/**
* Called when the undo action is clicked in the snackbar.
*
* @param action The action performed.
*/
override fun onActionCanceled(action: Int, positions: MutableList<Int>?) {
adapter?.restoreDeletedItems()
undoHelper = null
}
/**
* Called when the time to restore the items expires.
*
* @param action The action performed.
* @param event The event that triggered the action
*/
override fun onActionConfirmed(action: Int, event: Int) {
val adapter = adapter ?: return
presenter.deleteTimeRanges(adapter.deletedItems.map { it.timeRange })
undoHelper = null
}
/**
* Called from the presenter when a time range conflicts with another.
*/
fun onTimeRangeConflictsError() {
activity?.toast(R.string.biometric_lock_time_conflicts)
}
override fun startNextDialog(startTime: Duration?) {
if (startTime != null) {
BiometricTimesCreateDialog(this@BiometricTimesController, startTime).showDialog(router, null)
} else activity?.toast(R.string.biometric_lock_invalid_time_selected)
}
override fun createTimeRange(startTime: Duration?, endTime: Duration?) {
if (startTime != null && endTime != null) {
presenter.createTimeRange(TimeRange(startTime, endTime))
} else activity?.toast(R.string.biometric_lock_invalid_time_selected)
}
}

View File

@ -0,0 +1,75 @@
package eu.kanade.tachiyomi.ui.category.biometric
import android.app.Dialog
import android.os.Bundle
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.datetime.timePicker
import com.bluelinelabs.conductor.Controller
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import java.util.Calendar
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.hours
import kotlin.time.minutes
/**
* Dialog to create a new category for the library.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : BiometricTimesCreateDialog.Listener {
/**
* Name of the new category. Value updated with each input from the user.
*/
private var startTime: Duration? = null
private var endTime: Duration? = null
constructor(target: T) : this() {
targetController = target
}
constructor(target: T, startTime: Duration) : this() {
targetController = target
this.startTime = startTime
}
/**
* Called when creating the dialog for this controller.
*
* @param savedViewState The saved state of this dialog.
* @return a new dialog instance.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog(activity!!)
.title(if (startTime == null) R.string.biometric_lock_start_time else R.string.biometric_lock_end_time)
.timePicker(show24HoursView = false) { _, datetime ->
val hour = datetime.get(Calendar.HOUR_OF_DAY)
XLog.nst().d(hour)
val minute = datetime.get(Calendar.MINUTE)
XLog.nst().d(minute)
if (hour !in 0..24 || minute !in 0..60) return@timePicker
if (startTime != null) {
endTime = hour.hours + minute.minutes
} else {
startTime = hour.hours + minute.minutes
}
}
.negativeButton(android.R.string.cancel)
.positiveButton(android.R.string.ok) {
if (endTime != null) {
(targetController as? Listener)?.createTimeRange(startTime, endTime)
} else {
(targetController as? Listener)?.startNextDialog(startTime)
}
}
}
interface Listener {
fun startNextDialog(startTime: Duration?)
fun createTimeRange(startTime: Duration?, endTime: Duration?)
}
}

View File

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.ui.category.biometric
import android.view.View
import androidx.core.view.isVisible
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlin.time.ExperimentalTime
import kotlinx.android.synthetic.main.categories_item.reorder
import kotlinx.android.synthetic.main.categories_item.title
/**
* Holder used to display category items.
*
* @param view The view used by category items.
* @param adapter The adapter containing this holder.
*/
class BiometricTimesHolder(view: View, val adapter: BiometricTimesAdapter) : BaseFlexibleViewHolder(view, adapter) {
/**
* Binds this holder with the given category.
*
* @param timeRange The category to bind.
*/
@OptIn(ExperimentalTime::class)
fun bind(timeRange: TimeRange) {
// Set capitalized title.
title.text = timeRange.getFormattedString(itemView.context)
reorder.isVisible = false
}
}

View File

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.ui.category.biometric
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import kotlin.time.ExperimentalTime
/**
* Category item for a recycler view.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesItem(val timeRange: TimeRange) : AbstractFlexibleItem<BiometricTimesHolder>() {
/**
* Whether this item is currently selected.
*/
var isSelected = false
/**
* Returns the layout resource for this item.
*/
override fun getLayoutRes(): Int {
return R.layout.categories_item
}
/**
* Returns a new view holder for this item.
*
* @param view The view of this item.
* @param adapter The adapter of this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): BiometricTimesHolder {
return BiometricTimesHolder(view, adapter as BiometricTimesAdapter)
}
/**
* Binds the given view holder with this item.
*
* @param adapter The adapter of this item.
* @param holder The holder to bind.
* @param position The position of this item in the adapter.
* @param payloads List of partial changes.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: BiometricTimesHolder,
position: Int,
payloads: List<Any?>?
) {
holder.bind(timeRange)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
return false
}
override fun hashCode(): Int {
return timeRange.hashCode()
}
}

View File

@ -0,0 +1,87 @@
package eu.kanade.tachiyomi.ui.category.biometric
import android.os.Bundle
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.plusAssign
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of [BiometricTimesController]. Used to manage the categories of the library.
*/
@OptIn(ExperimentalTime::class)
class BiometricTimesPresenter : BasePresenter<BiometricTimesController>() {
/**
* List containing categories.
*/
private var timeRanges: List<TimeRange> = emptyList()
val preferences: PreferencesHelper = Injekt.get()
val scope = CoroutineScope(Job() + Dispatchers.Main)
/**
* Called when the presenter is created.
*
* @param savedState The saved state of this presenter.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
preferences.biometricTimeRanges().asFlow().onEach { prefTimeRanges ->
timeRanges = prefTimeRanges.toList()
.mapNotNull { TimeRange.fromPreferenceString(it) }.onEach { XLog.nst().d(it) }
Observable.just(timeRanges)
.map { it.map(::BiometricTimesItem) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(BiometricTimesController::setBiometricTimeItems)
}.launchIn(scope)
}
/**
* Creates and adds a new category to the database.
*
* @param name The name of the category to create.
*/
fun createTimeRange(timeRange: TimeRange) {
// Do not allow duplicate categories.
if (timeRangeConflicts(timeRange)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onTimeRangeConflictsError() })
return
}
XLog.nst().d(timeRange)
preferences.biometricTimeRanges() += timeRange.toPreferenceString()
}
/**
* Deletes the given categories from the database.
*
* @param timeRanges The list of categories to delete.
*/
fun deleteTimeRanges(timeRanges: List<TimeRange>) {
preferences.biometricTimeRanges().set(
this.timeRanges.filterNot { it in timeRanges }.map { it.toPreferenceString() }.toSet()
)
}
/**
* Returns true if a category with the given name already exists.
*/
private fun timeRangeConflicts(timeRange: TimeRange): Boolean {
return timeRanges.any { timeRange.conflictsWith(it) }
}
}

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.ui.category.biometric
import android.content.Context
import android.text.format.DateFormat
import com.elvishew.xlog.XLog
import java.util.Calendar
import java.util.Date
import java.util.SimpleTimeZone
import kotlin.math.floor
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.hours
import kotlin.time.milliseconds
import kotlin.time.minutes
@ExperimentalTime
data class TimeRange(val startTime: Duration, val endTime: Duration) {
override fun toString(): String {
val startHour = floor(startTime.inHours).roundToInt()
val startMinute = (startTime - floor(startTime.inHours).hours).inMinutes.roundToInt()
val endHour = floor(endTime.inHours).roundToInt()
val endMinute = (endTime - floor(endTime.inHours).hours).inMinutes.roundToInt()
return String.format("%02d:%02d - %02d:%02d", startHour, startMinute, endHour, endMinute)
}
fun getFormattedString(context: Context): String {
val startDate = Date(startTime.toLongMilliseconds())
val endDate = Date(endTime.toLongMilliseconds())
val format = DateFormat.getTimeFormat(context)
format.timeZone = SimpleTimeZone(0, "UTC")
return format.format(startDate) + " - " + format.format(endDate)
}
fun toPreferenceString(): String {
return "${startTime.inMinutes},${endTime.inMinutes}"
}
fun conflictsWith(other: TimeRange): Boolean {
return startTime in other.startTime..other.endTime || endTime in other.startTime..other.endTime
}
companion object {
fun fromPreferenceString(timeRange: String): TimeRange? {
return timeRange.split(",").mapNotNull { it.toDoubleOrNull() }.let {
if (it.size != 2) null else {
TimeRange(it[0].minutes, it[1].minutes)
}
}
}
}
}

View File

@ -6,7 +6,12 @@ import androidx.biometric.BiometricManager
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.category.biometric.TimeRange
import java.util.Calendar
import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.time.hours
import kotlin.time.minutes
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import uy.kohesive.injekt.injectLazy
@ -19,7 +24,10 @@ class SecureActivityDelegate(private val activity: FragmentActivity) {
preferences.secureScreen().asFlow()
.onEach {
if (it) {
activity.window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
activity.window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
@ -40,12 +48,19 @@ class SecureActivityDelegate(private val activity: FragmentActivity) {
}
}
@OptIn(ExperimentalTime::class)
private fun isAppLocked(): Boolean {
return locked &&
(
preferences.lockAppAfter().get() <= 0 ||
Date().time >= preferences.lastAppUnlock().get() + 60 * 1000 * preferences.lockAppAfter().get()
)
) && preferences.biometricTimeRanges().get().mapNotNull { TimeRange.fromPreferenceString(it) }.let { timeRanges ->
if (timeRanges.isNotEmpty()) {
val today: Calendar = Calendar.getInstance()
val now = today.get(Calendar.HOUR_OF_DAY).hours + today.get(Calendar.MINUTE).minutes
timeRanges.any { now in it.startTime..it.endTime }
} else true
}
}
companion object {

View File

@ -5,8 +5,12 @@ import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesController
import eu.kanade.tachiyomi.util.preference.defaultValue
import eu.kanade.tachiyomi.util.preference.intListPreference
import eu.kanade.tachiyomi.util.preference.onClick
import eu.kanade.tachiyomi.util.preference.preference
import eu.kanade.tachiyomi.util.preference.summaryRes
import eu.kanade.tachiyomi.util.preference.switchPreference
import eu.kanade.tachiyomi.util.preference.titleRes
@ -54,5 +58,15 @@ class SettingsSecurityController : SettingsController() {
titleRes = R.string.hide_notification_content
defaultValue = false
}
preference {
titleRes = R.string.action_edit_biometric_lock_times
val timeRanges = preferences.biometricTimeRanges().get().count()
summary = context.resources.getQuantityString(R.plurals.num_lock_times, timeRanges, timeRanges)
onClick {
router.pushController(BiometricTimesController().withFadeTransaction())
}
}
}
}

View File

@ -187,6 +187,20 @@
<string name="save_chapter_as_cbz">Save Chapters as CBZ</string>
<string name="save_chapter_as_cbz_level">CBZ Compression level</string>
<!-- Security settings -->
<string name="biometric_lock_times">Biometric lock times</string>
<string name="action_edit_biometric_lock_times">Edit lock times</string>
<string name="biometric_lock_times_empty">You have biometric lock times. Tap the plus button to create one.</string>
<string name="biometric_lock_time_conflicts">A lock time conflicts with one that already exists!</string>
<plurals name="num_lock_times">
<item quantity="one">%d lock time</item>
<item quantity="other">%d lock times</item>
</plurals>
<string name="biometric_lock_start_time">Enter starting time</string>
<string name="biometric_lock_end_time">Enter end time</string>
<string name="biometric_lock_time_deleted_snack">Biometric lock time completed</string>
<string name="biometric_lock_invalid_time_selected">Invalid time selected</string>
<!-- Reader Settings -->
<string name="download_threads">Download threads</string>
<string name="download_threads_summary">Higher values can speed up image downloading significantly, but can also trigger bans. Recommended value is 2 or 3. Current value is: %s</string>