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

View File

@ -10,6 +10,10 @@ on:
jobs: jobs:
build: build:
strategy:
matrix:
color: ["orange"]
store: ["fdroid", "github", "google"]
name: app:buildOrangeDebug name: app:buildOrangeDebug
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -31,4 +35,4 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run app:buildOrangeDebug - 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: with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Build APK - name: Build GitHub APK
run: ./gradlew assembleBlueRelease --stacktrace run: ./gradlew assembleBlueGithubRelease --stacktrace
- name: Build AAB - name: Build Google AAB
run: ./gradlew :app:bundleBlueRelease --stacktrace run: ./gradlew :app:bundleBlueGoogleRelease --stacktrace
- uses: r0adkll/sign-android-release@v1.0.4 - uses: r0adkll/sign-android-release@v1.0.4
name: Sign app APK name: Sign GitHub APK
id: sign_app_apk id: sign_app_apk
with: with:
releaseDirectory: app/build/outputs/apk/blue/release releaseDirectory: app/build/outputs/apk/blueGithub/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }} signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }} alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -46,10 +46,10 @@ jobs:
BUILD_TOOLS_VERSION: "34.0.0" BUILD_TOOLS_VERSION: "34.0.0"
- uses: r0adkll/sign-android-release@v1.0.4 - uses: r0adkll/sign-android-release@v1.0.4
name: Sign app AAB name: Sign Google AAB
id: sign_app_aab id: sign_app_aab
with: with:
releaseDirectory: app/build/outputs/bundle/blueRelease releaseDirectory: app/build/outputs/bundle/blueGoogleRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }} signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }} alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -80,4 +80,4 @@ jobs:
track: internal track: internal
whatsNewDirectory: googleplay/whatsnew whatsNewDirectory: googleplay/whatsnew
status: completed 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' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Test - name: Test
run: ./gradlew app:testOrangeReleaseUnitTest --stacktrace run: ./gradlew app:testOrangeGoogleReleaseUnitTest --stacktrace
- name: Build APK - name: Build APK
run: ./gradlew assembleOrangeRelease --stacktrace run: ./gradlew assembleOrangeGoogleRelease --stacktrace
- name: Build AAB - name: Build AAB
run: ./gradlew :app:bundleOrangeRelease --stacktrace run: ./gradlew :app:bundleOrangeGoogleRelease --stacktrace
- uses: r0adkll/sign-android-release@v1.0.4 - uses: r0adkll/sign-android-release@v1.0.4
name: Sign app APK name: Sign app APK
id: sign_app_apk id: sign_app_apk
with: with:
releaseDirectory: app/build/outputs/apk/orange/release releaseDirectory: app/build/outputs/apk/orangeGoogle/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }} signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }} alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -54,7 +54,7 @@ jobs:
name: Sign app AAB name: Sign app AAB
id: sign_app_aab id: sign_app_aab
with: with:
releaseDirectory: app/build/outputs/bundle/orangeRelease releaseDirectory: app/build/outputs/bundle/orangeGoogleRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }} signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }} alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@ -85,4 +85,4 @@ jobs:
track: production track: production
whatsNewDirectory: googleplay/whatsnew whatsNewDirectory: googleplay/whatsnew
status: completed 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\"") buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"")
} }
buildTypes { buildTypes {
debug {
getIsDefault().set(true)
}
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
@ -54,13 +58,31 @@ android {
} }
flavorDimensions += "color" flavorDimensions += "color"
flavorDimensions += "store"
productFlavors { productFlavors {
blue {} blue {
dimension "color"
}
orange { orange {
dimension "color"
resValue "string", "app_name", APP_NAME + " Current" resValue "string", "app_name", APP_NAME + " Current"
applicationIdSuffix ".current" applicationIdSuffix ".current"
versionNameSuffix "+" + gitSha versionNameSuffix "+" + gitSha
} }
fdroid {
dimension "store"
}
github {
dimension "store"
}
google {
dimension "store"
}
} }
lint { lint {
@ -199,6 +221,9 @@ dependencies {
implementation libs.bundles.aboutlibraries implementation libs.bundles.aboutlibraries
implementation libs.timber implementation libs.timber
googleImplementation libs.app.update
googleImplementation libs.app.update.ktx
testImplementation libs.androidx.test.junit testImplementation libs.androidx.test.junit
testImplementation libs.robolectric testImplementation libs.robolectric
testImplementation libs.bundles.mockito 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.interfaces.ReselectableFragment
import app.pachli.pager.MainPagerAdapter import app.pachli.pager.MainPagerAdapter
import app.pachli.settings.PrefKeys import app.pachli.settings.PrefKeys
import app.pachli.updatecheck.UpdateCheck
import app.pachli.updatecheck.UpdateNotificationFrequency
import app.pachli.usecase.DeveloperToolsUseCase import app.pachli.usecase.DeveloperToolsUseCase
import app.pachli.usecase.LogoutUsecase import app.pachli.usecase.LogoutUsecase
import app.pachli.util.EmbeddedFontFamily import app.pachli.util.EmbeddedFontFamily
import app.pachli.util.await
import app.pachli.util.deleteStaleCachedMedia import app.pachli.util.deleteStaleCachedMedia
import app.pachli.util.emojify import app.pachli.util.emojify
import app.pachli.util.getDimension import app.pachli.util.getDimension
@ -159,6 +162,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
@Inject @Inject
lateinit var draftsAlert: DraftsAlert lateinit var draftsAlert: DraftsAlert
@Inject
lateinit var updateCheck: UpdateCheck
@Inject @Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase lateinit var developerToolsUseCase: DeveloperToolsUseCase
@ -403,8 +409,64 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
selectedEmojiPack = currentEmojiPack selectedEmojiPack = currentEmojiPack
recreate() 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() { override fun onStart() {
super.onStart() super.onStart()
// For some reason the navigation drawer is opened when the activity is recreated // 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.preferenceCategory
import app.pachli.settings.sliderPreference import app.pachli.settings.sliderPreference
import app.pachli.settings.switchPreference import app.pachli.settings.switchPreference
import app.pachli.updatecheck.UpdateNotificationFrequency
import app.pachli.util.APP_THEME_DEFAULT import app.pachli.util.APP_THEME_DEFAULT
import app.pachli.util.LocaleManager import app.pachli.util.LocaleManager
import app.pachli.util.deserialize import app.pachli.util.deserialize
@ -283,6 +284,19 @@ class PreferencesFragment : PreferenceFragmentCompat() {
summaryProvider = ProxyPreferencesFragment.SummaryProvider 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 */ /** UI text scaling factor, stored as float, 100 = 100% = no scaling */
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" 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 */ /** Keys that are no longer used (e.g., the preference has been removed */
object Deprecated { object Deprecated {
// Empty at this time // 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> <item>account</item>
</string-array> </string-array>
<string-array name="pref_update_notification_frequency_values">
<item>NEVER</item>
<item>ONCE_PER_VERSION</item>
<item>ALWAYS</item>
</string-array>
</resources> </resources>

View File

@ -41,4 +41,10 @@
<item>@string/filter_action_warn</item> <item>@string/filter_action_warn</item>
<item>@string/filter_action_hide</item> <item>@string/filter_action_hide</item>
</string-array> </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> </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="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="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> </resources>

View File

@ -108,9 +108,15 @@ So if you will resolve issue #1234, name the branch `1234-...`.
### Choose a build variant ### 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. 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. 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 ```shell
./gradlew app:testOrangeDebugUnitTest ./gradlew app:testOrangeFdroidDebugUnitTest
``` ```
Where practical new code should include new unit tests. 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. 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 ```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. 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. 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 ```shell
./gradlew updateLintBaselineOrangeDebug ./gradlew updateLintBaselineOrangeFdroidDebug
``` ```
#### Screenshots #### Screenshots

View File

@ -23,6 +23,7 @@ androidx-test-core-ktx = "1.5.0"
androidx-viewpager2 = "1.0.0" androidx-viewpager2 = "1.0.0"
androidx-work = "2.8.1" androidx-work = "2.8.1"
androidx-room = "2.6.0" androidx-room = "2.6.0"
app-update = "2.1.0"
autodispose = "2.2.1" autodispose = "2.2.1"
bouncycastle = "1.70" bouncycastle = "1.70"
conscrypt = "2.5.2" 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-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-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
androidx-work-testing = { module = "androidx.work:work-testing", 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-android-lifecycle = { module = "com.uber.autodispose2:autodispose-androidx-lifecycle", version.ref = "autodispose" }
autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" } autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" }
bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" }