From aa2d92e6a8ae156e5a047aa65ff96100d8712dd9 Mon Sep 17 00:00:00 2001 From: Mygod Date: Sun, 24 Oct 2021 17:35:38 -0400 Subject: [PATCH] Add support for checking app updates --- mobile/build.gradle.kts | 2 + .../be/mygod/vpnhotspot/util/UpdateChecker.kt | 67 +++++++++++++++++ .../be/mygod/vpnhotspot/util/UpdateChecker.kt | 56 ++++++++++++++ .../java/be/mygod/vpnhotspot/MainActivity.kt | 73 ++++++++++++++----- .../be/mygod/vpnhotspot/client/MacLookup.kt | 3 +- .../be/mygod/vpnhotspot/util/AppUpdate.kt | 10 +++ .../java/be/mygod/vpnhotspot/util/Utils.kt | 8 ++ .../main/res/drawable/ic_action_update.xml | 10 +++ .../main/res/drawable/ic_file_downloading.xml | 10 +++ mobile/src/main/res/menu/navigation.xml | 6 ++ 10 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 mobile/src/freedom/java/be/mygod/vpnhotspot/util/UpdateChecker.kt create mode 100644 mobile/src/google/java/be/mygod/vpnhotspot/util/UpdateChecker.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt create mode 100644 mobile/src/main/res/drawable/ic_action_update.xml create mode 100644 mobile/src/main/res/drawable/ic_file_downloading.xml diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 8a909de8..a79278c2 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -93,6 +93,8 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") add("googleImplementation", "com.github.tiann:FreeReflection:3.1.0") + add("googleImplementation", "com.google.android.play:core:1.10.2") + add("googleImplementation", "com.google.android.play:core-ktx:1.8.1") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.room:room-testing:$roomVersion") androidTestImplementation("androidx.test:runner:1.4.0") diff --git a/mobile/src/freedom/java/be/mygod/vpnhotspot/util/UpdateChecker.kt b/mobile/src/freedom/java/be/mygod/vpnhotspot/util/UpdateChecker.kt new file mode 100644 index 00000000..b08cc44f --- /dev/null +++ b/mobile/src/freedom/java/be/mygod/vpnhotspot/util/UpdateChecker.kt @@ -0,0 +1,67 @@ +package be.mygod.vpnhotspot.util + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.core.content.edit +import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import timber.log.Timber +import java.net.HttpURLConnection +import java.net.URL +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.math.max + +object UpdateChecker { + private const val KEY_LAST_FETCHED = "update.lastFetched" + private const val KEY_VERSION = "update.version" + private const val KEY_PUBLISHED = "update.published" + private const val UPDATE_INTERVAL = 1000 * 60 * 60 * 6 + + private class GitHubUpdate(override val message: String, private val published: Long) : AppUpdate { + override val stalenessDays get() = max(0, + TimeUnit.DAYS.convert(System.currentTimeMillis() - published, TimeUnit.MILLISECONDS)).toInt() + + override fun updateForResult(activity: Activity, requestCode: Int) { + app.customTabsIntent.launchUrl(activity, Uri.parse("https://github.com/Mygod/VPNHotspot/releases")) + } + } + + fun check() = flow { + val myVersion = "v${BuildConfig.VERSION_NAME}" + emit(app.pref.getString(KEY_VERSION, null)?.let { + if (myVersion == it) null else GitHubUpdate(it, app.pref.getLong(KEY_PUBLISHED, -1)) + }) + while (true) { + val now = System.currentTimeMillis() + val lastFetched = app.pref.getLong(KEY_LAST_FETCHED, -1) + if (lastFetched in 0..now) delay(lastFetched + UPDATE_INTERVAL - now) + val conn = URL("https://api.github.com/repos/Mygod/VPNHotspot/releases/latest") + .openConnection() as HttpURLConnection + try { + conn.setRequestProperty("Accept", "application/vnd.github.v3+json") + val response = JSONObject(withContext(Dispatchers.IO) { + conn.inputStream.bufferedReader().readText() + }) + val version = response.getString("name") + val published = Instant.parse(response.getString("published_at")).toEpochMilli() + app.pref.edit { + putLong(KEY_LAST_FETCHED, System.currentTimeMillis()) + putString(KEY_VERSION, version) + putLong(KEY_PUBLISHED, published) + } + emit(if (myVersion == version) null else GitHubUpdate(version, published)) + } catch (e: Exception) { + Timber.w(e) + } finally { + conn.disconnectCompat() + } + } + } +} diff --git a/mobile/src/google/java/be/mygod/vpnhotspot/util/UpdateChecker.kt b/mobile/src/google/java/be/mygod/vpnhotspot/util/UpdateChecker.kt new file mode 100644 index 00000000..1cd885c7 --- /dev/null +++ b/mobile/src/google/java/be/mygod/vpnhotspot/util/UpdateChecker.kt @@ -0,0 +1,56 @@ +package be.mygod.vpnhotspot.util + +import android.app.Activity +import android.net.Uri +import be.mygod.vpnhotspot.App.Companion.app +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.install.model.InstallErrorCode +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.ktx.AppUpdateResult +import com.google.android.play.core.ktx.requestUpdateFlow +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber + +object UpdateChecker { + private class UpdateAvailable(private val update: AppUpdateResult.Available) : AppUpdate { + override val stalenessDays get() = update.updateInfo.clientVersionStalenessDays() ?: 0 + override fun updateForResult(activity: Activity, requestCode: Int) = try { + check(update.startFlexibleUpdate(activity, requestCode)) { "startFlexibleUpdate failed" } + } catch (e: Exception) { + Timber.w(e) + app.customTabsIntent.launchUrl(activity, + Uri.parse("https://play.google.com/store/apps/details?id=be.mygod.vpnhotspot")) + } + } + private class UpdateDownloading(private val update: AppUpdateResult.InProgress) : AppUpdate { + override val downloaded get() = false + override val message: String? get() { + if (update.installState.installStatus() != InstallStatus.FAILED) return null + val code = update.installState.installErrorCode() + for (f in InstallErrorCode::class.java.declaredFields) if (f.getInt(null) == code) return f.name + return "Unrecognized Error" + } + } + private class UpdateDownloaded(private val update: AppUpdateResult.Downloaded) : AppUpdate { + override val downloaded get() = true + override val stalenessDays get() = 0 + override fun updateForResult(activity: Activity, requestCode: Int) { + GlobalScope.launch { update.completeUpdate() } + } + } + + private val manager by lazy { AppUpdateManagerFactory.create(app) } + + fun check() = manager.requestUpdateFlow().map { result -> + when (result) { + is AppUpdateResult.NotAvailable -> null + is AppUpdateResult.Available -> UpdateAvailable(result) + is AppUpdateResult.InProgress -> { + if (result.installState.installStatus() == InstallStatus.CANCELED) null else UpdateDownloading(result) + } + is AppUpdateResult.Downloaded -> UpdateDownloaded(result) + } + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt index d2acd4ae..70176f6f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt @@ -6,26 +6,46 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import be.mygod.vpnhotspot.client.ClientViewModel import be.mygod.vpnhotspot.client.ClientsFragment import be.mygod.vpnhotspot.databinding.ActivityMainBinding import be.mygod.vpnhotspot.manage.TetheringFragment import be.mygod.vpnhotspot.net.IpNeighbour import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock +import be.mygod.vpnhotspot.util.AppUpdate import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.util.Services +import be.mygod.vpnhotspot.util.UpdateChecker import be.mygod.vpnhotspot.widget.SmartSnackbar +import com.google.android.material.badge.BadgeDrawable import com.google.android.material.navigation.NavigationBarView +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import java.net.Inet4Address class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener { lateinit var binding: ActivityMainBinding + private lateinit var updateItem: MenuItem + private lateinit var updateBadge: BadgeDrawable override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.navigation.setOnItemSelectedListener(this) + val badge = binding.navigation.getOrCreateBadge(R.id.navigation_clients).apply { + backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary) + badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light) + } + updateItem = binding.navigation.menu.findItem(R.id.navigation_update) + updateItem.isCheckable = false + updateBadge = binding.navigation.getOrCreateBadge(R.id.navigation_update).apply { + backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary) + badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light) + } if (savedInstanceState == null) displayFragment(TetheringFragment()) val model by viewModels() lifecycle.addObserver(model) @@ -34,38 +54,57 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen val count = clients.count { it.ip.any { (ip, state) -> ip is Inet4Address && state == IpNeighbour.State.VALID } } - if (count > 0) binding.navigation.getOrCreateBadge(R.id.navigation_clients).apply { - backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary) - badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light) - number = count - } else binding.navigation.removeBadge(R.id.navigation_clients) + badge.isVisible = count > 0 + badge.number = count } SmartSnackbar.Register(binding.fragmentHolder) WifiDoubleLock.ActivityListener(this) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + onAppUpdateAvailable(null) + UpdateChecker.check().collect(this@MainActivity::onAppUpdateAvailable) + } + } + } + + private var lastUpdate: AppUpdate? = null + private fun onAppUpdateAvailable(update: AppUpdate?) { + lastUpdate = update + updateItem.isVisible = update != null + if (update == null) return + updateItem.isEnabled = update.downloaded != false + updateItem.setIcon(when (update.downloaded) { + null -> R.drawable.ic_action_update + false -> R.drawable.ic_file_downloading + true -> R.drawable.ic_action_autorenew + }) + updateItem.title = update.message ?: "Update" + updateBadge.isVisible = when (val days = update.stalenessDays) { + null -> false + else -> { + if (days > 0) updateBadge.number = days else updateBadge.clearNumber() + true + } + } } override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) { R.id.navigation_clients -> { - if (!item.isChecked) { - item.isChecked = true - displayFragment(ClientsFragment()) - } + displayFragment(ClientsFragment()) true } R.id.navigation_tethering -> { - if (!item.isChecked) { - item.isChecked = true - displayFragment(TetheringFragment()) - } + displayFragment(TetheringFragment()) true } R.id.navigation_settings -> { - if (!item.isChecked) { - item.isChecked = true - displayFragment(SettingsPreferenceFragment()) - } + displayFragment(SettingsPreferenceFragment()) true } + R.id.navigation_update -> { + lastUpdate!!.updateForResult(this, 1) + false + } else -> false } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt index 19ec56c0..d0e6e46a 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt @@ -7,6 +7,7 @@ import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.net.MacAddressCompat import be.mygod.vpnhotspot.room.AppDatabase +import be.mygod.vpnhotspot.util.disconnectCompat import be.mygod.vpnhotspot.widget.SmartSnackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -36,7 +37,7 @@ object MacLookup { @MainThread fun abort(mac: MacAddressCompat) = macLookupBusy.remove(mac)?.let { (conn, job) -> job.cancel() - if (Build.VERSION.SDK_INT < 26) GlobalScope.launch(Dispatchers.IO) { conn.disconnect() } else conn.disconnect() + conn.disconnectCompat() } @MainThread diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt new file mode 100644 index 00000000..4193bf6e --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt @@ -0,0 +1,10 @@ +package be.mygod.vpnhotspot.util + +import android.app.Activity + +interface AppUpdate { + val downloaded: Boolean? get() = null + val message: String? get() = null + val stalenessDays: Int? get() = null + fun updateForResult(activity: Activity, requestCode: Int): Unit = error("Update not supported") +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt index 75d27f73..2f41f5ce 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -24,6 +24,9 @@ import androidx.fragment.app.FragmentManager import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.MacAddressCompat import be.mygod.vpnhotspot.widget.SmartSnackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import timber.log.Timber import java.io.File import java.io.FileNotFoundException @@ -32,6 +35,7 @@ import java.lang.invoke.MethodHandles import java.lang.reflect.InvocationHandler import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method +import java.net.HttpURLConnection import java.net.InetAddress import java.net.NetworkInterface import java.net.SocketException @@ -66,6 +70,10 @@ fun Method.matchesCompat(name: String, args: Array?, vararg classes: C } } else matches(name, *classes) +fun HttpURLConnection.disconnectCompat() { + if (Build.VERSION.SDK_INT < 26) GlobalScope.launch(Dispatchers.IO) { disconnect() } else disconnect() +} + fun Context.ensureReceiverUnregistered(receiver: BroadcastReceiver) { try { unregisterReceiver(receiver) diff --git a/mobile/src/main/res/drawable/ic_action_update.xml b/mobile/src/main/res/drawable/ic_action_update.xml new file mode 100644 index 00000000..3b923020 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_action_update.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_file_downloading.xml b/mobile/src/main/res/drawable/ic_file_downloading.xml new file mode 100644 index 00000000..03774184 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_file_downloading.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/menu/navigation.xml b/mobile/src/main/res/menu/navigation.xml index 34f64818..8d416530 100644 --- a/mobile/src/main/res/menu/navigation.xml +++ b/mobile/src/main/res/menu/navigation.xml @@ -16,4 +16,10 @@ android:icon="@drawable/ic_action_settings" android:title="@string/title_settings"/> + +