Add support for checking app updates

This commit is contained in:
Mygod
2021-10-24 17:35:38 -04:00
parent cd6c72f559
commit aa2d92e6a8
10 changed files with 227 additions and 18 deletions

View File

@@ -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")

View File

@@ -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<AppUpdate?> {
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()
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<ClientViewModel>()
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
}

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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<out Any?>?, 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)

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M21,10.12h-6.78l2.74,-2.82c-2.73,-2.7 -7.15,-2.8 -9.88,-0.1c-2.73,2.71 -2.73,7.08 0,9.79s7.15,2.71 9.88,0C18.32,15.65 19,14.08 19,12.1h2c0,1.98 -0.88,4.55 -2.64,6.29c-3.51,3.48 -9.21,3.48 -12.72,0c-3.5,-3.47 -3.53,-9.11 -0.02,-12.58s9.14,-3.47 12.65,0L21,3V10.12zM12.5,8v4.25l3.5,2.08l-0.72,1.21L11,13V8H12.5z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M18.32,4.26C16.84,3.05 15.01,2.25 13,2.05v2.02c1.46,0.18 2.79,0.76 3.9,1.62L18.32,4.26zM19.93,11h2.02c-0.2,-2.01 -1,-3.84 -2.21,-5.32L18.31,7.1C19.17,8.21 19.75,9.54 19.93,11zM18.31,16.9l1.43,1.43c1.21,-1.48 2.01,-3.32 2.21,-5.32h-2.02C19.75,14.46 19.17,15.79 18.31,16.9zM13,19.93v2.02c2.01,-0.2 3.84,-1 5.32,-2.21l-1.43,-1.43C15.79,19.17 14.46,19.75 13,19.93zM13,12V7h-2v5H7l5,5l5,-5H13zM11,19.93v2.02c-5.05,-0.5 -9,-4.76 -9,-9.95s3.95,-9.45 9,-9.95v2.02C7.05,4.56 4,7.92 4,12S7.05,19.44 11,19.93z"/>
</vector>

View File

@@ -16,4 +16,10 @@
android:icon="@drawable/ic_action_settings"
android:title="@string/title_settings"/>
<item
android:id="@+id/navigation_update"
android:icon="@drawable/ic_action_update"
android:title="Update"
android:visible="false"/>
</menu>