Commit 6bdbcac0 authored by Christophe Henry's avatar Christophe Henry
Browse files

Fix 98: Ensure app is always refreshed on resume

parent 82916a92
# Development
* Fix [!96](https://git.feneas.org/christophehenry/freshrss-android/-/issues/96): App crashes when trying to login to a bad URL ([!98](https://git.feneas.org/christophehenry/freshrss-android/-/merge_requests/98))
* Fix [!98](https://git.feneas.org/christophehenry/freshrss-android/-/issues/98) Automatically refresh if needed on activity resume ([!100](https://git.feneas.org/christophehenry/freshrss-android/merge_requests/100))
# 1.3.0
......
......@@ -6,9 +6,12 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.Room
import androidx.work.*
import fr.chenry.android.freshrss.store.FreshRSSPreferences
import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase
import fr.chenry.android.freshrss.store.database.FreshRSSDabatabase_Migrations
import fr.chenry.android.freshrss.utils.*
......@@ -17,9 +20,11 @@ import org.acra.ACRA
import org.acra.annotation.*
import org.acra.data.StringFormat
import org.acra.sender.HttpSender
import org.joda.time.DateTimeZone
import org.joda.time.*
import java.util.Properties
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import kotlin.math.max
@AcraCore(reportFormat = StringFormat.JSON)
@AcraHttpSender(
......@@ -37,10 +42,11 @@ import java.util.concurrent.TimeUnit
open class FreshRSSApplication: Application(), SharedPreferences.OnSharedPreferenceChangeListener {
val debugMode: LiveData<Boolean> = MutableLiveData(false)
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)}
open val db by lazy {
Room.databaseBuilder(context, FreshRSSDabatabase::class.java, DB_NAME)
.addMigrations(*FreshRSSDabatabase_Migrations.build())
......@@ -58,7 +64,17 @@ open class FreshRSSApplication: Application(), SharedPreferences.OnSharedPrefere
// application globally even though it's an actual a singleton...
appInstance = this
notificationManager.createNotificationChannels(getNotificationChannels())
preferences.initDefaults()
preferences.apply {
initDefaults()
registerChangeListener(this@FreshRSSApplication)
}
(debugMode as MutableLiveData).value = runCatching {
Properties().let {
it.load(baseContext.assets.open("config.properties"))
it.getProperty("debug")!!.toBoolean()
}
}.getOrDefault(false)
// Debug
//Stetho.initializeWithDefaults(this)
......@@ -67,47 +83,55 @@ open class FreshRSSApplication: Application(), SharedPreferences.OnSharedPrefere
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
/* TODO: test changing the refresh frequency updates the refresh job
Mais là, la flemme et de toutes façons, faut release */
if(key == preferences.refreshFrequencyKey) {
cancelPeriodicWorkRefresh()
ensurePeriodicRequest()
if(key == preferences.refreshFrequencyKey) GlobalScope.launch {
workManager.cancelAllWorkByTag(RefreshWorker.PERIODIC_WORK_TAG).await()
if(isPeriodicRefreshEnabled()) enqueuePeriodicWork(preferences.refreshFrequency)
}
}
open fun forceRefresh() {
val runningWork = workManager.getManualRefreshWorks().find {
it.state == WorkInfo.State.RUNNING
if(workManager.getRunningRefreshWork().isEmpty()) runBlocking {
enqueueUniqueWork()
}
}
if(runningWork != null) return
cancelPeriodicWorkRefresh()
runBlocking {
workManager.enqueueUniqueWork(
RefreshWorker.ONE_TIME_WORK_NAME,
ExistingWorkPolicy.REPLACE,
RefreshWorker.manualWorkRequest
).await()
open fun refresh() {
if(workManager.getRunningRefreshWork().isEmpty()) runBlocking {
if(isPeriodicRefreshEnabled()) enqueuePeriodicWork()
else enqueueUniqueWork()
}
}
open fun cancelOngoinRefresh(): Unit = runBlocking {
open fun cancelOnGoingRefresh(): Unit = runBlocking {
workManager.cancelAllWorkByTag(RefreshWorker.REFRESH_WORK_TAG).await()
}.unit()
open fun cancelPeriodicWorkRefresh(): Unit = runBlocking {
workManager.cancelAllWorkByTag(RefreshWorker.PERIODIC_WORK_TAG).await()
}.unit()
open fun ensurePeriodicRequest() = GlobalScope.launch {
if(preferences.refreshFrequency <= 0 || workManager.getPeriodicRefreshWorks().size > 0) return@launch
if(!isPeriodicRefreshEnabled() || workManager.getPeriodicRefreshWorks().isNotEmpty()) return@launch
val period = Store.account.value?.lastFetchDate?.let {Period(it, DateTime.now())}
val initialDelay = preferences.refreshFrequency - (period?.minutes?.toLong() ?: preferences.refreshFrequency)
enqueuePeriodicWork(max(0, initialDelay))
}
fun isPeriodicRefreshEnabled() = preferences.refreshFrequency > 0
private suspend fun enqueuePeriodicWork(initialDelay: Long = 0, initialDelayTimeUnit: TimeUnit = TimeUnit.MINUTES) =
workManager.enqueueUniquePeriodicWork(
RefreshWorker.PERIODIC_WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
RefreshWorker.periodicWorkRequest(preferences.refreshFrequency, TimeUnit.MINUTES)
RefreshWorker.periodicWorkRequest(
preferences.refreshFrequency,
TimeUnit.MINUTES,
initialDelay,
initialDelayTimeUnit
)
).await()
}
private suspend fun enqueueUniqueWork() = workManager.enqueueUniqueWork(
RefreshWorker.ONE_TIME_WORK_NAME,
ExistingWorkPolicy.REPLACE,
RefreshWorker.manualWorkRequest
).await()
companion object {
const val DB_NAME = "freshrss.db"
......
......@@ -14,6 +14,7 @@ import fr.chenry.android.freshrss.store.api.ALL_ITEMS_ID
import fr.chenry.android.freshrss.utils.*
import kotlinx.coroutines.*
import org.joda.time.DateTime
import org.joda.time.format.*
import java.util.concurrent.TimeUnit
class RefreshWorker(appContext: Context, workerParams: WorkerParameters): CoroutineWorker(appContext, workerParams) {
......@@ -69,8 +70,10 @@ class RefreshWorker(appContext: Context, workerParams: WorkerParameters): Corout
cancellAllNotifications()
val now = DateTime.now(getUserTimeZone())
result.onSuccess {
requireF().db.updateLastFetchDate(Store.account.value!!.id, DateTime.now(getUserTimeZone()))
requireF().db.updateLastFetchDate(Store.account.value!!.id, now)
}.onFailure {
when(it) {
is CancellationException -> this.i("Refreshing job was canceled")
......@@ -84,11 +87,18 @@ class RefreshWorker(appContext: Context, workerParams: WorkerParameters): Corout
}
}
requireF().ensurePeriodicRequest()
val dtFormatter = DateTimeFormatterBuilder()
.append(ISODateTimeFormat.date())
.appendLiteral(' ')
.append(ISODateTimeFormat.hourMinute())
.toFormatter()
val outDataBuilder = Data.Builder().putString("Ran at", now.toString(dtFormatter))
if(result.isFailure) outDataBuilder.putString(ERRROR, result.exceptionOrNull()!!.toString())
return when {
result.isSuccess -> Result.success()
else -> Result.failure(workDataOf(ERRROR to result.exceptionOrNull()!!.toString()))
result.isSuccess -> Result.success(outDataBuilder.build())
else -> Result.failure(outDataBuilder.build())
}
}
......@@ -136,6 +146,7 @@ class RefreshWorker(appContext: Context, workerParams: WorkerParameters): Corout
private val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val manualWorkRequest = OneTimeWorkRequestBuilder<RefreshWorker>()
......@@ -145,13 +156,17 @@ class RefreshWorker(appContext: Context, workerParams: WorkerParameters): Corout
.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()
fun periodicWorkRequest(
repeatInterval: Long,
repeatIntervalTimeUnit: TimeUnit,
initialDelay: Long = 0,
initialDelayTimeUnit: TimeUnit = TimeUnit.MINUTES
) = PeriodicWorkRequestBuilder<RefreshWorker>(repeatInterval, repeatIntervalTimeUnit)
.setConstraints(constraints)
.setInitialDelay(initialDelay, initialDelayTimeUnit)
.setInputData(workDataOf())
.addTag(REFRESH_WORK_TAG)
.addTag(PERIODIC_WORK_TAG)
.build()
}
}
......@@ -17,8 +17,6 @@ import fr.chenry.android.freshrss.store.Store
import fr.chenry.android.freshrss.store.api.utils.ServerException
import fr.chenry.android.freshrss.utils.*
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.Properties
class LoginActivity: AppCompatActivity() {
......@@ -51,16 +49,16 @@ class LoginActivity: AppCompatActivity() {
ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, InstanceUrl.authorizedProtocols)
val properties = Properties()
Try {properties.load(baseContext.assets.open("config.properties"))}
instanceUrl = Try {InstanceUrl.parse(properties.getProperty("instance"))}.getOrDefault(instanceUrl)
runCatching {properties.load(baseContext.assets.open("config.properties"))}
instanceUrl = runCatching {InstanceUrl.parse(properties.getProperty("instance"))}.getOrDefault(instanceUrl)
instance.setText(instanceUrl.base)
val protocolIdx = InstanceUrl.authorizedProtocols.indexOf(instanceUrl.protocole)
activity_login_protocol_selection.setSelection(if(protocolIdx >= 0) protocolIdx else 0)
Try{login.setText(properties.getProperty("login"))}
Try{password.setText(properties.getProperty("password"))}
runCatching {login.setText(properties.getProperty("login"))}
runCatching {password.setText(properties.getProperty("password"))}
computeInstanceURLHint()
instance.addTextChangedListener {computeInstanceURLHint()}
......@@ -108,10 +106,10 @@ class LoginActivity: AppCompatActivity() {
} else {
showProgress(true)
Store.login(instanceUrl.toString(), loginStr, passwordStr).onSuccess {
requireF().refresh()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
GlobalScope.launch {requireF().forceRefresh()}
}.onFailure (::handleError)
}.onFailure(::handleError)
}
}
......@@ -131,9 +129,12 @@ class LoginActivity: AppCompatActivity() {
instance.error = getString(R.string.error_instance)
if(err is ServerException) {
when(err.response.code) {
403 -> {}
404 -> {}
else -> {}
403 -> {
}
404 -> {
}
else -> {
}
}
}
}
......
......@@ -3,6 +3,7 @@ package fr.chenry.android.freshrss.activities
import android.content.res.AssetManager
import android.os.Bundle
import android.view.MenuItem
import android.view.SubMenu
import android.widget.TextView
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
......@@ -22,6 +23,7 @@ import fr.chenry.android.freshrss.store.viewmodels.AccountVM
import fr.chenry.android.freshrss.utils.*
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
import org.joda.time.DateTime
class MainActivity: AppCompatActivity() {
private val accountVM by viewModels<AccountVM>()
......@@ -51,33 +53,60 @@ class MainActivity: AppCompatActivity() {
navigation.addOnDestinationChangedListener {_, _, _ -> drawerLayout.closeDrawers()}
accountVM.account.observe(this) {
activity_main_navigation_view
application_left_menu
.getHeaderView(0)
.findViewById<TextView>(R.id.navigation_header_container_user)
.text = it.login
}
requireF().debugMode.observe(this) {
val submenu = application_left_menu.menu.findItem(R.id.app_left_menu_app_sub)?.subMenu
if(it && submenu is SubMenu) submenu.add(R.string.debug_infos).apply {
icon = getDrawable(R.drawable.ic_build_black_24dp)
setOnMenuItemClickListener {
drawerLayout.closeDrawers()
DebugAlertDialog().show(supportFragmentManager, DebugAlertDialog::class.simpleName)
true
}
}
}
}
override fun onResume() {
super.onResume()
requireF().ensurePeriodicRequest()
}
// TODO: Remove this function when androidx.appcompat:appcompat:1.2.0 is released
// See https://stackoverflow.com/a/59961940
override fun getAssets(): AssetManager = resources.assets
fun onAddSubscriptionClick(@Suppress("UNUSED_PARAMETER") item: MenuItem) {
drawerLayout.closeDrawers()
AddSubscriptionDialog(::okCallback).show(supportFragmentManager, AddSubscriptionDialog::class.simpleName)
}
fun onSettingsItemClick(@Suppress("UNUSED_PARAMETER") item: MenuItem) =
navigation.navigate(MainSubscriptionFragmentDirections.actionGlobalSettingsFragment())
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
if(item?.itemId == android.R.id.home && navigation.isTopLevelDestination()) {
if(isDrawerOpen()) drawerLayout.closeDrawers()
else drawerLayout.openDrawer(activity_main_navigation_view)
else drawerLayout.openDrawer(application_left_menu)
return true
}
return super.onOptionsItemSelected(item)
}
// TODO: Remove this function when androidx.appcompat:appcompat:1.2.0 is released
// See https://stackoverflow.com/a/59961940
override fun getAssets(): AssetManager = resources.assets
override fun onBackPressed() =
if(navigation.isTopLevelDestination() && isDrawerOpen()) drawerLayout.closeDrawers()
else super.onBackPressed()
fun onAddSubscriptionClick(@Suppress("UNUSED_PARAMETER") item: MenuItem) {
drawerLayout.closeDrawers()
AddSubscriptionDialog(::okCallback).show(supportFragmentManager, AddSubscriptionDialog::class.simpleName)
}
override fun onSupportNavigateUp(): Boolean =
navigation.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
private fun isDrawerOpen() = drawerLayout.isDrawerOpen(application_left_menu)
private fun okCallback(input: String) {
val dialog = AddSubscriptionWaitDialog()
......@@ -85,7 +114,7 @@ class MainActivity: AppCompatActivity() {
GlobalScope.launch {
val postAsync = Store.postAddSubscriptionAsync(input)
// Add some delay for user experience if network is too fast
val delay = async {delay(2000)}
val delay = async {delay(750)}
listOf(postAsync, delay).awaitAll()
dialog.dismiss()
......@@ -96,7 +125,7 @@ class MainActivity: AppCompatActivity() {
)
postAsync.await().onSuccess {
requireF().cancelOngoinRefresh()
requireF().cancelOnGoingRefresh()
requireF().forceRefresh()
}.onFailure {
when {
......@@ -116,15 +145,8 @@ class MainActivity: AppCompatActivity() {
}
}
fun onSettingsItemClick(@Suppress("UNUSED_PARAMETER") item: MenuItem) =
navigation.navigate(MainSubscriptionFragmentDirections.actionGlobalSettingsFragment())
override fun onBackPressed() =
if(navigation.isTopLevelDestination() && isDrawerOpen()) drawerLayout.closeDrawers()
else super.onBackPressed()
override fun onSupportNavigateUp(): Boolean =
navigation.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
private fun isDrawerOpen() = drawerLayout.isDrawerOpen(activity_main_navigation_view)
private fun shouldForceRefresh() = requireF().isPeriodicRefreshEnabled() && accountVM.account.value?.let {
val refreshRequency = requireF().preferences.refreshFrequency.toInt()
it.lastFetchDate.plusMinutes(refreshRequency).isBefore(DateTime.now())
} ?: false
}
......@@ -47,9 +47,9 @@ class StartActivity: AppCompatActivity() {
}
withContext(Dispatchers.IO) {
delay(500)
delay(250)
welcomeText.set(text)
delay(1000)
delay(500)
}
val klass = when(account) {
......
......@@ -71,7 +71,7 @@ class AddSubscriptionDialog(private val callback: (String) -> Unit): DialogFragm
* "https://www.youtube.com/feeds/videos.xml?playlist_id=PLygWqO36YZG039tHkjo0v4O-1vl7l74or"
*/
fun tryProcessYoutubeUrl(url: String): String {
val ytUrl = Try {URL(url)}.getOrNull() ?: return url
val ytUrl = runCatching {URL(url)}.getOrNull() ?: return url
val path = ytUrl.path!!.addTrailingSlash()
val domain = ytUrl.host!!.split(".").takeLast(2).joinToString(".")
......
......@@ -21,7 +21,7 @@ class CleanupReportingAdministrator: ReportingAdministrator {
): Boolean {
val app = context.applicationContext
if(app is FreshRSSApplication) {
app.cancelOngoinRefresh()
app.cancelOnGoingRefresh()
NotificationManagerCompat.from(app).cancelAll()
}
return super.shouldSendReport(context, config, crashReportData)
......
package fr.chenry.android.freshrss.utils
import android.app.Dialog
import android.os.Bundle
import android.text.*
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.text.bold
import androidx.core.text.toSpanned
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.observe
import androidx.recyclerview.widget.*
import androidx.work.Data
import androidx.work.WorkInfo
import fr.chenry.android.freshrss.databinding.DebugAlertDialogBinding
import fr.chenry.android.freshrss.databinding.DebugAlertDialogWorkInfosBinding
class DebugAlertDialog: DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = requireActivity().let {
val view = DebugAlertDialogBinding.inflate(
requireActivity().layoutInflater,
LinearLayout(context),
false
)
val refreshWorkliveData = requireF().workManager.getPeriodicRefreshWorksLiveData()
val divider = DividerItemDecoration(view.root.context, DividerItemDecoration.HORIZONTAL)
view.lifecycleOwner = requireActivity()
view.periodicWorkInfos = refreshWorkliveData
view.alertDialogWorkInfos.layoutManager = LinearLayoutManager(view.root.context)
view.alertDialogWorkInfos.adapter = WorkInfosAdapter(refreshWorkliveData)
view.alertDialogWorkInfos.addItemDecoration(divider)
val finishedWorkLiveData = requireF().workManager.getFinishedWorkLiveData()
view.finishedWorkInfos = finishedWorkLiveData
view.alertDialogFinishedWorkInfos.layoutManager = LinearLayoutManager(view.root.context)
view.alertDialogFinishedWorkInfos.adapter = WorkInfosAdapter(finishedWorkLiveData)
view.alertDialogFinishedWorkInfos.addItemDecoration(divider)
AlertDialog.Builder(it)
.setView(view.root)
.setCancelable(false)
.setPositiveButton(android.R.string.ok) {_, _ -> dismiss()}
.create()
}
override fun onResume() {
super.onResume()
isCancelable = false
}
private inner class WorkInfosAdapter(private val workInfos: LiveData<List<WorkInfo>>):
RecyclerView.Adapter<WorkInfosAdapter.ViewHolder>() {
init {
workInfos.observe(requireActivity()) {
notifyDataSetChanged()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
ViewHolder(DebugAlertDialogWorkInfosBinding.inflate(layoutInflater, parent, false))
override fun getItemCount(): Int = workInfos.value?.size ?: 0
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
workInfos.value?.get(position)?.let(holder::bind).unit()
private inner class ViewHolder(
private val binding: DebugAlertDialogWorkInfosBinding
): RecyclerView.ViewHolder(binding.root) {
fun bind(workinfos: WorkInfo) {
val outData = workinfos.outputData.keyValueMap.map {
SpannableStringBuilder().apply {
bold {append("${it.key}: ")}
append(it.value.toString())
}.toSpanned()
}
binding.resultInfosVisibility = if(workinfos.outputData != Data.EMPTY) View.VISIBLE else View.GONE
binding.periodicWorkInfo = workinfos
binding.debugWorkInfoOut.adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_list_item_1,
outData
)
}
}
}
}
......@@ -20,7 +20,7 @@ class InstanceUrl(val protocole: String, base: String) {
private val endpoint: String = if (hasApiEndpoint) "" else apiEndpoint
init {
isMalformed = Try { URL("$protocole${this.base}") }.getOrNull() == null
isMalformed = runCatching { URL("$protocole${this.base}") }.getOrNull() == null
}
fun toSpanString(): CharSequence = SpannableStringBuilder().apply {
......
......@@ -10,6 +10,7 @@ import android.os.Build
import android.util.Log
import androidx.fragment.app.Fragment
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.Transformations
import androidx.navigation.NavController
import androidx.work.WorkInfo
import androidx.work.WorkManager
......@@ -86,12 +87,24 @@ fun AndroidViewModel.requireF() = this.getApplication<FreshRSSApplication>()
fun RefreshWorker.requireF() = this.applicationContext as FreshRSSApplication
fun WorkManager.getRefreshWorkLiveData() = this.getWorkInfosByTagLiveData(RefreshWorker.REFRESH_WORK_TAG)
fun WorkManager.getPeriodicRefreshWorksLiveData() = this.getWorkInfosByTagLiveData(RefreshWorker.PERIODIC_WORK_TAG)
fun WorkManager.getFinishedWorkLiveData() =
Transformations.map(this.getWorkInfosByTagLiveData(RefreshWorker.REFRESH_WORK_TAG)) {
it.filter {wi -> wi.isFinished}
}
fun WorkManager.getRefreshWork(): List<WorkInfo> = this.getWorkInfosByTag(RefreshWorker.REFRESH_WORK_TAG).get()
fun WorkManager.getRunningRefreshWork(): List<WorkInfo> =
this.getRefreshWork().filter {it.state == WorkInfo.State.RUNNING}
fun WorkManager.getManualRefreshWorks(): MutableList<WorkInfo> =
this.getWorkInfosByTag(RefreshWorker.MANUAL_WORK_TAG).get()
fun WorkManager.getPeriodicRefreshWorks(): MutableList<WorkInfo> =
fun WorkManager.getPeriodicRefreshWorks(): List<WorkInfo> =
this.getWorkInfosByTag(RefreshWorker.PERIODIC_WORK_TAG).get()