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:
parent
86dee94035
commit
dda9dde1b9
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
|
@ -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()) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in New Issue