Skip to content

Commit 65a0400

Browse files
committed
Prompt users to update via play store, if available & necessary
This is intended for users who sideload the app (via the ADB interceptor) and then continue to use it for a while. These users won't receive updates at all. Not perfect, but for now we'll nudge them across onto the play store version in that case. Later the ADB interceptor will also update installs automatically, to handle users using ADB setup long term.
1 parent 9f8b340 commit 65a0400

File tree

3 files changed

+128
-0
lines changed

3 files changed

+128
-0
lines changed

app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies {
5050
implementation 'com.beust:klaxon:5.0.1'
5151
implementation 'com.squareup.okhttp3:okhttp:4.3.0'
5252
implementation 'com.google.android.material:material:1.1.0-beta02'
53+
implementation 'net.swiftzer.semver:semver:1.1.0'
5354
implementation 'io.sentry:sentry-android:1.7.27'
5455
implementation 'org.slf4j:slf4j-nop:1.7.25'
5556
implementation 'com.google.android.gms:play-services-analytics:10.2.4'

app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tech.httptoolkit.android
22

33
import android.app.Application
4+
import android.content.Context
45
import android.util.Log
56
import com.android.installreferrer.api.InstallReferrerClient
67
import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse
@@ -11,10 +12,17 @@ import com.google.android.gms.analytics.HitBuilders
1112
import com.google.android.gms.analytics.Tracker
1213
import io.sentry.Sentry
1314
import io.sentry.android.AndroidSentryClientFactory
15+
import kotlinx.coroutines.*
16+
import net.swiftzer.semver.SemVer
17+
import okhttp3.OkHttpClient
18+
import okhttp3.Request
19+
import java.text.SimpleDateFormat
20+
import java.util.*
1421
import java.util.concurrent.atomic.AtomicBoolean
1522
import kotlin.coroutines.resume
1623
import kotlin.coroutines.suspendCoroutine
1724

25+
1826
class HttpToolkitApplication : Application() {
1927

2028
private val TAG = HttpToolkitApplication::class.simpleName
@@ -149,4 +157,87 @@ class HttpToolkitApplication : Application() {
149157
analytics?.setLocalDispatchPeriod(120) // Set dispatching back to Android default
150158
}
151159

160+
suspend fun isUpdateRequired(): Boolean {
161+
return withContext(Dispatchers.IO) {
162+
if (wasInstalledFromStore(this@HttpToolkitApplication)) {
163+
// We only check for updates for side-loaded/ADB-loaded versions. This is useful
164+
// because otherwise anything outside the play store gets no updates.
165+
Log.i(TAG, "Installed from play store, no update prompting required")
166+
return@withContext false
167+
}
168+
169+
val httpClient = OkHttpClient()
170+
val request = Request.Builder()
171+
.url("https://api.github.com/repos/httptoolkit/httptoolkit-android/releases/latest")
172+
.build()
173+
174+
try {
175+
val response = httpClient.newCall(request).execute().use { response ->
176+
if (response.code != 200) throw RuntimeException("Failed to check for updates")
177+
response.body!!.string()
178+
}
179+
180+
val release = Klaxon().parse<GithubRelease>(response)!!
181+
val releaseVersion =
182+
tryParseSemver(release.name)
183+
?: tryParseSemver(release.tag_name)
184+
?: throw RuntimeException("Could not parse release version ${release.tag_name}")
185+
val releaseDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(release.published_at)
186+
187+
val installedVersion = getInstalledVersion(this@HttpToolkitApplication)
188+
189+
val updateAvailable = releaseVersion > installedVersion
190+
// We avoid immediately prompting for updates because a) there's a review delay
191+
// before new updates go live, and b) it's annoying otherwise, if there's a rapid
192+
// series of releases. Better to start chasing users only after a week stable.
193+
val updateNotTooRecent = releaseDate.before(daysAgo(0))
194+
195+
Log.i(TAG,
196+
if (updateAvailable && updateNotTooRecent)
197+
"New version available, released > 1 week"
198+
else if (updateAvailable)
199+
"New version available, but still recent, released $releaseDate"
200+
else
201+
"App is up to date"
202+
)
203+
return@withContext updateAvailable && updateNotTooRecent
204+
} catch (e: Exception) {
205+
Log.w(TAG, e)
206+
return@withContext false
207+
}
208+
}
209+
}
210+
211+
}
212+
213+
private fun wasInstalledFromStore(context: Context): Boolean {
214+
return context.packageManager.getInstallerPackageName(context.packageName) != null
215+
}
216+
217+
private data class GithubRelease(
218+
val tag_name: String?,
219+
val name: String?,
220+
val published_at: String
221+
)
222+
223+
private fun tryParseSemver(version: String?): SemVer? = try {
224+
if (version == null) null
225+
else SemVer.parse(
226+
// Strip leading 'v'
227+
version.replace(Regex("^v"), "")
228+
)
229+
} catch (e: IllegalArgumentException) {
230+
null
231+
}
232+
233+
private fun getInstalledVersion(context: Context): SemVer {
234+
return SemVer.parse(
235+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
236+
)
237+
}
238+
239+
private fun daysAgo(days: Int): Date {
240+
val calendar = Calendar.getInstance()
241+
calendar.add(Calendar.DAY_OF_YEAR, -days)
242+
return calendar.time
152243
}

app/src/main/java/tech/httptoolkit/android/MainActivity.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
44
import android.content.Context
55
import android.content.Intent
66
import android.content.IntentFilter
7+
import android.content.pm.PackageManager
78
import android.net.Uri
89
import android.net.VpnService
910
import android.os.Bundle
@@ -18,6 +19,7 @@ import android.widget.TextView
1819
import androidx.annotation.StringRes
1920
import androidx.appcompat.app.AppCompatActivity
2021
import androidx.localbroadcastmanager.content.LocalBroadcastManager
22+
import com.google.android.gms.common.GooglePlayServicesUtil
2123
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2224
import io.sentry.Sentry
2325
import kotlinx.coroutines.*
@@ -94,6 +96,13 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
9496
}
9597
}
9698
}
99+
100+
// Async check for updates, and maybe prompt the user if necessary (if using play store)
101+
launch {
102+
supervisorScope {
103+
if (isStoreAvailable(this@MainActivity) && app.isUpdateRequired()) promptToUpdate()
104+
}
105+
}
97106
}
98107

99108
override fun onResume() {
@@ -460,4 +469,31 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
460469
}
461470
}
462471

472+
private suspend fun promptToUpdate() {
473+
withContext(Dispatchers.Main) {
474+
MaterialAlertDialogBuilder(this@MainActivity)
475+
.setTitle("Updates available")
476+
.setIcon(R.drawable.ic_info_circle)
477+
.setMessage("An updated version of HTTP Toolkit is available")
478+
.setNegativeButton("Ignore") { _, _ -> }
479+
.setPositiveButton("Update now") { _, _ ->
480+
// Open the app in the market. That a release is available on github doesn't
481+
// *strictly* mean that it's available on the Android market right now, but
482+
// it is imminent, and installing from play means it'll update fully later.
483+
startActivity(
484+
Intent(Intent.ACTION_VIEW).apply {
485+
data = Uri.parse("market://details?id=tech.httptoolkit.android.v1")
486+
}
487+
)
488+
}
489+
.show()
490+
}
491+
}
492+
}
493+
494+
private fun isStoreAvailable(context: Context): Boolean = try {
495+
context.packageManager.getPackageInfo(GooglePlayServicesUtil.GOOGLE_PLAY_STORE_PACKAGE, 0)
496+
true
497+
} catch (e: PackageManager.NameNotFoundException) {
498+
false
463499
}

0 commit comments

Comments
 (0)