diff --git a/app/build.gradle b/app/build.gradle index 01dc1b9..72ed13b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,6 +50,7 @@ dependencies { implementation 'com.beust:klaxon:5.0.1' implementation 'com.squareup.okhttp3:okhttp:4.3.0' implementation 'com.google.android.material:material:1.1.0-beta02' + implementation 'net.swiftzer.semver:semver:1.1.0' implementation 'io.sentry:sentry-android:1.7.27' implementation 'org.slf4j:slf4j-nop:1.7.25' implementation 'com.google.android.gms:play-services-analytics:10.2.4' diff --git a/app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt b/app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt index fdb2042..340e582 100644 --- a/app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt +++ b/app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt @@ -1,6 +1,7 @@ package tech.httptoolkit.android import android.app.Application +import android.content.Context import android.util.Log import com.android.installreferrer.api.InstallReferrerClient import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResponse @@ -11,10 +12,17 @@ import com.google.android.gms.analytics.HitBuilders import com.google.android.gms.analytics.Tracker import io.sentry.Sentry import io.sentry.android.AndroidSentryClientFactory +import kotlinx.coroutines.* +import net.swiftzer.semver.SemVer +import okhttp3.OkHttpClient +import okhttp3.Request +import java.text.SimpleDateFormat +import java.util.* import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine + class HttpToolkitApplication : Application() { private val TAG = HttpToolkitApplication::class.simpleName @@ -149,4 +157,87 @@ class HttpToolkitApplication : Application() { analytics?.setLocalDispatchPeriod(120) // Set dispatching back to Android default } + suspend fun isUpdateRequired(): Boolean { + return withContext(Dispatchers.IO) { + if (wasInstalledFromStore(this@HttpToolkitApplication)) { + // We only check for updates for side-loaded/ADB-loaded versions. This is useful + // because otherwise anything outside the play store gets no updates. + Log.i(TAG, "Installed from play store, no update prompting required") + return@withContext false + } + + val httpClient = OkHttpClient() + val request = Request.Builder() + .url("https://api.github.com/repos/httptoolkit/httptoolkit-android/releases/latest") + .build() + + try { + val response = httpClient.newCall(request).execute().use { response -> + if (response.code != 200) throw RuntimeException("Failed to check for updates") + response.body!!.string() + } + + val release = Klaxon().parse(response)!! + val releaseVersion = + tryParseSemver(release.name) + ?: tryParseSemver(release.tag_name) + ?: throw RuntimeException("Could not parse release version ${release.tag_name}") + val releaseDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").parse(release.published_at) + + val installedVersion = getInstalledVersion(this@HttpToolkitApplication) + + val updateAvailable = releaseVersion > installedVersion + // We avoid immediately prompting for updates because a) there's a review delay + // before new updates go live, and b) it's annoying otherwise, if there's a rapid + // series of releases. Better to start chasing users only after a week stable. + val updateNotTooRecent = releaseDate.before(daysAgo(0)) + + Log.i(TAG, + if (updateAvailable && updateNotTooRecent) + "New version available, released > 1 week" + else if (updateAvailable) + "New version available, but still recent, released $releaseDate" + else + "App is up to date" + ) + return@withContext updateAvailable && updateNotTooRecent + } catch (e: Exception) { + Log.w(TAG, e) + return@withContext false + } + } + } + +} + +private fun wasInstalledFromStore(context: Context): Boolean { + return context.packageManager.getInstallerPackageName(context.packageName) != null +} + +private data class GithubRelease( + val tag_name: String?, + val name: String?, + val published_at: String +) + +private fun tryParseSemver(version: String?): SemVer? = try { + if (version == null) null + else SemVer.parse( + // Strip leading 'v' + version.replace(Regex("^v"), "") + ) +} catch (e: IllegalArgumentException) { + null +} + +private fun getInstalledVersion(context: Context): SemVer { + return SemVer.parse( + context.packageManager.getPackageInfo(context.packageName, 0).versionName + ) +} + +private fun daysAgo(days: Int): Date { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, -days) + return calendar.time } \ No newline at end of file diff --git a/app/src/main/java/tech/httptoolkit/android/MainActivity.kt b/app/src/main/java/tech/httptoolkit/android/MainActivity.kt index 3cc12b0..d4993df 100644 --- a/app/src/main/java/tech/httptoolkit/android/MainActivity.kt +++ b/app/src/main/java/tech/httptoolkit/android/MainActivity.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.net.Uri import android.net.VpnService import android.os.Bundle @@ -18,6 +19,7 @@ import android.widget.TextView import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.google.android.gms.common.GooglePlayServicesUtil import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.sentry.Sentry import kotlinx.coroutines.* @@ -94,6 +96,13 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { } } } + + // Async check for updates, and maybe prompt the user if necessary (if using play store) + launch { + supervisorScope { + if (isStoreAvailable(this@MainActivity) && app.isUpdateRequired()) promptToUpdate() + } + } } override fun onResume() { @@ -460,4 +469,31 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { } } + private suspend fun promptToUpdate() { + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@MainActivity) + .setTitle("Updates available") + .setIcon(R.drawable.ic_info_circle) + .setMessage("An updated version of HTTP Toolkit is available") + .setNegativeButton("Ignore") { _, _ -> } + .setPositiveButton("Update now") { _, _ -> + // Open the app in the market. That a release is available on github doesn't + // *strictly* mean that it's available on the Android market right now, but + // it is imminent, and installing from play means it'll update fully later. + startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("market://details?id=tech.httptoolkit.android.v1") + } + ) + } + .show() + } + } +} + +private fun isStoreAvailable(context: Context): Boolean = try { + context.packageManager.getPackageInfo(GooglePlayServicesUtil.GOOGLE_PLAY_STORE_PACKAGE, 0) + true +} catch (e: PackageManager.NameNotFoundException) { + false } \ No newline at end of file