feat: Allow the user to trigger update checks (#458)

Add an additional preference entry that triggers an update when tapped.
It also displays the earliest time of the next automatic update check as
the preference summary.

Move the code that performs the update check (and the logic for whether
to perform the check) out of `MainActivity` and in to `UpdateCheck` so
it's available from `PreferencesFragment`.
This commit is contained in:
Nik Clayton 2024-02-20 14:50:59 +01:00 committed by GitHub
parent 13cfa1a15d
commit 8293e90b73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 232 additions and 83 deletions

View File

@ -17,18 +17,21 @@
package app.pachli.updatecheck
import android.content.Context
import android.content.Intent
import android.net.Uri
import app.pachli.BuildConfig
import app.pachli.core.preferences.SharedPreferencesRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UpdateCheck @Inject constructor(
@ApplicationContext context: Context,
sharedPreferencesRepository: SharedPreferencesRepository,
private val fdroidService: FdroidService,
) : UpdateCheckBase(sharedPreferencesRepository) {
) : UpdateCheckBase(context, sharedPreferencesRepository) {
override val updateIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("market://details?id=${BuildConfig.APPLICATION_ID}")
}

View File

@ -17,15 +17,18 @@
package app.pachli.updatecheck
import android.content.Context
import android.content.Intent
import android.net.Uri
import app.pachli.core.preferences.SharedPreferencesRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class UpdateCheck @Inject constructor(
@ApplicationContext context: Context,
sharedPreferencesRepository: SharedPreferencesRepository,
private val gitHubService: GitHubService,
) : UpdateCheckBase(sharedPreferencesRepository) {
) : UpdateCheckBase(context, sharedPreferencesRepository) {
private val versionCodeExtractor = """(\d+)\.apk""".toRegex()
override val updateIntent = Intent(Intent.ACTION_VIEW).apply {

View File

@ -17,18 +17,21 @@
package app.pachli.updatecheck
import android.content.Context
import android.content.Intent
import android.net.Uri
import app.pachli.BuildConfig
import app.pachli.core.preferences.SharedPreferencesRepository
import com.google.android.play.core.appupdate.AppUpdateManager
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.suspendCancellableCoroutine
class UpdateCheck @Inject constructor(
@ApplicationContext context: Context,
sharedPreferencesRepository: SharedPreferencesRepository,
private val appUpdateManager: AppUpdateManager,
) : UpdateCheckBase(sharedPreferencesRepository) {
) : UpdateCheckBase(context, sharedPreferencesRepository) {
override val updateIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(
"https://play.google.com/store/apps/details?id=${BuildConfig.APPLICATION_ID}",

View File

@ -106,7 +106,6 @@ import app.pachli.interfaces.FabFragment
import app.pachli.interfaces.ReselectableFragment
import app.pachli.pager.MainPagerAdapter
import app.pachli.updatecheck.UpdateCheck
import app.pachli.updatecheck.UpdateNotificationFrequency
import app.pachli.usecase.DeveloperToolsUseCase
import app.pachli.usecase.LogoutUsecase
import app.pachli.util.deleteStaleCachedMedia
@ -428,7 +427,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
showJankyAnimationWarning()
}
checkForUpdate()
lifecycleScope.launch { updateCheck.checkForUpdate() }
}
/** Warn the user about possibly-broken animations. */
@ -441,60 +440,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
sharedPreferencesRepository.edit { putBoolean(PrefKeys.SHOW_JANKY_ANIMATION_WARNING, false) }
}
/**
* Check for available updates, and prompt user to update.
*
* Show a dialog prompting the user to update if a newer version of the app is available.
* The user can start an update, ignore this version, or dismiss all future update
* notifications.
*/
private fun checkForUpdate() = lifecycleScope.launch {
val frequency = UpdateNotificationFrequency.from(sharedPreferencesRepository.getString(PrefKeys.UPDATE_NOTIFICATION_FREQUENCY, null))
if (frequency == UpdateNotificationFrequency.NEVER) return@launch
val latestVersionCode = updateCheck.getLatestVersionCode()
if (latestVersionCode <= BuildConfig.VERSION_CODE) return@launch
if (frequency == UpdateNotificationFrequency.ONCE_PER_VERSION) {
val ignoredVersion = sharedPreferencesRepository.getInt(PrefKeys.UPDATE_NOTIFICATION_VERSIONCODE, -1)
if (latestVersionCode == ignoredVersion) {
Timber.d("Ignoring update to %d", latestVersionCode)
return@launch
}
}
Timber.d("New version is: %d", latestVersionCode)
when (showUpdateDialog()) {
AlertDialog.BUTTON_POSITIVE -> {
startActivity(updateCheck.updateIntent)
}
AlertDialog.BUTTON_NEUTRAL -> {
with(sharedPreferencesRepository.edit()) {
putInt(PrefKeys.UPDATE_NOTIFICATION_VERSIONCODE, latestVersionCode)
apply()
}
}
AlertDialog.BUTTON_NEGATIVE -> {
with(sharedPreferencesRepository.edit()) {
putString(
PrefKeys.UPDATE_NOTIFICATION_FREQUENCY,
UpdateNotificationFrequency.NEVER.name,
)
apply()
}
}
}
}
private suspend fun showUpdateDialog() = AlertDialog.Builder(this)
.setTitle(R.string.update_dialog_title)
.setMessage(R.string.update_dialog_message)
.setCancelable(true)
.setIcon(DR.mipmap.ic_launcher)
.create()
.await(R.string.update_dialog_positive, R.string.update_dialog_negative, R.string.update_dialog_neutral)
override fun onStart() {
super.onStart()
// For some reason the navigation drawer is opened when the activity is recreated

View File

@ -17,6 +17,13 @@
package app.pachli.components.preference
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import app.pachli.R
@ -26,6 +33,7 @@ import app.pachli.core.network.model.Notification
import app.pachli.core.preferences.AppTheme
import app.pachli.core.preferences.AppTheme.Companion.APP_THEME_DEFAULT
import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.settings.emojiPreference
import app.pachli.settings.listPreference
import app.pachli.settings.makePreferenceScreen
@ -33,6 +41,8 @@ import app.pachli.settings.preference
import app.pachli.settings.preferenceCategory
import app.pachli.settings.sliderPreference
import app.pachli.settings.switchPreference
import app.pachli.updatecheck.UpdateCheck
import app.pachli.updatecheck.UpdateCheckResult.AT_LATEST
import app.pachli.updatecheck.UpdateNotificationFrequency
import app.pachli.util.LocaleManager
import app.pachli.util.deserialize
@ -45,6 +55,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import dagger.hilt.android.AndroidEntryPoint
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class PreferencesFragment : PreferenceFragmentCompat() {
@ -55,8 +66,43 @@ class PreferencesFragment : PreferenceFragmentCompat() {
@Inject
lateinit var localeManager: LocaleManager
@Inject
lateinit var updateCheck: UpdateCheck
@Inject
lateinit var sharedPreferencesRepository: SharedPreferencesRepository
private val iconSize by unsafeLazy { resources.getDimensionPixelSize(DR.dimen.preference_icon_size) }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
// Show the "Check for update now" summary. This must also change
// depending on the update notification frequency. You can't link two
// preferences like that as a dependency, so listen for changes to
// the relevant keys and update the summary when they change.
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
sharedPreferencesRepository.changes.collect { prefKey ->
when (prefKey) {
PrefKeys.UPDATE_NOTIFICATION_FREQUENCY,
PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS,
-> {
findPreference<Preference>(PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS)?.let {
it.summary = updateCheck.provideSummary(it)
}
}
else -> { /* do nothing */ }
}
}
}
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
makePreferenceScreen {
preferenceCategory(R.string.pref_title_appearance_settings) {
@ -288,6 +334,8 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
preferenceCategory(R.string.pref_title_update_settings) {
it.icon = makeIcon(GoogleMaterial.Icon.gmd_upgrade)
listPreference {
setDefaultValue(UpdateNotificationFrequency.ALWAYS.name)
setEntries(R.array.pref_update_notification_frequency_names)
@ -296,7 +344,26 @@ class PreferencesFragment : PreferenceFragmentCompat() {
setSummaryProvider { entry }
setTitle(R.string.pref_title_update_notification_frequency)
isSingleLineTitle = false
icon = makeIcon(GoogleMaterial.Icon.gmd_upgrade)
icon = makeIcon(GoogleMaterial.Icon.gmd_calendar_today)
}
preference {
title = getString(R.string.pref_title_update_check_now)
key = PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS
setOnPreferenceClickListener {
lifecycleScope.launch {
if (updateCheck.checkForUpdate(true) == AT_LATEST) {
Toast.makeText(
this@PreferencesFragment.requireContext(),
getString(R.string.pref_update_check_no_updates),
Toast.LENGTH_LONG,
).show()
}
}
return@setOnPreferenceClickListener true
}
summary = updateCheck.provideSummary(this)
icon = makeIcon(GoogleMaterial.Icon.gmd_refresh)
}
}
}

View File

@ -17,13 +17,30 @@
package app.pachli.updatecheck
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.preference.Preference
import app.pachli.BuildConfig
import app.pachli.R
import app.pachli.core.common.util.AbsoluteTimeFormatter
import app.pachli.core.designsystem.R as DR
import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.core.ui.await
import app.pachli.updatecheck.UpdateCheckResult.AT_LATEST
import app.pachli.updatecheck.UpdateCheckResult.DIALOG_SHOWN
import app.pachli.updatecheck.UpdateCheckResult.IGNORED
import app.pachli.updatecheck.UpdateCheckResult.SKIPPED_BECAUSE_NEVER
import app.pachli.updatecheck.UpdateCheckResult.SKIPPED_BECAUSE_TOO_SOON
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant
import java.util.Date
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
import kotlin.time.toJavaDuration
import timber.log.Timber
enum class UpdateNotificationFrequency {
/** Never prompt the user to update */
@ -50,31 +67,31 @@ enum class UpdateNotificationFrequency {
}
}
enum class UpdateCheckResult {
/** Skipped update check because user configured frequency is "never" */
SKIPPED_BECAUSE_NEVER,
/** Skipped update check because it's too soon relative to the last check */
SKIPPED_BECAUSE_TOO_SOON,
/** Performed update check, user is at latest available version */
AT_LATEST,
/** Performed update check, user is ignoring the remote version */
IGNORED,
/** Performed update check, update dialog was shown to the user */
DIALOG_SHOWN,
}
@Singleton
abstract class UpdateCheckBase(private val sharedPreferencesRepository: SharedPreferencesRepository) {
abstract class UpdateCheckBase(
@ApplicationContext private val context: Context,
private val sharedPreferencesRepository: SharedPreferencesRepository,
) : Preference.SummaryProvider<Preference> {
/** An intent that can be used to start the update process (e.g., open a store listing) */
abstract val updateIntent: Intent
/**
* @return The newest available versionCode (which may be the current version code if there is
* no newer version, or if [MINIMUM_DURATION_BETWEEN_CHECKS] has not elapsed since the last
* check.
*/
suspend fun getLatestVersionCode(): Int {
val now = System.currentTimeMillis()
val lastCheck = sharedPreferencesRepository.getLong(PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS, 0)
if (now - lastCheck < MINIMUM_DURATION_BETWEEN_CHECKS.inWholeMilliseconds) {
return BuildConfig.VERSION_CODE
}
sharedPreferencesRepository.edit {
putLong(PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS, now)
}
return remoteFetchLatestVersionCode() ?: BuildConfig.VERSION_CODE
}
/**
* Fetch the version code of the latest available version of Pachli from whatever
* remote service the running version was downloaded from.
@ -83,8 +100,116 @@ abstract class UpdateCheckBase(private val sharedPreferencesRepository: SharedPr
*/
abstract suspend fun remoteFetchLatestVersionCode(): Int?
/**
* Check for available updates, and prompt user to update.
*
* Show a dialog prompting the user to update if a newer version of the app is available.
* The user can start an update, ignore this version, or dismiss all future update
* notifications.
*
* @param force If true then the user's preferences for update checking frequency are
* ignored and the update check is always performed.
*
* @return The result of performing the update check
*/
suspend fun checkForUpdate(force: Boolean = false): UpdateCheckResult {
val frequency = UpdateNotificationFrequency.from(
sharedPreferencesRepository.getString(PrefKeys.UPDATE_NOTIFICATION_FREQUENCY, null),
)
if (!force && frequency == UpdateNotificationFrequency.NEVER) return SKIPPED_BECAUSE_NEVER
val now = System.currentTimeMillis()
if (!force) {
val lastCheck = sharedPreferencesRepository.getLong(
PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS,
0,
)
if (now - lastCheck < MINIMUM_DURATION_BETWEEN_CHECKS.inWholeMilliseconds) {
return SKIPPED_BECAUSE_TOO_SOON
}
}
sharedPreferencesRepository.edit {
putLong(PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS, now)
}
val latestVersionCode = remoteFetchLatestVersionCode() ?: BuildConfig.VERSION_CODE
if (latestVersionCode <= BuildConfig.VERSION_CODE) return AT_LATEST
if (frequency == UpdateNotificationFrequency.ONCE_PER_VERSION) {
val ignoredVersion =
sharedPreferencesRepository.getInt(PrefKeys.UPDATE_NOTIFICATION_VERSIONCODE, -1)
if (latestVersionCode == ignoredVersion && !force) {
Timber.d("Ignoring update to %d", latestVersionCode)
return IGNORED
}
}
Timber.d("New version is: %d", latestVersionCode)
when (showUpdateDialog(context)) {
AlertDialog.BUTTON_POSITIVE -> {
context.startActivity(updateIntent)
}
AlertDialog.BUTTON_NEUTRAL -> {
with(sharedPreferencesRepository.edit()) {
putInt(PrefKeys.UPDATE_NOTIFICATION_VERSIONCODE, latestVersionCode)
apply()
}
}
AlertDialog.BUTTON_NEGATIVE -> {
with(sharedPreferencesRepository.edit()) {
putString(
PrefKeys.UPDATE_NOTIFICATION_FREQUENCY,
app.pachli.updatecheck.UpdateNotificationFrequency.NEVER.name,
)
apply()
}
}
}
return DIALOG_SHOWN
}
private suspend fun showUpdateDialog(context: Context) = AlertDialog.Builder(context)
.setTitle(R.string.update_dialog_title)
.setMessage(R.string.update_dialog_message)
.setCancelable(true)
.setIcon(DR.mipmap.ic_launcher)
.create()
.await(
R.string.update_dialog_positive,
R.string.update_dialog_negative,
R.string.update_dialog_neutral,
)
override fun provideSummary(preference: Preference): CharSequence? {
val frequency = UpdateNotificationFrequency.from(
sharedPreferencesRepository.getString(PrefKeys.UPDATE_NOTIFICATION_FREQUENCY, null),
)
if (frequency == UpdateNotificationFrequency.NEVER) return null
val now = Instant.now()
val lastCheck = sharedPreferencesRepository.getLong(
PrefKeys.UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS,
now.toEpochMilli(),
)
val nextCheck = Instant.ofEpochMilli(lastCheck).plus(MINIMUM_DURATION_BETWEEN_CHECKS.toJavaDuration())
val dateString = AbsoluteTimeFormatter().format(Date.from(nextCheck))
return context.getString(R.string.pref_update_next_scheduled_check, dateString)
}
companion object {
/** How much time should elapse between version checks */
private val MINIMUM_DURATION_BETWEEN_CHECKS = 24.hours
val MINIMUM_DURATION_BETWEEN_CHECKS = 24.hours
}
}

View File

@ -716,4 +716,7 @@
<string name="server_repository_error_validate_node_info">validating nodeinfo %1$s failed: %2$s</string>
<string name="server_repository_error_get_instance_info">fetching /api/v1/instance failed: %1$s</string>
<string name="server_repository_error_capabilities">parsing server capabilities failed: %1$s</string>
<string name="pref_title_update_check_now">Check for update now</string>
<string name="pref_update_check_no_updates">There are no updates available</string>
<string name="pref_update_next_scheduled_check">Next scheduled check: %1$s</string>
</resources>

View File

@ -36,7 +36,7 @@ import timber.log.Timber
*/
@Singleton
class SharedPreferencesRepository @Inject constructor(
val sharedPreferences: SharedPreferences,
private val sharedPreferences: SharedPreferences,
@ApplicationScope private val externalScope: CoroutineScope,
) : SharedPreferences by sharedPreferences {
/**