Commit 6810e022 authored by Christophe Henry's avatar Christophe Henry

Replace refresh service with WorkManager

parent 4438b0f6
Pipeline #3703 passed with stage
in 0 seconds
......@@ -15,7 +15,7 @@ android {
targetSdkVersion 28
versionCode 12
versionName "1.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "fr.chenry.android.freshrss.utils.FreshRSSTestRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
javaCompileOptions {
annotationProcessorOptions {
......@@ -65,7 +65,6 @@ android {
configurations.all {
resolutionStrategy.force "com.google.code.findbugs:jsr305:1.3.9"
exclude group: "com.google.guava", module: "listenablefuture"
}
applicationVariants.all { variant ->
......@@ -107,7 +106,6 @@ dependencies {
def lifecycle_version = "2.2.0"
def room_version = '2.2.4'
def roomigrant_version = "0.1.7"
def fuel_version = "2.0.1"
def jackson_version = '2.10.2'
def espresso_version = "3.2.0"
def promise_version = "3.3.0"
......@@ -117,6 +115,8 @@ dependencies {
def autoservice_version = "1.0-rc6"
def android_test = "1.2.0"
def retrofit_version = "2.7.2"
def okhttp_version = "4.4.0"
def work_version = "2.3.2"
// Linter
ktlint "com.github.shyiko:ktlint:0.31.0"
......@@ -172,6 +172,12 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-jackson:$retrofit_version"
implementation "com.squareup.retrofit2:converter-scalars:$retrofit_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
// WorkManager
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "com.google.guava:guava:28.2-android"
// Utils
implementation "org.apache.commons:commons-text:1.8"
......@@ -191,7 +197,7 @@ dependencies {
// Tests
testImplementation "junit:junit:4.13"
testImplementation "org.hamcrest:hamcrest-library:2.2"
testImplementation "com.squareup.okhttp3:mockwebserver:4.4.0"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"
testImplementation "com.github.javafaker:javafaker:1.0.2"
/*
* org.json:json is used with explicit permission of its copyright holders :
......@@ -217,6 +223,7 @@ dependencies {
androidTestImplementation "androidx.test.ext:junit:1.1.1"
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "androidx.work:work-testing:$work_version"
// Debug
debugImplementation "com.facebook.stetho:stetho:1.5.1"
......
package fr.chenry.android.freshrss.utils
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import java.util.Locale
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
override val dbName by lazy {"${getString(R.string.app_name).toLowerCase(Locale.ENGLISH)}-test.db"}
override fun refresh() {
if(enableRefresh) super.refresh()
}
}
}
......@@ -29,11 +29,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".RefresherService"
android:enabled="true"
android:exported="false">
</service>
<activity
android:name=".activities.LoginActivity"
android:label="@string/app_name"
......
......@@ -2,18 +2,18 @@ package fr.chenry.android.freshrss
import android.app.Application
import android.app.NotificationChannel
import android.content.*
import android.os.*
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.core.os.postDelayed
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.Room
import fr.chenry.android.freshrss.RefresherService.RefresherBinder
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 kotlinx.coroutines.*
import nl.komponents.kovenant.android.startKovenant
import nl.komponents.kovenant.android.stopKovenant
import org.acra.ACRA
......@@ -23,6 +23,7 @@ import org.acra.sender.HttpSender
import org.joda.time.DateTimeZone
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
@AcraCore(reportFormat = StringFormat.JSON)
@AcraHttpSender(
......@@ -37,18 +38,16 @@ import java.util.TimeZone
resTitle = R.string.notification_crash_title,
resChannelImportance = NotificationManagerCompat.IMPORTANCE_MAX
)
class FreshRSSApplication: Application() {
open class FreshRSSApplication: Application(), SharedPreferences.OnSharedPreferenceChangeListener {
private val _refresherService = MutableLiveData<RefresherService>()
private val serviceConnection = RefresherServiceConnection()
open val preferences: FreshRSSPreferences = FreshRSSPreferences(this)
open val workManager by lazy {WorkManager.getInstance(this)}
open val notificationManager: NotificationManagerCompat by lazy {NotificationManagerCompat.from(this)}
val refresherService: LiveData<RefresherService> get() = _refresherService
val preferences: FreshRSSPreferences = FreshRSSPreferences(this)
lateinit var notificationManager: NotificationManagerCompat
open protected val dbName by lazy {"${getString(R.string.app_name).toLowerCase(Locale.ENGLISH)}.db"}
val database by lazy {
val dbName = "${context.getString(R.string.app_name).toLowerCase(Locale.ENGLISH)}.db"
val db by lazy {
Room.databaseBuilder(context, FreshRSSDabatabase::class.java, dbName)
.addMigrations(*FreshRSSDabatabase_Migrations.build())
.build()
......@@ -65,12 +64,8 @@ class FreshRSSApplication: Application() {
// Stupid hack because Android is still not able to provide the current
// application globally even though it's an actual a singleton...
appInstance = this
preferences.initDefaults()
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannels(getNotificationChannels())
bindService(Intent(this, RefresherService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
preferences.initDefaults()
// Debug
//Stetho.initializeWithDefaults(this)
......@@ -81,25 +76,60 @@ class FreshRSSApplication: Application() {
stopKovenant()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if(key == preferences.refreshFrequencyKey) enqueuePeriodicRequest()
}
open fun refresh() {
val runningWork = workManager.getWorkInfosByTag(RefreshWorker.REFRESH_WORK_TAG).get().find {
it.state == WorkInfo.State.RUNNING
}
if(runningWork != null) return
runBlocking {
workManager.enqueueUniqueWork(
RefreshWorker.ONE_TIME_WORK_NAME,
ExistingWorkPolicy.REPLACE,
RefreshWorker.manualWorkRequest
).await()
}
}
open fun cancelOngoinrefresh(): Unit = runBlocking {
workManager.cancelAllWorkByTag(RefreshWorker.REFRESH_WORK_TAG).await()
}.unit()
protected open fun enqueuePeriodicRequest() = GlobalScope.launch {
workManager.cancelAllWorkByTag(RefreshWorker.PERIODIC_WORK_TAG).await()
if(preferences.refreshFrequency <= 0) return@launch
workManager.enqueueUniquePeriodicWork(
RefreshWorker.PERIODIC_WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
RefreshWorker.periodicWorkRequest(preferences.refreshFrequency, TimeUnit.MINUTES)
).await()
}
companion object {
private lateinit var appInstance: FreshRSSApplication
val app: FreshRSSApplication get() = appInstance
val db: FreshRSSDabatabase
inline get() = app.database
inline get() = app.db
val context: Context
inline get() = app.applicationContext
val notificationManager: NotificationManagerCompat
inline get() = app.notificationManager
val preferences: FreshRSSPreferences
inline get() = app.preferences
val userTimeZone: DateTimeZone
inline get() = DateTimeZone.forTimeZone(TimeZone.getDefault())
fun refresh() = app.refresh()
fun cancelOngoinrefresh() = app.cancelOngoinrefresh()
fun getString(id: Int, vararg formatArgs: Any = arrayOf()): String = if(formatArgs.isEmpty())
app.resources.getString(id) else
app.resources.getString(id, *formatArgs)
......@@ -115,52 +145,6 @@ class FreshRSSApplication: Application() {
}
}
}
inner class RefresherServiceConnection: ServiceConnection, SharedPreferences.OnSharedPreferenceChangeListener {
private val handler = Handler()
private val token = object {}
private var refreshFrequency: Long = 30L
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
if(service is RefresherBinder) {
refreshFrequency = this@FreshRSSApplication.preferences.refreshFrequency
service.service.let {
_refresherService.value = it
this@FreshRSSApplication.preferences.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
postDelayedRefesh()
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
_refresherService.value?.apply {cancelRefreshNotification()}
handler.removeCallbacksAndMessages(token)
this@FreshRSSApplication.preferences.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
_refresherService.value = null
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
val newRefreshFrequency = this@FreshRSSApplication.preferences.refreshFrequency
if(refreshFrequency == newRefreshFrequency) return
refreshFrequency = newRefreshFrequency
handler.removeCallbacksAndMessages(token)
this.postDelayedRefesh()
}
private fun postDelayedRefesh() {
// Deactivate when refreshFrequency is 0 meaning automatic refresh is off
if(this.refreshFrequency == 0L) return
handler.postDelayed(this.refreshFrequency * 60 * 1000, token) {
this@FreshRSSApplication.refresherService.value?.let {
it.refresh(false)
postDelayedRefesh()
}
}
}
}
}
val F = FreshRSSApplication.Companion
package fr.chenry.android.freshrss
import android.app.Notification
import android.content.Context
import android.os.Build
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.work.*
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.models.Subscription
import fr.chenry.android.freshrss.utils.*
import kotlinx.coroutines.*
import org.joda.time.DateTime
import java.util.concurrent.TimeUnit
class RefreshWorker(appContext: Context, workerParams: WorkerParameters): CoroutineWorker(appContext, workerParams) {
private val app = applicationContext as FreshRSSApplication
private val noNetworkMsg = app.getString(R.string.no_internet_connection_avaible)
private val refreshNotification =
createNotification(
NotificationChannels.REFRESH,
R.string.notification_refresh_title,
R.string.notification_refresh_description
)
.setCategory(Notification.CATEGORY_PROGRESS)
.setProgress(0, 0, true)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
private val failNotification =
createNotification(
NotificationChannels.ERRORS,
R.string.notification_refresh_failed_title,
R.string.notification_refresh_failed_description,
R.color.error
)
.setCategory(Notification.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
private val foregroundInfo = ForegroundInfo(NotificationHelper.ONGOING_REFRESH_NOTIFICATION, refreshNotification)
override suspend fun doWork(): Result {
cancellAllNotifications()
val manual = inputData.getBoolean(MANUAL_WORK_TAG, false)
if(!applicationContext.isConnectedToNetwork()) {
if(manual) withContext(Dispatchers.Main) {
Toast.makeText(app, noNetworkMsg, Toast.LENGTH_LONG).show()
}
return Result.failure()
}
val result = runCatching {
setForeground(foregroundInfo)
Store.getSubscriptions()
Store.getUnreadCount()
Store.getStreamContents(Subscription.ALL_ITEMS_ID)
Store.getUnreadItemIds()
}
cancelRefreshNotification()
result.onSuccess {
app.db.updateLastFetchDate(Store.account.value!!.id, DateTime.now(getUserTimeZone()))
}.onFailure {
when(it) {
is CancellationException -> this.i("Refreshing job was canceled")
else -> {
this.e(it)
app.notificationManager.notify(NotificationHelper.FAIL_REFRESH_NOTIFICATION, failNotification)
}
}
}
return when {
result.isSuccess -> Result.success()
else -> Result.failure(workDataOf(ERRROR to result.exceptionOrNull()!!.toString()))
}
}
private fun cancelRefreshNotification() =
app.notificationManager.cancel(NotificationHelper.ONGOING_REFRESH_NOTIFICATION)
private fun cancellAllNotifications() {
cancelRefreshNotification()
app.notificationManager.cancel(NotificationHelper.FAIL_REFRESH_NOTIFICATION)
}
private fun createNotification(chan: NotificationChannels, title: Int, text: Int, colori: Int? = null) =
NotificationCompat
.Builder(app, chan.channelId)
.setContentTitle(applicationContext.getString(title))
.setContentText(applicationContext.getString(text))
.setSmallIcon(R.drawable.ic_rss_feed_black_24dp)
.setShowWhen(false)
.apply {if(colori != null && Build.VERSION.SDK_INT > Build.VERSION_CODES.M) color = app.getColor(colori)}
companion object {
const val REFRESH_WORK_TAG = "REFRESH_WORK"
const val ONE_TIME_WORK_NAME = "ONE_TIME_REFRESH_WORK"
const val PERIODIC_WORK_NAME = "PERIODIC_REFRESH_WORK"
const val MANUAL_WORK_TAG = "MANUAL_WORK"
const val PERIODIC_WORK_TAG = "PERIODIC_WORK"
const val ERRROR = "error"
private val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build()
val manualWorkRequest = OneTimeWorkRequestBuilder<RefreshWorker>()
.setInputData(workDataOf(MANUAL_WORK_TAG to true))
.setInitialDelay(0L, TimeUnit.SECONDS)
.addTag(REFRESH_WORK_TAG)
.addTag(MANUAL_WORK_TAG)
.build()
fun periodicWorkRequest(repeatInterval: Long, repeatIntervalTimeUnit: TimeUnit) =
PeriodicWorkRequestBuilder<RefreshWorker>(repeatInterval, repeatIntervalTimeUnit)
.setConstraints(constraints)
.setInitialDelay(repeatInterval, repeatIntervalTimeUnit)
.setInputData(workDataOf())
.addTag(REFRESH_WORK_TAG)
.addTag(PERIODIC_WORK_TAG)
.build()
}
}
package fr.chenry.android.freshrss
import android.app.Notification
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.widget.Toast
import androidx.core.app.NotificationCompat
import fr.chenry.android.freshrss.R.drawable
import fr.chenry.android.freshrss.R.string
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.utils.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.failUi
class RefresherService: Service() {
private val refreshNotification =
createNotification(
NotificationChannels.REFRESH,
string.notification_refresh_title,
string.notification_refresh_description
)
.setCategory(Notification.CATEGORY_PROGRESS)
.setProgress(0, 0, true)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
private val failNotification =
createNotification(
NotificationChannels.ERRORS,
string.notification_refresh_failed_title,
string.notification_refresh_failed_description,
R.color.error
)
.setCategory(Notification.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
override fun onBind(intent: Intent) = RefresherBinder()
fun refresh(manual: Boolean = true): Promise<Unit, Exception> {
if(Store.refreshingPromise.value != null) return Store.refreshingPromise.value!!
if(!F.context.isConnectedToNetwork()) {
if(manual) {
Toast.makeText(
F.context,
getString(string.no_internet_connection_avaible),
Toast.LENGTH_LONG
).show()
}
Store.refreshingPromise.postValue(null)
return Promise.ofSuccess(Unit)
}
F.notificationManager.notify(NotificationHelper.ONGOING_REFRESH_NOTIFICATION, refreshNotification)
val promise = Store.getSubscriptions()
.bind {Store.getUnreadCount()}
.bind {
runBlocking(Dispatchers.IO) {
F.db.getAllSubcriptionsIds()
.map {Store.getStreamContents(it)}
.let {all(it, cancelOthersOnError = false)}
}
}
.bind {Store.getUnreadItems()}
Store.refreshingPromise.postValue(promise)
promise.always {Store.refreshingPromise.postValue(null)}
.failUi {
it.printStackTrace()
F.notificationManager.notify(NotificationHelper.FAIL_REFRESH_NOTIFICATION, failNotification)
}.alwaysUi {
cancelRefreshNotification()
}
return promise
}
fun cancelRefreshNotification() =
F.notificationManager.cancel(NotificationHelper.ONGOING_REFRESH_NOTIFICATION)
private fun createNotification(chan: NotificationChannels, title: Int, text: Int, color: Int? = null) =
NotificationCompat
.Builder(F.app, chan.channelId)
.setContentTitle(F.getString(title))
.setContentText(F.getString(text))
.setSmallIcon(drawable.ic_rss_feed_black_24dp)
.setShowWhen(false)
.apply {
if(color != null && Build.VERSION.SDK_INT > Build.VERSION_CODES.M)
this.color = F.context.getColor(color)
}
inner class RefresherBinder: Binder() {
val service: RefresherService get() = this@RefresherService
}
}
......@@ -127,7 +127,7 @@ class LoginActivity: AppCompatActivity() {
this.e(err)
instance.error = getString(R.string.error_instance)
if(err is ServerException) {
when(err.response.code()) {
when(err.response.code) {
403 -> {}
404 -> {}
else -> {}
......
......@@ -6,19 +6,21 @@ import android.view.MenuItem
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.observe
import androidx.navigation.NavController
import androidx.navigation.Navigation
import androidx.navigation.ui.*
import fr.chenry.android.freshrss.F
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.components.navigationdrawer.AddSubscriptionDialog
import fr.chenry.android.freshrss.components.navigationdrawer.AddSubscriptionWaitDialog
import fr.chenry.android.freshrss.components.subscriptions.MainSubscriptionFragmentDirections
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.viewmodels.AccountVM
import fr.chenry.android.freshrss.utils.*
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
class MainActivity: AppCompatActivity() {
private val accountVM by viewModels<AccountVM>()
......@@ -41,7 +43,8 @@ class MainActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
F.app.refresherService.value?.apply {refresh()}
requireF().refresh()
setupActionBarWithNavController(navigation, appBarConfiguration)
drawerLayout.addDrawerListener(BurgerMenuDrawerListener(this.supportActionBar!!, navigation))
......@@ -72,9 +75,44 @@ class MainActivity: AppCompatActivity() {
fun onAddSubscriptionClick(@Suppress("UNUSED_PARAMETER") item: MenuItem) {
drawerLayout.closeDrawers()
AddSubscriptionDialog(::okCallback).show(supportFragmentManager, AddSubscriptionDialog::class.simpleName)
}
AddSubscriptionDialog {Store.postAddSubscription(it).fail(this::e)}
.show(main_activity_host_fragment!!.childFragmentManager, AddSubscriptionDialog::class.java.canonicalName)
private fun okCallback(input: String) {
val dialog = AddSubscriptionWaitDialog()
dialog.show(supportFragmentManager, AddSubscriptionWaitDialog::class.simpleName)
GlobalScope.launch {
val postAsync = Store.postAddSubscriptionAsync(input)
// Add some delay for user experience if network is too fast
val delay = async {delay(2000)}
listOf(postAsync, delay).awaitAll()
dialog.dismiss()
fun getTextDialog(id: Int) = HtmlCompat.fromHtml(
applicationContext.getString(id, "<a href='$input'>$input</a>").replace("\n", "<br/>"),
HtmlCompat.FROM_HTML_MODE_COMPACT
)
postAsync.await().onSuccess {