diff --git a/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt b/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt index 779fa759e..694fa8f45 100644 --- a/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt +++ b/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt @@ -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}") } diff --git a/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt b/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt index 7c5ba3ecd..7209c6492 100644 --- a/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt +++ b/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt @@ -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 { diff --git a/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt b/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt index 41ae9e3a4..a82f9c9fa 100644 --- a/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt +++ b/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt @@ -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}", diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index 181e9804c..59d77bfe7 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -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 diff --git a/app/src/main/java/app/pachli/components/preference/PreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/PreferencesFragment.kt index 76e83b405..b3b2870b6 100644 --- a/app/src/main/java/app/pachli/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/PreferencesFragment.kt @@ -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(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) } } } diff --git a/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt b/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt index 97877a114..89a9d3cb4 100644 --- a/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt +++ b/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt @@ -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 { /** 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 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04e1ecaee..415da742b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -716,4 +716,7 @@ validating nodeinfo %1$s failed: %2$s fetching /api/v1/instance failed: %1$s parsing server capabilities failed: %1$s + Check for update now + There are no updates available + Next scheduled check: %1$s diff --git a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt index 162ce2a1e..4e2405d91 100644 --- a/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt +++ b/core/preferences/src/main/kotlin/app/pachli/core/preferences/SharedPreferencesRepository.kt @@ -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 { /**