From dda9dde1b9dcf0d71df4d971d83e7680331e7f1c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 8 Nov 2023 08:42:39 +0100 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 16 ++-- .../workflows/populate-gradle-build-cache.yml | 6 +- .../upload-blue-release-google-play.yml | 18 ++-- .../upload-orange-release-google-play.yml | 12 +-- app/build.gradle | 27 +++++- .../kotlin/app/pachli/di/UpdateCheckModule.kt | 48 ++++++++++ .../app/pachli/updatecheck/FdroidService.kt | 40 +++++++++ .../app/pachli/updatecheck/UpdateCheck.kt | 39 ++++++++ app/src/fdroid/res/values/strings.xml | 21 +++++ .../kotlin/app/pachli/di/UpdateCheckModule.kt | 48 ++++++++++ .../app/pachli/updatecheck/GithubService.kt | 45 ++++++++++ .../app/pachli/updatecheck/UpdateCheck.kt | 44 +++++++++ app/src/github/res/values/strings.xml | 21 +++++ .../kotlin/app/pachli/di/UpdateCheckModule.kt | 38 ++++++++ .../app/pachli/updatecheck/UpdateCheck.kt | 46 ++++++++++ app/src/google/res/values/strings.xml | 21 +++++ app/src/main/java/app/pachli/MainActivity.kt | 62 +++++++++++++ .../preference/PreferencesFragment.kt | 14 +++ .../app/pachli/settings/SettingsConstants.kt | 4 + .../app/pachli/updatecheck/UpdateCheck.kt | 90 +++++++++++++++++++ app/src/main/res/values/donottranslate.xml | 6 ++ app/src/main/res/values/string-arrays.xml | 6 ++ app/src/main/res/values/strings.xml | 9 ++ docs/contributing/code.md | 22 +++-- gradle/libs.versions.toml | 3 + 25 files changed, 675 insertions(+), 31 deletions(-) create mode 100644 app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt create mode 100644 app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt create mode 100644 app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt create mode 100644 app/src/fdroid/res/values/strings.xml create mode 100644 app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt create mode 100644 app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt create mode 100644 app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt create mode 100644 app/src/github/res/values/strings.xml create mode 100644 app/src/google/kotlin/app/pachli/di/UpdateCheckModule.kt create mode 100644 app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt create mode 100644 app/src/google/res/values/strings.xml create mode 100644 app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e11bb23e..3f7a3b6a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/populate-gradle-build-cache.yml b/.github/workflows/populate-gradle-build-cache.yml index 2a2a091e8..f506cd76e 100644 --- a/.github/workflows/populate-gradle-build-cache.yml +++ b/.github/workflows/populate-gradle-build-cache.yml @@ -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 diff --git a/.github/workflows/upload-blue-release-google-play.yml b/.github/workflows/upload-blue-release-google-play.yml index 4b197eabe..7167c55e6 100644 --- a/.github/workflows/upload-blue-release-google-play.yml +++ b/.github/workflows/upload-blue-release-google-play.yml @@ -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 diff --git a/.github/workflows/upload-orange-release-google-play.yml b/.github/workflows/upload-orange-release-google-play.yml index cda14cb74..6d80d3d69 100644 --- a/.github/workflows/upload-orange-release-google-play.yml +++ b/.github/workflows/upload-orange-release-google-play.yml @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 3e7c1d90a..01689c74b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt b/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt new file mode 100644 index 000000000..774ea438a --- /dev/null +++ b/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt @@ -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 . + */ + +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() +} diff --git a/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt b/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt new file mode 100644 index 000000000..1d6df81cd --- /dev/null +++ b/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt @@ -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 . + */ + +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 +) + +interface FdroidService { + @GET("/api/v1/packages/{package}") + suspend fun getPackage( + @Path("package") pkg: String + ): NetworkResult +} diff --git a/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt b/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt new file mode 100644 index 000000000..99556cfb8 --- /dev/null +++ b/app/src/fdroid/kotlin/app/pachli/updatecheck/UpdateCheck.kt @@ -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 . + */ + +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 + } +} diff --git a/app/src/fdroid/res/values/strings.xml b/app/src/fdroid/res/values/strings.xml new file mode 100644 index 000000000..f6fbda744 --- /dev/null +++ b/app/src/fdroid/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + Open F-Droid to see the details? + Open F-Droid + diff --git a/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt b/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt new file mode 100644 index 000000000..428a8371b --- /dev/null +++ b/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt @@ -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 . + */ + +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() +} diff --git a/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt b/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt new file mode 100644 index 000000000..ee5c96592 --- /dev/null +++ b/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt @@ -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 . + */ + +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 +) + +interface GitHubService { + @GET("/repos/{owner}/{repo}/releases/latest") + suspend fun getLatestRelease( + @Path("owner") owner: String, + @Path("repo") repo: String + ): NetworkResult +} diff --git a/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt b/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt new file mode 100644 index 000000000..e79a43c5e --- /dev/null +++ b/app/src/github/kotlin/app/pachli/updatecheck/UpdateCheck.kt @@ -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 . + */ + +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 + } +} diff --git a/app/src/github/res/values/strings.xml b/app/src/github/res/values/strings.xml new file mode 100644 index 000000000..0582bc324 --- /dev/null +++ b/app/src/github/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + Open GitHub to see the details? + Open GitHub + diff --git a/app/src/google/kotlin/app/pachli/di/UpdateCheckModule.kt b/app/src/google/kotlin/app/pachli/di/UpdateCheckModule.kt new file mode 100644 index 000000000..3b4f30658 --- /dev/null +++ b/app/src/google/kotlin/app/pachli/di/UpdateCheckModule.kt @@ -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 . + */ + +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) +} diff --git a/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt b/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt new file mode 100644 index 000000000..cbffc1a09 --- /dev/null +++ b/app/src/google/kotlin/app/pachli/updatecheck/UpdateCheck.kt @@ -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 . + */ + +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()) {} + } + } + } +} diff --git a/app/src/google/res/values/strings.xml b/app/src/google/res/values/strings.xml new file mode 100644 index 000000000..f98c66b7f --- /dev/null +++ b/app/src/google/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + Open Google Play to see the details? + Open Google Play + diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index fcbfd03d0..0bdc07bfd 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -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 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 e89852442..1436c7fd9 100644 --- a/app/src/main/java/app/pachli/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/PreferencesFragment.kt @@ -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) + } + } } } diff --git a/app/src/main/java/app/pachli/settings/SettingsConstants.kt b/app/src/main/java/app/pachli/settings/SettingsConstants.kt index 5ca210635..be47ffaa2 100644 --- a/app/src/main/java/app/pachli/settings/SettingsConstants.kt +++ b/app/src/main/java/app/pachli/settings/SettingsConstants.kt @@ -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 diff --git a/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt b/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt new file mode 100644 index 000000000..f790d42f5 --- /dev/null +++ b/app/src/main/java/app/pachli/updatecheck/UpdateCheck.kt @@ -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 . + */ + +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 + } +} diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index c3aaf994a..da498e603 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -303,4 +303,10 @@ account + + NEVER + ONCE_PER_VERSION + ALWAYS + + diff --git a/app/src/main/res/values/string-arrays.xml b/app/src/main/res/values/string-arrays.xml index 728a87428..a4ee5d888 100644 --- a/app/src/main/res/values/string-arrays.xml +++ b/app/src/main/res/values/string-arrays.xml @@ -41,4 +41,10 @@ @string/filter_action_warn @string/filter_action_hide + + + @string/pref_update_notification_frequency_never + @string/pref_update_notification_frequency_once_per_version + @string/pref_update_notification_frequency_always + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a3273654..ee34ab54b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -830,4 +830,13 @@ Do you want to save your profile changes? %1$s %2$d + + Software updates + Tell me about new Pachli versions + Never + Once per version + Always + An update is available + Don\'t remind me for this version + Never remind me diff --git a/docs/contributing/code.md b/docs/contributing/code.md index 983c3e09e..c6455ceb9 100644 --- a/docs/contributing/code.md +++ b/docs/contributing/code.md @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af69e1729..407de3ebd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }