Commit e73411ba authored by Christophe Henry's avatar Christophe Henry

Solves #14 and #8: implement auto-refresh and make notification unclosable

parent a18b445c
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="fr.chenry.android.freshrss">
<!-- To auto-complete the email text field in the login form with the user's emails -->
<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
<uses-permission android:name="android.permission.READ_PROFILE"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.INTERNET"/>
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="fr.chenry.android.freshrss">
<dist:module dist:instant="true"/>
<uses-permission android:name="android.permission.INTERNET" />
<dist:module dist:instant="true" />
<application
android:name="fr.chenry.android.freshrss.FreshRSSApplication"
android:name=".FreshRSSApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
......@@ -21,20 +18,24 @@
android:supportsPictureInPicture="false"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name="fr.chenry.android.freshrss.activities.TestActivity">
</activity>
tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
<service
android:name=".RefresherService"
android:enabled="true"
android:exported="false">
</service>
<activity android:name=".activities.TestActivity"></activity>
<activity
android:name="fr.chenry.android.freshrss.activities.LoginActivity"
android:label="@string/title_activity_login">
android:name=".activities.LoginActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="fr.chenry.android.freshrss.activities.MainActivity"
android:name=".activities.MainActivity"
android:label="@string/app_name">
</activity>
</application>
......
package fr.chenry.android.freshrss
import android.app.Application
import android.content.Context
import android.content.*
import android.os.Handler
import android.os.IBinder
import androidx.core.app.NotificationManagerCompat
import androidx.core.os.postDelayed
import com.facebook.stetho.Stetho
import fr.chenry.android.freshrss.RefresherService.RefresherBinder
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.utils.whenNotNull
import nl.komponents.kovenant.android.startKovenant
import nl.komponents.kovenant.android.stopKovenant
import nl.komponents.kovenant.deferred
import java.util.*
class FreshRSSApplication: Application() {
private val refreshDelay: Long get() = 30
var refresherService: RefresherService? = null
private set
private val serviceConnection = RefresherServiceConnection()
override fun onCreate() {
super.onCreate()
startKovenant()
// Stupid hack because Android is still not able to provide the current application application globally
// even though it is effectively a singleton...
FreshRSSApplication.applicationPromise.resolve(this)
startKovenant()
bindService(Intent(this, RefresherService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
Store.debugMode = try {
val properties = Properties()
properties.load(baseContext.assets.open("config.properties"))
......@@ -25,9 +40,7 @@ class FreshRSSApplication: Application() {
false
}
if(Store.debugMode) {
Stetho.initializeWithDefaults(this)
}
if(Store.debugMode) Stetho.initializeWithDefaults(this)
}
override fun onTerminate() {
......@@ -44,5 +57,31 @@ class FreshRSSApplication: Application() {
val notificationManager: NotificationManagerCompat
get() = NotificationManagerCompat.from(FreshRSSApplication.application)
fun getStringR(id: Int) = application.resources.getString(id)
}
inner class RefresherServiceConnection: ServiceConnection {
private val handler = Handler()
private val token = object{}
override fun onServiceDisconnected(name: ComponentName?) {
handler.removeCallbacksAndMessages(token)
refresherService = null
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
refresherService = (service as RefresherBinder).service
postDelayedRefesh()
}
private fun postDelayedRefesh() {
handler.postDelayed(this@FreshRSSApplication.refreshDelay * 60 * 1000, token) {
this@FreshRSSApplication.refresherService.whenNotNull {
it.refresh()
postDelayedRefesh()
}
}
}
}
}
\ No newline at end of file
package fr.chenry.android.freshrss
import android.app.Notification
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import androidx.lifecycle.MutableLiveData
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.utils.NotificationChanels
import fr.chenry.android.freshrss.utils.NotificationHelper
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
class RefresherService: Service() {
private val refreshingPromise = MutableLiveData<Promise<Unit, Exception>>()
private val refreshNotification =
createNotification(R.string.notification_refresh_title, R.string.notification_refresh_description)
.setCategory(Notification.CATEGORY_PROGRESS)
.build()
private val failNotification =
createNotification(R.string.notification_refresh_failed_title, R.string.notification_refresh_failed_description)
.setCategory(Notification.CATEGORY_ERROR)
.build()
override fun onBind(intent: Intent) = RefresherBinder()
fun refresh(): Promise<Unit, Exception> {
if(refreshingPromise.value != null) return refreshingPromise.value!!
this.startForeground(NotificationHelper.ONGOING_REFRESH_NOTIFICATION, refreshNotification)
val promise = Store.getSubscriptions()
.bind {Store.getUnreadCount()}
.bind {all(Store.subscriptions.keys.map {Store.getStreamContents(it)}, cancelOthersOnError = false)}
.bind {Store.getUnreadItems()}
refreshingPromise.postValue(promise)
promise.always {refreshingPromise.postValue(null)}
.successUi {stopForeground(true)}
.failUi {
FreshRSSApplication
.notificationManager
.notify(NotificationHelper.FAIL_REFRESH_NOTIFICATION, failNotification)
}
return promise
}
private fun createNotification(title: Int, text: Int) = NotificationCompat
.Builder(FreshRSSApplication.application, NotificationChanels.REFRESH.channelId)
.setSmallIcon(R.drawable.ic_rss_feed_black_24dp)
.setStyle(
NotificationCompat
.BigTextStyle()
.setSummaryText(FreshRSSApplication.getStringR(title))
.bigText(FreshRSSApplication.getStringR(text)))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setShowWhen(false)
.setContentInfo(FreshRSSApplication.getStringR(title))
inner class RefresherBinder: Binder() {
val service: RefresherService get() = this@RefresherService
}
}
......@@ -4,10 +4,10 @@ import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.*
import fr.chenry.android.freshrss.components.waiting.WaitingFragment
import fr.chenry.android.freshrss.store.*
import fr.chenry.android.freshrss.utils.e
import fr.chenry.android.freshrss.utils.*
import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.resolve
import nl.komponents.kovenant.ui.failUi
......@@ -17,7 +17,11 @@ class MainActivity: AppCompatActivity() {
private val deferred = deferred<Unit, Exception>()
init {
Store.refresh().successUi {deferred.resolve()}.failUi(deferred::reject)
FreshRSSApplication.application.refresherService.whenNotNull {
it.refresh().successUi {deferred.resolve()}.failUi(deferred::reject)
}.whenNull {
deferred.reject(Exception("${FreshRSSApplication::class.qualifiedName}: Service ${RefresherService::class.qualifiedName} not bound"))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
......
......@@ -6,11 +6,13 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.api.models.Subscription
import fr.chenry.android.freshrss.store.api.models.Subscriptions
import fr.chenry.android.freshrss.utils.getOrDefault
import fr.chenry.android.freshrss.utils.whenNotNull
import kotlinx.android.synthetic.main.fragment_main_subscription.*
import kotlin.reflect.KClass
......@@ -66,7 +68,7 @@ class MainSubscriptionFragment: Fragment() {
override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
inflater?.inflate(R.menu.main_actionbar, menu)
menu?.findItem(R.id.action_refresh)?.setOnMenuItemClickListener {
Store.refresh().getOrDefault(null).let {true}
FreshRSSApplication.application.refresherService.whenNotNull {it.refresh()}.let {true}
}
super.onCreateOptionsMenu(menu, inflater)
......
package fr.chenry.android.freshrss.store
import androidx.lifecycle.MutableLiveData
import fr.chenry.android.freshrss.R
import fr.chenry.android.freshrss.R.string
import fr.chenry.android.freshrss.components.subscriptions.SubscriptionSection
import fr.chenry.android.freshrss.store.api.Api
import fr.chenry.android.freshrss.store.database.models.Account
import fr.chenry.android.freshrss.store.api.models.StreamId
import fr.chenry.android.freshrss.store.api.models.Subscription
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.store.database.models.*
import fr.chenry.android.freshrss.store.database.models.ReadStatus.UNREAD
import fr.chenry.android.freshrss.store.databindingsupport.GenericLiveData
import fr.chenry.android.freshrss.utils.NotificationHelper
import fr.chenry.android.freshrss.utils.NotificationHelper.NotificationChanels.REFRESH
import fr.chenry.android.freshrss.utils.e
import nl.komponents.kovenant.*
import nl.komponents.kovenant.combine.and
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
object Store {
lateinit var api: Api
......@@ -30,7 +22,6 @@ object Store {
val subscriptionsSection = MutableLiveData<SubscriptionSection>().apply {this.value = SubscriptionSection.ALL}
val tags = MutableLiveData<List<String>>().apply {this.value = listOf()}
val favorites = GenericLiveData<String, Subscription>(mutableMapOf())
private val refreshingPromise = MutableLiveData<Promise<Unit, Exception>>()
private var lastFetchTimestamp = 0L
fun init(account: Account) {
......@@ -41,26 +32,6 @@ object Store {
fun login(instance: String, user: String, password: String): Promise<Unit, Exception> =
Api.login(instance, user, password) then {init(it)}
fun refresh(): Promise<Unit, Exception> {
if(refreshingPromise.value != null) return refreshingPromise.value!!
val notificationId = NotificationHelper.post(REFRESH, R.string.notification_refresh_title)
ensureToken()
val promise = getSubscriptions()
.bind {getUnreadCount()}
.bind {all(subscriptions.keys.map {getStreamContents(it)}, cancelOthersOnError = false)}
.bind {getUnreadItems()}
refreshingPromise.postValue(promise)
promise.always {refreshingPromise.postValue(null)}
.successUi {NotificationHelper.cancel(notificationId)}
.failUi {NotificationHelper.update(REFRESH, notificationId, string.notification_refresh_failed_title)}
return promise
}
fun getSubscriptions(): Promise<Unit, Exception> =
api.getSubscriptions() then {subscriptions.addAll(it.map {self -> self.id to self})}
......@@ -109,7 +80,7 @@ object Store {
fun postReadStatus(article: Article, readStatus: ReadStatus): Promise<Unit, Exception> =
ensureToken() bind {
api.postReadStatus(article.id, readStatus)
.success { FreshRSSDabatabase.instance.upsertArticle(article.copy(readStatus = readStatus)) }
.success {FreshRSSDabatabase.instance.upsertArticle(article.copy(readStatus = readStatus))}
}
private fun ensureToken(): Promise<Unit, Exception> {
......
......@@ -95,3 +95,6 @@ fun <V, E>Promise<V, E>.getOrDefault(default: V) = try {
this.w(e)
default
}
fun <T: Any>T?.whenNotNull(body: (T) -> Unit) = if(this !== null) body(this).let{this} else this
fun <T: Any>T?.whenNull(body: () -> Unit) = this ?: body().let {this}
\ No newline at end of file
......@@ -9,58 +9,29 @@ import fr.chenry.android.freshrss.FreshRSSApplication
import fr.chenry.android.freshrss.R
object NotificationHelper {
fun post(channel: NotificationChanels, contentTitleId: Int, notificationId: Int = channel.channelId): Int {
NotificationManagerCompat.from(FreshRSSApplication.application)
.notify(notificationId, getNotification(channel, contentTitleId))
return notificationId
}
fun update(channel: NotificationChanels, notificationId: Int, contentTitleId: Int) =
FreshRSSApplication
.notificationManager
.notify(notificationId,
getNotification(channel, contentTitleId))
private fun getNotification(channel: NotificationChanels, contentTitleId: Int): Notification =
NotificationCompat
.Builder(FreshRSSApplication.application, channel.channelTitle)
.setSmallIcon(R.drawable.ic_rss_feed_black_24dp)
.setContentTitle(FreshRSSApplication.application.resources.getString(contentTitleId))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
const val ONGOING_REFRESH_NOTIFICATION = 1
const val FAIL_REFRESH_NOTIFICATION = 2
fun cancel(id: Int) = NotificationManagerCompat.from(FreshRSSApplication.application).cancel(id)
private fun createNotificationChannel(channelNameId: Int, channelDescription: Int): Pair<Int, String> {
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) return 0 to ""
return try {
val name = FreshRSSApplication.application.resources.getString(channelNameId)
val descriptionText = FreshRSSApplication.application.resources.getString(channelDescription)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channelId = FreshRSSApplication.application.resources.getString(R.string.notification_channel_refresh)
val channel = NotificationChannel(channelId, name, importance).apply {description = descriptionText}
// Register the channel with the system
val notificationManager =
FreshRSSApplication.application.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
channelNameId to channelId
} catch(_: Throwable) {
0 to "DEFAULT"
}
enum class NotificationChanels(nameResourceId: Int, descriptionResourceId: Int, importance: Int? = null) {
REFRESH(R.string.notification_channel_refresh_title, R.string.notification_channel_refresh_description);
val channelId: String get() = this.name
init {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Try {
FreshRSSApplication.application.getSystemService(Context.NOTIFICATION_SERVICE).apply {
this as NotificationManager
val n = FreshRSSApplication.getStringR(nameResourceId)
val i = importance ?: NotificationManager.IMPORTANCE_LOW
NotificationChannel(channelId, n, i).let {
it.description = FreshRSSApplication.getStringR(descriptionResourceId)
createNotificationChannel(it)
}
}
}
}
}
enum class NotificationChanels(channel: Pair<Int, String>) {
REFRESH(
createNotificationChannel(
R.string.notification_refresh_title,
R.string.notification_refresh_description
)
);
val channelId = channel.first
val channelTitle = channel.second
}
}
\ No newline at end of file
package fr.chenry.android.freshrss.utils
class Try<T: Any>(body: () -> T) {
private lateinit var _error: Throwable
private lateinit var _value: T
val isSuccess: Boolean get() = ::_error.isInitialized
val value: T get() = _value
val error: Throwable get() = _error
init {
try {
_value = body()
} catch(err: Throwable) {
_error = err
}
}
fun <U: Any>map(body: (Try<T>) -> U): Try<U> = Try{body(this)}
fun <U: Any>whenSuccess(body: (T) -> U): Try<T> = if(isSuccess) body(value).let{this} else this
fun <U: Any>whenError(body: (Throwable) -> U): Try<T> = if(!isSuccess) body(error).let{this} else this
}
\ No newline at end of file
<resources>
<!-- Do not translate -->
<string name="notification_channel_refresh">REFRESH</string>
<string name="app_name">FreshRSS</string>
<string name="title_activity_login">Sign in</string>
<string name="prompt_login">Login</string>
<string name="prompt_password">Password</string>
<string name="action_sign_in">Sign in</string>
......@@ -34,10 +31,6 @@
<string name="next">Next</string>
<string name="error_invalid_password">This password is too short</string>
<string name="original_page">Open in browser</string>
<string name="notification_refresh_title">Refreshing your RSS feeds</string>
<string name="notification_refresh_description">FreshRSS is fetching your content from the server</string>
<string name="notification_refresh_failed_title">FresshRSS failed to retrieve content from your FreshRSS server</string>
<string name="refresh">Refresh</string>
<string name="mark_read">Mark read</string>
<string name="mark_unread">Mark unread</string>
......@@ -47,4 +40,12 @@
<string name="unread">unread</string>
<string name="request_already_ongoing">A request is already ongoing. Please retry later.</string>
<string name="unable_to">Sorry, the application failed to %s</string>
<!-- Notifications -->
<string name="notification_refresh_title">Refreshing your RSS feeds</string>
<string name="notification_refresh_description">FreshRSS is fetching your content from the server</string>
<string name="notification_refresh_failed_title">Refreshing failed</string>
<string name="notification_refresh_failed_description">FresshRSS failed to retrieve content from your FreshRSS server</string>
<string name="notification_channel_refresh_title">Refresh events</string>
<string name="notification_channel_refresh_description">Events occuring when trying to fetch content from your FreshRSS server</string>
</resources>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment