Commit aed7a577 authored by Christophe Henry's avatar Christophe Henry

Disable auto-fetch on app start and display last fetch date

parent 41dfb2cf
......@@ -77,7 +77,7 @@ pipeline {
stage("Unit tests") {
steps {
gitlabCommitStatus("Unit tests") {
sh "./gradlew testReleaseUnitTest --info"
sh "./gradlew testDebugUnitTest --info"
junit "**/test-results/**/*.xml"
publishHTML([
allowMissing : false,
......
......@@ -112,6 +112,7 @@ dependencies {
def roomigrant_version = "0.1.7"
def jackson_version = "2.10.3"
def espresso_version = "3.2.0"
def espresso_idling_version = "3.1.0"
def android_navigation = "1.0.0"
def jsoup_version = "1.13.1"
def acraVersion = "5.5.0"
......@@ -121,11 +122,11 @@ dependencies {
def okhttp_version = "4.4.1"
def work_version = "2.3.3"
def mockk_version = "1.9.3"
def hamcrest_version = "2.2"
// Linter
ktlint "com.github.shyiko:ktlint:0.31.0"
implementation "androidx.legacy:legacy-support-v4:1.0.0"
implementation fileTree(include: ["*.jar"], dir: "libs")
// Kotlin stuff
......@@ -133,27 +134,22 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// AndroidX
implementation "androidx.core:core-ktx:1.2.0"
implementation "androidx.appcompat:appcompat:$appcompat_version"
implementation "androidx.appcompat:appcompat-resources:$appcompat_version"
implementation "androidx.activity:activity-ktx:$activity_version"
implementation "androidx.fragment:fragment-ktx:$fragment_version"
implementation "androidx.core:core-ktx:1.2.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "androidx.preference:preference-ktx:1.1.0"
implementation "com.google.android.material:material:1.2.0-alpha05"
// AndroidX testing
debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"
kapt "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
// Navigation
implementation "android.arch.navigation:navigation-fragment-ktx:$android_navigation"
......@@ -164,7 +160,7 @@ dependencies {
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-rxjava2:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
implementation "com.github.MatrixDev.Roomigrant:RoomigrantLib:$roomigrant_version"
kapt "com.github.MatrixDev.Roomigrant:RoomigrantCompiler:$roomigrant_version"
......@@ -198,9 +194,9 @@ dependencies {
// Tests
testImplementation "junit:junit:4.13"
testImplementation "org.hamcrest:hamcrest-library:2.2"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
testImplementation "com.github.javafaker:javafaker:1.0.2"
debugImplementation "org.hamcrest:hamcrest-library:$hamcrest_version"
debugImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
debugImplementation "com.github.javafaker:javafaker:1.0.2"
/*
* org.json:json is used with explicit permission of its copyright holders :
*
......@@ -219,18 +215,31 @@ dependencies {
* The Software shall be used for Good, not Evil.
*/
testImplementation "org.json:json:20190722"
testImplementation "io.mockk:mockk:$mockk_version"
androidTestImplementation "com.github.javafaker:javafaker:1.0.2"
androidTestImplementation "androidx.test:rules:$android_test"
androidTestImplementation "androidx.test:runner:$android_test"
// TODO: Workaround; See https://github.com/mockk/mockk/issues/281#issuecomment-479238315
debugImplementation("io.mockk:mockk:$mockk_version") { exclude module: "objenesis" }
debugImplementation "org.objenesis:objenesis:2.6"
debugImplementation "androidx.test:core:$android_test"
debugImplementation "androidx.test:rules:$android_test"
debugImplementation "androidx.test:runner:$android_test"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
// Espresso
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "androidx.work:work-testing:$work_version"
// TODO: Workaround; See https://github.com/mockk/mockk/issues/281#issuecomment-479238315
androidTestImplementation("io.mockk:mockk:$mockk_version") { exclude module: "objenesis" }
androidTestImplementation "org.objenesis:objenesis:2.6"
androidTestImplementation "androidx.test.espresso:espresso-web:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_version"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espresso_idling_version"
androidTestImplementation "androidx.test.espresso:espresso-idling-resource:$espresso_idling_version"
debugImplementation("androidx.fragment:fragment-testing:$fragment_version") {
exclude group: "androidx.test", module: "core"
}
androidTestImplementation "androidx.work:work-testing:$work_version"
androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
androidTestImplementation "androidx.room:room-testing:$room_version"
// Debug
debugImplementation "com.facebook.stetho:stetho:1.5.1"
......
......@@ -19,6 +19,7 @@ import androidx.test.rule.ActivityTestRule
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.utils.FreshRSSBaseTest
import junit.framework.TestCase.assertFalse
import org.junit.Rule
import org.junit.Test
......@@ -27,14 +28,14 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class MainActivityTest {
class MainActivityTest: FreshRSSBaseTest() {
@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java)
@get:Rule
var exceptionRule = ExpectedException.none()
var exceptionRule = ExpectedException.none()!!
@Test
fun `drawer-is-closed-when-adding-a-new-subscription`() {
......
package fr.chenry.android.freshrss.components.articles
import android.app.Activity.RESULT_OK
import android.app.Instrumentation
import android.content.Intent
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.*
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.DriverAtoms.getText
import androidx.test.espresso.web.webdriver.Locator
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread
import fr.chenry.android.freshrss.MainNavDirections
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.activities.MainActivity
import fr.chenry.android.freshrss.components.articles.webviewutils.FRSSWebView
import fr.chenry.android.freshrss.components.articles.webviewutils.ShareIntent
import fr.chenry.android.freshrss.utils.*
import fr.chenry.android.freshrss.utils.factories.ArticleFactory
import fr.chenry.android.freshrss.utils.factories.SubscriptionFactory
import kotlinx.coroutines.runBlocking
import org.hamcrest.Matcher
import org.hamcrest.Matchers.*
import org.junit.*
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class ArticleDetailFragmentTest: FreshRSSBaseTest() {
private val bundle = ArticleDetailFragmentArgs(application.article.id).toBundle()
@get:Rule
val activityRule = IntentsTestRule(MainActivity::class.java)
@Test
fun `should-display-article`() {
launchFragmentInContainer<ArticleDetailFragment>(
themeResId = R.style.AppTheme,
fragmentArgs = bundle
)
onWebView(withClassName(containsString(FRSSWebView::class.simpleName)))
.forceJavascriptEnabled()
.withElement(findElement(Locator.ID, "article-title"))
.check(webMatches(getText(), equalTo(application.article.title)))
}
@Test
fun `should-be-able-to-share`() {
fun chooser(vararg matchers: Matcher<Intent?>?): Matcher<Intent?>? {
return allOf(hasAction(Intent.ACTION_CHOOSER), hasExtra(`is`(Intent.EXTRA_INTENT), allOf(*matchers)))
}
launchFragmentInContainer<ArticleDetailFragment>(
themeResId = R.style.AppTheme,
fragmentArgs = bundle
)
val expectedExtraText = ShareIntent.format(ShareIntent.getTemplateAttributes(application.feed.title, application.article))
intending(any(Intent::class.java)).respondWithFunction {
Instrumentation.ActivityResult(RESULT_OK, Intent())
}
onView(withId(R.id.fab_share)).perform(click())
intended(
chooser(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, expectedExtraText)
)
)
}
@Test
fun `should-be-able-to-open-in-browser`() {
launchFragmentInContainer<ArticleDetailFragment>(
themeResId = R.style.AppTheme,
fragmentArgs = bundle
)
intending(any(Intent::class.java)).respondWithFunction {
Instrumentation.ActivityResult(RESULT_OK, Intent())
}
onView(withId(R.id.fab_open_browser)).perform(click())
intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(application.article.url)))
}
}
package fr.chenry.android.freshrss.components.navigationdrawer
import androidx.annotation.StringRes
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.DrawerMatchers
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.activities.MainActivity
import fr.chenry.android.freshrss.utils.*
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.matchesPattern
import org.junit.*
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class SettingsFragmentTest: FreshRSSBaseTest() {
@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java)
@get:Rule
val disableAnimationsRule = DisableAnimationsRule()
@Before
fun setUp() {
activityRule.activity.requireF().preferences.reset()
}
@Test
fun `should-navigate-to-settings-fragment`() {
onView(withId(R.id.activity_main_navigation_drawer)).perform(DrawerActions.open())
onView(withId(R.id.activity_main_navigation_drawer)).check(matches(DrawerMatchers.isOpen()))
onView(withText(R.string.title_settings)).perform(click())
onView(isRoot()).check {_, noViewFoundException ->
if(noViewFoundException != null) throw noViewFoundException
val activity = activityRule.activity as MainActivity
assertThat(
activity.navigation.currentDestination!!,
equalTo(activity.navigation.graph.findNode(R.id.settingsFragment))
)
}
}
@Test
fun `should-correctly-initialize-default-values`() {
with(launchFragmentInContainer<SettingsFragment>(themeResId = R.style.AppTheme)) {
onFragment {fragment ->
val expected = getString(
R.string.refresh_frequency_title,
getString(R.string.refresh_frequency_30m)
) as CharSequence
val expected2 = fragment.refreshFrequencyPreference.entries.indexOfFirst {
it == getString(R.string.refresh_frequency_30m)
}.let {fragment.refreshFrequencyPreference.entryValues.getOrNull(it)}
assertThat(fragment.refreshFrequencyPreference.title, equalTo(expected))
assertThat(fragment.refreshFrequencyPreference.value, equalTo(expected2))
assertThat(fragment.retainScrollPositionPreference.isChecked, equalTo(true))
}
}
}
@Test
fun `should-correctly-modify-settings`() {
val scenario = launchFragmentInContainer<SettingsFragment>(themeResId = R.style.AppTheme)
onView(withText(matchesPattern(getString(R.string.refresh_frequency_title, ".+")))).perform(click())
onView(withText(R.string.refresh_frequency_5h)).perform(click())
scenario.onFragment {fragment ->
val expected = getString(
R.string.refresh_frequency_title,
getString(R.string.refresh_frequency_5h)
) as CharSequence
val expected2 = fragment.refreshFrequencyPreference.entries.indexOfFirst {
it == getString(R.string.refresh_frequency_5h)
}.let {fragment.refreshFrequencyPreference.entryValues.getOrNull(it)}
assertThat(fragment.refreshFrequencyPreference.title, equalTo(expected))
assertThat(fragment.refreshFrequencyPreference.value, equalTo(expected2))
assertThat(fragment.requireF().preferences.refreshFrequency, equalTo(300L))
}
onView(withText(matchesPattern(getString(R.string.retain_scroll_position_preference_title)))).perform(click())
scenario.onFragment {fragment ->
assertThat(fragment.retainScrollPositionPreference.isChecked, equalTo(false))
assertThat(fragment.requireF().preferences.retainScrollPosition, equalTo(false))
}
}
private fun getString(@StringRes resId: Int, vararg formatArgs: Any) =
if(formatArgs.isNotEmpty()) activityRule.activity.getString(resId, *formatArgs)
else activityRule.activity.getString(resId)
}
......@@ -5,9 +5,10 @@ import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import fr.chenry.android.freshrss.utils.FreshRSSBaseTest
import org.junit.Rule
abstract class FreshRSSDabatabaseBaseTest {
abstract class FreshRSSDabatabaseBaseTest: FreshRSSBaseTest() {
open val testDB get() = "migration-test"
@get:Rule
......@@ -17,9 +18,7 @@ abstract class FreshRSSDabatabaseBaseTest {
FrameworkSQLiteOpenHelperFactory()
)
val instrumentation inline get() = InstrumentationRegistry.getInstrumentation()
val context inline get() = instrumentation.targetContext
val assets inline get() = instrumentation.context.assets
val database: SQLiteDatabase by lazy {
SQLiteDatabase.openDatabase(
context.getDatabasePath(testDB).absolutePath,
......
......@@ -3,7 +3,7 @@ package fr.chenry.android.freshrss.store.database
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import fr.chenry.android.freshrss.store.database.models.Subscription
import fr.chenry.android.freshrss.utils.SubscriptionsTestUtils
import fr.chenry.android.freshrss.utils.factories.SubscriptionFactory
import fr.chenry.android.freshrss.utils.TestUtils
import kotlinx.coroutines.runBlocking
import org.junit.*
......@@ -33,7 +33,7 @@ class FreshRSSDabatabaseTest: FreshRSSDabatabaseBaseTest() {
@Test
fun syncSubscriptionsFromScratch() {
runBlocking {
val subscriptions = SubscriptionsTestUtils.createSubscriptions(10)
val subscriptions = SubscriptionFactory.createSubscriptions(10)
.sortedBy {it.id}.map {it.id to it}.toMap()
db.syncSubscriptions(subscriptions)
......@@ -45,7 +45,7 @@ class FreshRSSDabatabaseTest: FreshRSSDabatabaseBaseTest() {
@Test
fun syncSubscriptionsWithUpdates() {
runBlocking {
val subscriptions = SubscriptionsTestUtils.createSubscriptions(10)
val subscriptions = SubscriptionFactory.createSubscriptions(10)
val subscriptionsMap = subscriptions.sortedBy {it.id}.map {it.id to it}.toMap(LinkedHashMap())
db.syncSubscriptions(subscriptionsMap)
......@@ -75,7 +75,7 @@ class FreshRSSDabatabaseTest: FreshRSSDabatabaseBaseTest() {
@Test
fun syncSubscriptionsWithDeletions() {
runBlocking {
val subscriptions = SubscriptionsTestUtils.createSubscriptions(10)
val subscriptions = SubscriptionFactory.createSubscriptions(10)
val subscriptionsMap = subscriptions.sortedBy {it.id}.map {it.id to it}.toMap(LinkedHashMap())
db.syncSubscriptions(subscriptionsMap)
......
package fr.chenry.android.freshrss.utils
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.io.IOException
import androidx.test.uiautomator.UiDevice
class DisableAnimationsRule: TestRule {
override fun apply(base: Statement, description: Description?) = object: Statement() {
override fun evaluate() {
disableAnimations()
try{
base.evaluate()
} finally {
enableAnimations()
}
}
}
@Throws(IOException::class)
private fun disableAnimations() {
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("settings put global transition_animation_scale 0")
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("settings put global window_animation_scale 0")
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("settings put global animator_duration_scale 0")
}
@Throws(IOException::class)
private fun enableAnimations() {
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("settings put global transition_animation_scale 1")
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("settings put global window_animation_scale 1")
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
.executeShellCommand("settings put global animator_duration_scale 1")
}
}
package fr.chenry.android.freshrss.utils
import android.app.Instrumentation
import android.content.Context
import android.content.res.AssetManager
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
abstract class FreshRSSBaseTest {
val application: FreshRSSTestRunner.FreshRSSTestApplication inline get() = ApplicationProvider.getApplicationContext()
val instrumentation: Instrumentation inline get() = InstrumentationRegistry.getInstrumentation()
val context: Context inline get() = instrumentation.targetContext
val assets: AssetManager inline get() = instrumentation.context.assets
}
......@@ -2,23 +2,40 @@ package fr.chenry.android.freshrss.utils
import android.app.Application
import android.content.Context
import androidx.room.Room
import androidx.test.runner.AndroidJUnitRunner
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase_Migrations
import fr.chenry.android.freshrss.utils.factories.ArticleFactory
import fr.chenry.android.freshrss.utils.factories.SubscriptionFactory
import kotlinx.coroutines.runBlocking
import java.util.Locale
@Suppress("unused")
class FreshRSSTestRunner: AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, className: String, context: Context): Application {
return super.newApplication(cl, FreshRSSTestApplication::class.java.name, context)
}
class FreshRSSTestApplication: FreshRSSApplication() {
public var enableRefresh: Boolean = false
val feed = SubscriptionFactory.createSubscriptions(1)[0]
val article = ArticleFactory.createArticle(streamId = feed.id)
override val dbName by lazy {"${getString(R.string.app_name).toLowerCase(Locale.ENGLISH)}-test.db"}
override val db: FreshRSSDabatabase by lazy {
Room.databaseBuilder(context, FreshRSSDabatabase::class.java, TEST_DB_NAME)
.fallbackToDestructiveMigration()
.build().apply {
runBlocking {
syncSubscriptions(mapOf(feed.id to feed))
insertArticles(listOf(article))
}
}
}
override fun refresh() {
if(enableRefresh) super.refresh()
companion object {
const val TEST_DB_NAME = "freshrss-test.db"
}
}
}
package fr.chenry.android.freshrss.utils.factories
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.utils.TestUtils
import org.joda.time.LocalDateTime
import org.junit.Test
import java.util.concurrent.TimeUnit
object ArticleFactory {
fun createArticle(
id: String = TestUtils.idNumber().valid(),
title: String = TestUtils.starTrek().character(),
href: String = TestUtils.internet().url(),
author: String = TestUtils.book().author(),
content: String = TestUtils.lorem().paragraph(5),
streamId: String = TestUtils.idNumber().valid(),
readStatus: ReadStatus = ReadStatus.values()[TestUtils.random().nextInt(ReadStatus.values().size)],
favorite: FavoriteStatus = FavoriteStatus.values()[TestUtils.random().nextInt(FavoriteStatus.values().size)],
crawled: LocalDateTime = TestUtils.jodaDate().future(10, TimeUnit.DAYS).toLocalDateTime(),
published: LocalDateTime = TestUtils.jodaDate().future(10, TimeUnit.DAYS).toLocalDateTime(),
scrollPosition: Int = TestUtils.random().nextInt(10_000)
) = Article(id, title, href, author, content, streamId, readStatus, favorite, crawled, published, scrollPosition)
fun createArticles(number: Int) = (1..number).map {createArticle()}
}
package fr.chenry.android.freshrss.utils
package fr.chenry.android.freshrss.utils.factories
import fr.chenry.android.freshrss.store.database.models.Subscription
import fr.chenry.android.freshrss.utils.TestUtils
import org.joda.time.LocalDateTime
import java.util.concurrent.TimeUnit
object SubscriptionsTestUtils {
object SubscriptionFactory {
fun createSubscription(
id: String = TestUtils.idNumber().valid(),
title: String = TestUtils.lebowski().character(),
......
......@@ -11,15 +11,13 @@ import androidx.work.*
import fr.chenry.android.freshrss.store.FreshRSSPreferences
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase_Migrations
import fr.chenry.android.freshrss.utils.NotificationChannels
import fr.chenry.android.freshrss.utils.unit
import fr.chenry.android.freshrss.utils.*
import kotlinx.coroutines.*
import org.acra.ACRA
import org.acra.annotation.*
import org.acra.data.StringFormat
import org.acra.sender.HttpSender
import org.joda.time.DateTimeZone
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
......@@ -39,14 +37,12 @@ import java.util.concurrent.TimeUnit
open class FreshRSSApplication: Application(), SharedPreferences.OnSharedPreferenceChangeListener {
open val preferences: FreshRSSPreferences = FreshRSSPreferences(this)
open val preferences: FreshRSSPreferences by lazy {FreshRSSPreferences(this)}
open val workManager by lazy {WorkManager.getInstance(this)}
open val notificationManager: NotificationManagerCompat by lazy {NotificationManagerCompat.from(this)}
protected open val dbName by lazy {"${getString(R.string.app_name).toLowerCase(Locale.ENGLISH)}.db"}
val db by lazy {