feat: Periodically check for updates and alert user (#236)

Users can inadvertently get stuck on older versions of the app; e.g., by
installing from one F-Droid repository that stops hosting the app at
some later time.

Analytics from the Play Store also shows a long tail of users who are,
for some reason, on an older version.

On resuming `MainActivity`, and approximately once per day, check and
see if a newer version of Pachli is available, and prompt the user to
update by going to the relevant install location (Google Play, F-Droid,
or GitHub).

The dialog prompt allows them to ignore this specific version, or
disable all future update notifications. This is also exposed through
the preferences, so the user can adjust it there too.

A different update check method is used for each installation location.

- F-Droid: Use the F-Droid API to query for the newest released version
- GitHub: Use the GitHub API to query for the newest release, and check
the APK filename attached to that release
- Google Play: Use the Play in-app-updates library
(https://developer.android.com/guide/playcore/in-app-updates) to query
for the newest released version

These are kept in different build flavours (source sets), so that e.g.,
the build for the F-Droid store can only query the F-Droid API, the UI
strings are specific to F-Droid, etc. This also ensures that the update
service libraries are specific to that build and do not
"cross-contaminate".

Note that this *does not* update the app, it takes the user to either
the relevant store page (F-Droid, Play) or GitHub release page. The user
must still start the update from that page.

CI configuration is updated to build the different flavours.
This commit is contained in:
Nik Clayton 2023-11-08 08:42:39 +01:00 committed by GitHub
parent 86dee94035
commit dda9dde1b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 675 additions and 31 deletions

View File

@ -9,6 +9,10 @@ on:
jobs:
build:
strategy:
matrix:
color: ["orange"]
store: ["fdroid", "github", "google"]
name: Build
runs-on: ubuntu-latest
steps:
@ -34,11 +38,11 @@ jobs:
- name: ktlint
run: ./gradlew clean ktlintCheck
- name: Regular lint
run: ./gradlew app:lintOrangeDebug
- name: Regular lint ${{ matrix.color }}${{ matrix.store }}Debug
run: ./gradlew app:lint${{ matrix.color }}${{ matrix.store }}Debug
- name: Test
run: ./gradlew app:testOrangeDebugUnitTest checks:test
- name: Test ${{ matrix.color }}${{ matrix.store }}DebugUnitTest checks:test
run: ./gradlew app:test${{ matrix.color }}${{ matrix.store }}DebugUnitTest checks:test
- name: Build
run: ./gradlew app:buildOrangeDebug
- name: Build ${{ matrix.color }}${{ matrix.store }}Debug
run: ./gradlew app:build${{ matrix.color }}${{ matrix.store }}Debug

View File

@ -10,6 +10,10 @@ on:
jobs:
build:
strategy:
matrix:
color: ["orange"]
store: ["fdroid", "github", "google"]
name: app:buildOrangeDebug
runs-on: ubuntu-latest
steps:
@ -31,4 +35,4 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run app:buildOrangeDebug
run: ./gradlew app:buildOrangeDebug
run: ./gradlew app:build${{ matrix.color }}${{ matrix.store }}Debug

View File

@ -27,17 +27,17 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Build APK
run: ./gradlew assembleBlueRelease --stacktrace
- name: Build GitHub APK
run: ./gradlew assembleBlueGithubRelease --stacktrace
- name: Build AAB
run: ./gradlew :app:bundleBlueRelease --stacktrace
- name: Build Google AAB
run: ./gradlew :app:bundleBlueGoogleRelease --stacktrace
- uses: r0adkll/sign-android-release@v1.0.4
name: Sign app APK
name: Sign GitHub APK
id: sign_app_apk
with:
releaseDirectory: app/build/outputs/apk/blue/release
releaseDirectory: app/build/outputs/apk/blueGithub/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -46,10 +46,10 @@ jobs:
BUILD_TOOLS_VERSION: "34.0.0"
- uses: r0adkll/sign-android-release@v1.0.4
name: Sign app AAB
name: Sign Google AAB
id: sign_app_aab
with:
releaseDirectory: app/build/outputs/bundle/blueRelease
releaseDirectory: app/build/outputs/bundle/blueGoogleRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -80,4 +80,4 @@ jobs:
track: internal
whatsNewDirectory: googleplay/whatsnew
status: completed
mappingFile: app/build/outputs/mapping/blueRelease/mapping.txt
mappingFile: app/build/outputs/mapping/blueGoogleRelease/mapping.txt

View File

@ -30,19 +30,19 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Test
run: ./gradlew app:testOrangeReleaseUnitTest --stacktrace
run: ./gradlew app:testOrangeGoogleReleaseUnitTest --stacktrace
- name: Build APK
run: ./gradlew assembleOrangeRelease --stacktrace
run: ./gradlew assembleOrangeGoogleRelease --stacktrace
- name: Build AAB
run: ./gradlew :app:bundleOrangeRelease --stacktrace
run: ./gradlew :app:bundleOrangeGoogleRelease --stacktrace
- uses: r0adkll/sign-android-release@v1.0.4
name: Sign app APK
id: sign_app_apk
with:
releaseDirectory: app/build/outputs/apk/orange/release
releaseDirectory: app/build/outputs/apk/orangeGoogle/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -54,7 +54,7 @@ jobs:
name: Sign app AAB
id: sign_app_aab
with:
releaseDirectory: app/build/outputs/bundle/orangeRelease
releaseDirectory: app/build/outputs/bundle/orangeGoogleRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -85,4 +85,4 @@ jobs:
track: production
whatsNewDirectory: googleplay/whatsnew
status: completed
mappingFile: app/build/outputs/mapping/orangeRelease/mapping.txt
mappingFile: app/build/outputs/mapping/orangeGoogleRelease/mapping.txt

View File

@ -46,6 +46,10 @@ android {
buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"")
}
buildTypes {
debug {
getIsDefault().set(true)
}
release {
minifyEnabled true
shrinkResources true
@ -54,13 +58,31 @@ android {
}
flavorDimensions += "color"
flavorDimensions += "store"
productFlavors {
blue {}
blue {
dimension "color"
}
orange {
dimension "color"
resValue "string", "app_name", APP_NAME + " Current"
applicationIdSuffix ".current"
versionNameSuffix "+" + gitSha
}
fdroid {
dimension "store"
}
github {
dimension "store"
}
google {
dimension "store"
}
}
lint {
@ -199,6 +221,9 @@ dependencies {
implementation libs.bundles.aboutlibraries
implementation libs.timber
googleImplementation libs.app.update
googleImplementation libs.app.update.ktx
testImplementation libs.androidx.test.junit
testImplementation libs.robolectric
testImplementation libs.bundles.mockito

View File

@ -0,0 +1,48 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.di
import app.pachli.updatecheck.FdroidService
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object UpdateCheckModule {
@Provides
@Singleton
fun providesFdroidService(
httpClient: OkHttpClient,
gson: Gson
): FdroidService = Retrofit.Builder()
.baseUrl("https://f-droid.org")
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()
.create()
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.updatecheck
import at.connyduck.calladapter.networkresult.NetworkResult
import retrofit2.http.GET
import retrofit2.http.Path
data class FdroidPackageVersion(
val versionName: String,
val versionCode: Int
)
data class FdroidPackage(
val packageName: String,
val suggestedVersionCode: Int,
val packages: List<FdroidPackageVersion>
)
interface FdroidService {
@GET("/api/v1/packages/{package}")
suspend fun getPackage(
@Path("package") pkg: String
): NetworkResult<FdroidPackage>
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.updatecheck
import android.content.Intent
import android.net.Uri
import app.pachli.BuildConfig
import app.pachli.util.SharedPreferencesRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UpdateCheck @Inject constructor(
sharedPreferencesRepository: SharedPreferencesRepository,
private val fdroidService: FdroidService
) : UpdateCheckBase(sharedPreferencesRepository) {
override val updateIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("market://details?id=${BuildConfig.APPLICATION_ID}")
}
override suspend fun remoteFetchLatestVersionCode(): Int? {
return fdroidService.getPackage(BuildConfig.APPLICATION_ID).getOrNull()?.suggestedVersionCode
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<resources>
<string name="update_dialog_message">Open F-Droid to see the details?</string>
<string name="update_dialog_positive">Open F-Droid</string>
</resources>

View File

@ -0,0 +1,48 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.di
import app.pachli.updatecheck.GitHubService
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object UpdateCheckModule {
@Provides
@Singleton
fun providesGitHubService(
httpClient: OkHttpClient,
gson: Gson
): GitHubService = Retrofit.Builder()
.baseUrl("https://api.github.com")
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()
.create()
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.updatecheck
import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Path
data class GitHubReleaseAsset(
/** File name for the asset, e.g., "113.apk" */
val name: String,
/** MIME content type for the asset, e.g., "application/vnd.android.package-archive" */
@SerializedName("content_type") val contentType: String
)
data class GitHubRelease(
/** URL for the release's web page */
@SerializedName("html_url") val htmlUrl: String,
val assets: List<GitHubReleaseAsset>
)
interface GitHubService {
@GET("/repos/{owner}/{repo}/releases/latest")
suspend fun getLatestRelease(
@Path("owner") owner: String,
@Path("repo") repo: String
): NetworkResult<GitHubRelease>
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.updatecheck
import android.content.Intent
import android.net.Uri
import app.pachli.util.SharedPreferencesRepository
import javax.inject.Inject
class UpdateCheck @Inject constructor(
sharedPreferencesRepository: SharedPreferencesRepository,
private val gitHubService: GitHubService
) : UpdateCheckBase(sharedPreferencesRepository) {
private val versionCodeExtractor = """(\d+)\.apk""".toRegex()
override val updateIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("https://www.github.com/pachli/pachli-android/releases/latest")
}
override suspend fun remoteFetchLatestVersionCode(): Int? {
val release = gitHubService.getLatestRelease("pachli", "pachli-android").getOrNull() ?: return null
for (asset in release.assets) {
if (asset.contentType != "application/vnd.android.package-archive") continue
return versionCodeExtractor.find(asset.name)?.groups?.get(1)?.value?.toIntOrNull() ?: continue
}
return null
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<resources>
<string name="update_dialog_message">Open GitHub to see the details?</string>
<string name="update_dialog_positive">Open GitHub</string>
</resources>

View File

@ -0,0 +1,38 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.di
import android.content.Context
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object UpdateCheckModule {
@Provides
@Singleton
fun providesAppUpdateManager(
@ApplicationContext context: Context
): AppUpdateManager = AppUpdateManagerFactory.create(context)
}

View File

@ -0,0 +1,46 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.updatecheck
import android.content.Intent
import android.net.Uri
import app.pachli.BuildConfig
import app.pachli.util.SharedPreferencesRepository
import com.google.android.play.core.appupdate.AppUpdateManager
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
class UpdateCheck @Inject constructor(
sharedPreferencesRepository: SharedPreferencesRepository,
private val appUpdateManager: AppUpdateManager
) : UpdateCheckBase(sharedPreferencesRepository) {
override val updateIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(
"https://play.google.com/store/apps/details?id=${BuildConfig.APPLICATION_ID}")
setPackage("com.android.vending")
}
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
override suspend fun remoteFetchLatestVersionCode(): Int? {
return suspendCancellableCoroutine { cont ->
appUpdateManager.appUpdateInfo.addOnSuccessListener { info ->
cont.resume(info.availableVersionCode()) {}
}
}
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<resources>
<string name="update_dialog_message">Open Google Play to see the details?</string>
<string name="update_dialog_positive">Open Google Play</string>
</resources>

View File

@ -87,9 +87,12 @@ import app.pachli.interfaces.FabFragment
import app.pachli.interfaces.ReselectableFragment
import app.pachli.pager.MainPagerAdapter
import app.pachli.settings.PrefKeys
import app.pachli.updatecheck.UpdateCheck
import app.pachli.updatecheck.UpdateNotificationFrequency
import app.pachli.usecase.DeveloperToolsUseCase
import app.pachli.usecase.LogoutUsecase
import app.pachli.util.EmbeddedFontFamily
import app.pachli.util.await
import app.pachli.util.deleteStaleCachedMedia
import app.pachli.util.emojify
import app.pachli.util.getDimension
@ -159,6 +162,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
@Inject
lateinit var draftsAlert: DraftsAlert
@Inject
lateinit var updateCheck: UpdateCheck
@Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase
@ -403,8 +409,64 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
selectedEmojiPack = currentEmojiPack
recreate()
}
checkForUpdate()
}
/**
* 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 $latestVersionCode")
return@launch
}
}
Timber.d("New version is: $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(R.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

@ -31,6 +31,7 @@ import app.pachli.settings.preference
import app.pachli.settings.preferenceCategory
import app.pachli.settings.sliderPreference
import app.pachli.settings.switchPreference
import app.pachli.updatecheck.UpdateNotificationFrequency
import app.pachli.util.APP_THEME_DEFAULT
import app.pachli.util.LocaleManager
import app.pachli.util.deserialize
@ -283,6 +284,19 @@ class PreferencesFragment : PreferenceFragmentCompat() {
summaryProvider = ProxyPreferencesFragment.SummaryProvider
}
}
preferenceCategory(R.string.pref_title_update_settings) {
listPreference {
setDefaultValue(UpdateNotificationFrequency.ALWAYS.name)
setEntries(R.array.pref_update_notification_frequency_names)
setEntryValues(R.array.pref_update_notification_frequency_values)
key = PrefKeys.UPDATE_NOTIFICATION_FREQUENCY
setSummaryProvider { entry }
setTitle(R.string.pref_title_update_notification_frequency)
isSingleLineTitle = false
icon = makeIcon(GoogleMaterial.Icon.gmd_upgrade)
}
}
}
}

View File

@ -108,6 +108,10 @@ object PrefKeys {
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
const val UPDATE_NOTIFICATION_FREQUENCY = "updateNotificationFrequency"
const val UPDATE_NOTIFICATION_VERSIONCODE = "updateNotificationVersioncode"
const val UPDATE_NOTIFICATION_LAST_NOTIFICATION_MS = "updateNotificationLastNotificationMs"
/** Keys that are no longer used (e.g., the preference has been removed */
object Deprecated {
// Empty at this time

View File

@ -0,0 +1,90 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.updatecheck
import android.content.Intent
import androidx.core.content.edit
import app.pachli.BuildConfig
import app.pachli.settings.PrefKeys
import app.pachli.util.SharedPreferencesRepository
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
enum class UpdateNotificationFrequency {
/** Never prompt the user to update */
NEVER,
/** Prompt the user to update once per version */
ONCE_PER_VERSION,
/** Always prompt the user to update */
ALWAYS,
;
companion object {
fun from(s: String?): UpdateNotificationFrequency {
s ?: return ALWAYS
return try {
valueOf(s.uppercase())
} catch (_: IllegalArgumentException) {
ALWAYS
}
}
}
}
@Singleton
abstract class UpdateCheckBase(private val sharedPreferencesRepository: SharedPreferencesRepository) {
/** 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.
*
* @return The latest version code, or null if it could not be determined
*/
abstract suspend fun remoteFetchLatestVersionCode(): Int?
companion object {
/** How much time should elapse between version checks */
private val MINIMUM_DURATION_BETWEEN_CHECKS = 24.hours
}
}

View File

@ -303,4 +303,10 @@
<item>account</item>
</string-array>
<string-array name="pref_update_notification_frequency_values">
<item>NEVER</item>
<item>ONCE_PER_VERSION</item>
<item>ALWAYS</item>
</string-array>
</resources>

View File

@ -41,4 +41,10 @@
<item>@string/filter_action_warn</item>
<item>@string/filter_action_hide</item>
</string-array>
<string-array name="pref_update_notification_frequency_names">
<item>@string/pref_update_notification_frequency_never</item>
<item>@string/pref_update_notification_frequency_once_per_version</item>
<item>@string/pref_update_notification_frequency_always</item>
</string-array>
</resources>

View File

@ -830,4 +830,13 @@
<string name="dialog_save_profile_changes_message">Do you want to save your profile changes?</string>
<string name="reaction_name_and_count">%1$s %2$d</string>
<string name="pref_title_update_settings">Software updates</string>
<string name="pref_title_update_notification_frequency">Tell me about new Pachli versions</string>
<string name="pref_update_notification_frequency_never">Never</string>
<string name="pref_update_notification_frequency_once_per_version">Once per version</string>
<string name="pref_update_notification_frequency_always">Always</string>
<string name="update_dialog_title">An update is available</string>
<string name="update_dialog_neutral">Don\'t remind me for this version</string>
<string name="update_dialog_negative">Never remind me</string>
</resources>

View File

@ -108,9 +108,15 @@ So if you will resolve issue #1234, name the branch `1234-...`.
### Choose a build variant
Pachli has two build variants, `blue` and `orange`. The blue variant is used to build each release. The `orange` variant can be installed alongside the `blue` variant, and is ideal if you want to keep the released version of Pachli and your testing version installed side by side.
Pachli has 2 x 3 build flavours.
Typically you would configure the build variant in Android Studio with Build > Select Build Variant..., and select `orangeDebug`.
The two colour flavours are `blue` and `orange`. The blue flavour is used to build each production release. The `orange` flavour can be installed alongside the `blue` flavour, and is ideal if you want to keep the released version of Pachli and your testing version installed side by side.
Pachli Current is built from the `orange` flavour.
The three store flavours are `fdroid`, `github`, and `google`. These contain store-specific code; for example, checking for an updated release.
Typically you would configure the build variant in Android Studio with Build > Select Build Variant..., and select `orangeFdroidDebug`.
This is not mandatory, but may make developing easier for you.
@ -280,10 +286,10 @@ If your PR can not be cleanly merged in to `main` it is difficult to review effe
The project has a number of automated tests, they will automatically be run on your PR when it is submitted.
You can run them with the `app:testOrangeDebugUnitTest` task.
You can run them with the `app:testOrangeFdroidDebugUnitTest` task.
```shell
./gradlew app:testOrangeDebugUnitTest
./gradlew app:testOrangeFdroidDebugUnitTest
```
Where practical new code should include new unit tests.
@ -292,20 +298,20 @@ Where practical new code should include new unit tests.
The project uses Android lint to check for many common errors or questionable practices. The Android lint checks will automatically be run on your PR when it is submitted.
You can run them with the `app:lintOrangeDebug` task.
You can run them with the `app:lintOrangeFdroidDebug` task.
```shell
./gradlew app:lintOrangeDebug
./gradlew app:lintOrangeFdroidDebug
```
A number of pre-existing lint errors and warnings have been grandfathered in to the project, and can be seen in the `app/lint-baseline.xml` file.
These are being removed over time, please do not add to them.
If your PR fixes some of the existing lint issues, or moves code so that the baseline file is no longer valid you can you regenerate it with the `updateLintBaselineOrangeDebug` task.
If your PR fixes some of the existing lint issues, or moves code so that the baseline file is no longer valid you can you regenerate it with the `updateLintBaselineOrangeFdroidDebug` task.
```shell
./gradlew updateLintBaselineOrangeDebug
./gradlew updateLintBaselineOrangeFdroidDebug
```
#### Screenshots

View File

@ -23,6 +23,7 @@ androidx-test-core-ktx = "1.5.0"
androidx-viewpager2 = "1.0.0"
androidx-work = "2.8.1"
androidx-room = "2.6.0"
app-update = "2.1.0"
autodispose = "2.2.1"
bouncycastle = "1.70"
conscrypt = "2.5.2"
@ -109,6 +110,8 @@ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "andro
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
app-update = { module = "com.google.android.play:app-update", version.ref = "app-update" }
app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "app-update" }
autodispose-android-lifecycle = { module = "com.uber.autodispose2:autodispose-androidx-lifecycle", version.ref = "autodispose" }
autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" }
bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" }