ui: handle update signatures
Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
parent
914917036e
commit
d6ad7d11d0
@ -6,6 +6,7 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28"
|
android:maxSdkVersion="28"
|
||||||
@ -103,6 +104,7 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.ACTION_SHUTDOWN" />
|
<action android:name="android.intent.action.ACTION_SHUTDOWN" />
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ import com.wireguard.android.backend.GoBackend
|
|||||||
import com.wireguard.android.backend.WgQuickBackend
|
import com.wireguard.android.backend.WgQuickBackend
|
||||||
import com.wireguard.android.configStore.FileConfigStore
|
import com.wireguard.android.configStore.FileConfigStore
|
||||||
import com.wireguard.android.model.TunnelManager
|
import com.wireguard.android.model.TunnelManager
|
||||||
|
import com.wireguard.android.updater.Updater
|
||||||
import com.wireguard.android.util.RootShell
|
import com.wireguard.android.util.RootShell
|
||||||
import com.wireguard.android.util.ToolsInstaller
|
import com.wireguard.android.util.ToolsInstaller
|
||||||
import com.wireguard.android.util.UserKnobs
|
import com.wireguard.android.util.UserKnobs
|
||||||
@ -115,6 +116,7 @@ class Application : android.app.Application() {
|
|||||||
Log.e(TAG, Log.getStackTraceString(e))
|
Log.e(TAG, Log.getStackTraceString(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Updater.monitorForUpdates()
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())
|
StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())
|
||||||
|
@ -8,15 +8,27 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.wireguard.android.activity.MainActivity
|
||||||
import com.wireguard.android.backend.WgQuickBackend
|
import com.wireguard.android.backend.WgQuickBackend
|
||||||
|
import com.wireguard.android.updater.Updater
|
||||||
import com.wireguard.android.util.applicationScope
|
import com.wireguard.android.util.applicationScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class BootShutdownReceiver : BroadcastReceiver() {
|
class BootShutdownReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val action = intent.action ?: return
|
||||||
|
|
||||||
|
if (Intent.ACTION_MY_PACKAGE_REPLACED == action && Updater.installer() == context.packageName) {
|
||||||
|
/* TODO: does not work because of restrictions placed on broadcast receivers. */
|
||||||
|
val start = Intent(context, MainActivity::class.java)
|
||||||
|
start.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(start)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
applicationScope.launch {
|
applicationScope.launch {
|
||||||
if (Application.getBackend() !is WgQuickBackend) return@launch
|
if (Application.getBackend() !is WgQuickBackend) return@launch
|
||||||
val action = intent.action ?: return@launch
|
|
||||||
val tunnelManager = Application.getTunnelManager()
|
val tunnelManager = Application.getTunnelManager()
|
||||||
if (Intent.ACTION_BOOT_COMPLETED == action) {
|
if (Intent.ACTION_BOOT_COMPLETED == action) {
|
||||||
Log.i(TAG, "Broadcast receiver restoring state (boot)")
|
Log.i(TAG, "Broadcast receiver restoring state (boot)")
|
||||||
|
@ -33,6 +33,7 @@ import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowC
|
|||||||
import com.wireguard.android.databinding.TunnelListFragmentBinding
|
import com.wireguard.android.databinding.TunnelListFragmentBinding
|
||||||
import com.wireguard.android.databinding.TunnelListItemBinding
|
import com.wireguard.android.databinding.TunnelListItemBinding
|
||||||
import com.wireguard.android.model.ObservableTunnel
|
import com.wireguard.android.model.ObservableTunnel
|
||||||
|
import com.wireguard.android.updater.SnackbarUpdateShower
|
||||||
import com.wireguard.android.util.ErrorMessages
|
import com.wireguard.android.util.ErrorMessages
|
||||||
import com.wireguard.android.util.QrCodeFromFileScanner
|
import com.wireguard.android.util.QrCodeFromFileScanner
|
||||||
import com.wireguard.android.util.TunnelImporter
|
import com.wireguard.android.util.TunnelImporter
|
||||||
@ -122,6 +123,8 @@ class TunnelListFragment : BaseFragment() {
|
|||||||
backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() }
|
backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() }
|
||||||
backPressedCallback?.isEnabled = false
|
backPressedCallback?.isEnabled = false
|
||||||
|
|
||||||
|
SnackbarUpdateShower.attachToActivity(requireActivity(), binding?.mainContainer!!, binding?.createFab)
|
||||||
|
|
||||||
return binding?.root
|
return binding?.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ import android.widget.Toast
|
|||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.wireguard.android.R
|
import com.wireguard.android.R
|
||||||
|
import com.wireguard.android.updater.Updater
|
||||||
import com.wireguard.android.util.ErrorMessages
|
import com.wireguard.android.util.ErrorMessages
|
||||||
|
|
||||||
class DonatePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
|
class DonatePreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
|
||||||
@ -22,21 +23,8 @@ class DonatePreference(context: Context, attrs: AttributeSet?) : Preference(cont
|
|||||||
override fun getTitle() = context.getString(R.string.donate_title)
|
override fun getTitle() = context.getString(R.string.donate_title)
|
||||||
|
|
||||||
override fun onClick() {
|
override fun onClick() {
|
||||||
val installer = try {
|
|
||||||
val packageName = context.packageName
|
|
||||||
val pm = context.packageManager
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
pm.getInstallSourceInfo(packageName).installingPackageName ?: ""
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
pm.getInstallerPackageName(packageName) ?: ""
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Google Play Store forbids links to our donation page. */
|
/* Google Play Store forbids links to our donation page. */
|
||||||
if (installer == "com.android.vending") {
|
if (Updater.installerIsGooglePlay()) {
|
||||||
MaterialAlertDialogBuilder(context)
|
MaterialAlertDialogBuilder(context)
|
||||||
.setTitle(R.string.donate_title)
|
.setTitle(R.string.donate_title)
|
||||||
.setMessage(R.string.donate_google_play_disappointment)
|
.setMessage(R.string.donate_google_play_disappointment)
|
||||||
|
2507
ui/src/main/java/com/wireguard/android/updater/Ed25519.java
Normal file
2507
ui/src/main/java/com/wireguard/android/updater/Ed25519.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,156 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.wireguard.android.updater
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.snackbar.BaseTransientBottomBar
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import com.wireguard.android.R
|
||||||
|
import com.wireguard.android.util.ErrorMessages
|
||||||
|
import com.wireguard.android.util.QuantityFormatter
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
object SnackbarUpdateShower {
|
||||||
|
private class SwapableSnackbar(activity: FragmentActivity, view: View, anchor: View?) {
|
||||||
|
val actionSnackbar = makeSnackbar(activity, view, anchor)
|
||||||
|
val statusSnackbar = makeSnackbar(activity, view, anchor)
|
||||||
|
var showingAction: Boolean = false
|
||||||
|
var showingStatus: Boolean = false
|
||||||
|
|
||||||
|
private fun makeSnackbar(activity: FragmentActivity, view: View, anchor: View?): Snackbar {
|
||||||
|
val snackbar = Snackbar.make(activity, view, "", Snackbar.LENGTH_INDEFINITE)
|
||||||
|
if (anchor != null)
|
||||||
|
snackbar.anchorView = anchor
|
||||||
|
snackbar.setTextMaxLines(6)
|
||||||
|
snackbar.behavior = object : BaseTransientBottomBar.Behavior() {
|
||||||
|
override fun canSwipeDismissView(child: View): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snackbar.addCallback(object : BaseTransientBottomBar.BaseCallback<Snackbar>() {
|
||||||
|
override fun onDismissed(snackbar: Snackbar?, @DismissEvent event: Int) {
|
||||||
|
super.onDismissed(snackbar, event)
|
||||||
|
if (event == DISMISS_EVENT_MANUAL || event == DISMISS_EVENT_ACTION ||
|
||||||
|
(snackbar == actionSnackbar && !showingAction) ||
|
||||||
|
(snackbar == statusSnackbar && !showingStatus)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
activity.lifecycleScope.launch {
|
||||||
|
delay(5.seconds)
|
||||||
|
snackbar?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return snackbar
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAction(text: String, action: String, listener: View.OnClickListener) {
|
||||||
|
if (showingStatus) {
|
||||||
|
showingStatus = false
|
||||||
|
statusSnackbar.dismiss()
|
||||||
|
}
|
||||||
|
actionSnackbar.setText(text)
|
||||||
|
actionSnackbar.setAction(action, listener)
|
||||||
|
if (!showingAction) {
|
||||||
|
actionSnackbar.show()
|
||||||
|
showingAction = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showText(text: String) {
|
||||||
|
if (showingAction) {
|
||||||
|
showingAction = false
|
||||||
|
actionSnackbar.dismiss()
|
||||||
|
}
|
||||||
|
statusSnackbar.setText(text)
|
||||||
|
if (!showingStatus) {
|
||||||
|
statusSnackbar.show()
|
||||||
|
showingStatus = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
actionSnackbar.dismiss()
|
||||||
|
statusSnackbar.dismiss()
|
||||||
|
showingAction = false
|
||||||
|
showingStatus = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachToActivity(activity: FragmentActivity, view: View, anchor: View?) {
|
||||||
|
val snackbar = SwapableSnackbar(activity, view, anchor)
|
||||||
|
val context = activity.applicationContext
|
||||||
|
|
||||||
|
var lastUserIntervention: Updater.Progress.NeedsUserIntervention? = null
|
||||||
|
val intentLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
lastUserIntervention?.markAsDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
Updater.state.onEach { progress ->
|
||||||
|
when (progress) {
|
||||||
|
is Updater.Progress.Complete ->
|
||||||
|
snackbar.dismiss()
|
||||||
|
|
||||||
|
is Updater.Progress.Available ->
|
||||||
|
snackbar.showAction(
|
||||||
|
context.getString(R.string.updater_avalable),
|
||||||
|
context.getString(R.string.updater_action)
|
||||||
|
) {
|
||||||
|
progress.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
is Updater.Progress.NeedsUserIntervention -> {
|
||||||
|
lastUserIntervention = progress
|
||||||
|
intentLauncher.launch(progress.intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Updater.Progress.Installing ->
|
||||||
|
snackbar.showText(context.getString(R.string.updater_installing))
|
||||||
|
|
||||||
|
is Updater.Progress.Rechecking ->
|
||||||
|
snackbar.showText(context.getString(R.string.updater_rechecking))
|
||||||
|
|
||||||
|
is Updater.Progress.Downloading -> {
|
||||||
|
if (progress.bytesTotal != 0UL) {
|
||||||
|
snackbar.showText(
|
||||||
|
context.getString(
|
||||||
|
R.string.updater_download_progress,
|
||||||
|
QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong()),
|
||||||
|
QuantityFormatter.formatBytes(progress.bytesTotal.toLong()),
|
||||||
|
progress.bytesDownloaded.toFloat() * 100.0 / progress.bytesTotal.toFloat()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
snackbar.showText(
|
||||||
|
context.getString(
|
||||||
|
R.string.updater_download_progress_nototal,
|
||||||
|
QuantityFormatter.formatBytes(progress.bytesDownloaded.toLong())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Updater.Progress.Failure -> {
|
||||||
|
snackbar.showText(
|
||||||
|
context.getString(
|
||||||
|
R.string.updater_failure,
|
||||||
|
ErrorMessages[progress.error]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
delay(5.seconds)
|
||||||
|
progress.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.launchIn(activity.lifecycleScope)
|
||||||
|
}
|
||||||
|
}
|
401
ui/src/main/java/com/wireguard/android/updater/Updater.kt
Normal file
401
ui/src/main/java/com/wireguard/android/updater/Updater.kt
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2017-2023 WireGuard LLC. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
package com.wireguard.android.updater
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import com.wireguard.android.Application
|
||||||
|
import com.wireguard.android.BuildConfig
|
||||||
|
import com.wireguard.android.util.UserKnobs
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.InvalidKeyException
|
||||||
|
import java.security.InvalidParameterException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
object Updater {
|
||||||
|
private const val TAG = "WireGuard/Updater"
|
||||||
|
private const val LATEST_VERSION_URL = "https://download.wireguard.com/android-client/latest.sig"
|
||||||
|
private const val APK_PATH_URL = "https://download.wireguard.com/android-client/%s"
|
||||||
|
private const val APK_NAME_PREFIX = BuildConfig.APPLICATION_ID + "-"
|
||||||
|
private const val APK_NAME_SUFFIX = ".apk"
|
||||||
|
private const val RELEASE_PUBLIC_KEY_BASE64 = "RWTAzwGRYr3EC9px0Ia3fbttz8WcVN6wrOwWp2delz4el6SI8XmkKSMp"
|
||||||
|
private val CURRENT_VERSION = BuildConfig.VERSION_NAME.removeSuffix("-debug")
|
||||||
|
|
||||||
|
sealed class Progress {
|
||||||
|
object Complete : Progress()
|
||||||
|
class Available(val version: String) : Progress() {
|
||||||
|
fun update() {
|
||||||
|
Application.getCoroutineScope().launch {
|
||||||
|
UserKnobs.setUpdaterNewerVersionConsented(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Rechecking : Progress()
|
||||||
|
class Downloading(val bytesDownloaded: ULong, val bytesTotal: ULong) : Progress()
|
||||||
|
object Installing : Progress()
|
||||||
|
class NeedsUserIntervention(val intent: Intent, private val id: Int) : Progress() {
|
||||||
|
|
||||||
|
private suspend fun installerActive(): Boolean {
|
||||||
|
if (mutableState.firstOrNull() != this@NeedsUserIntervention)
|
||||||
|
return true
|
||||||
|
try {
|
||||||
|
if (Application.get().packageManager.packageInstaller.getSessionInfo(id)?.isActive == true)
|
||||||
|
return true
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAsDone() {
|
||||||
|
Application.getCoroutineScope().launch {
|
||||||
|
if (installerActive())
|
||||||
|
return@launch
|
||||||
|
delay(7.seconds)
|
||||||
|
if (installerActive())
|
||||||
|
return@launch
|
||||||
|
emitProgress(Failure(Exception("Ignored by user")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Failure(val error: Throwable) : Progress() {
|
||||||
|
fun retry() {
|
||||||
|
Application.getCoroutineScope().launch {
|
||||||
|
downloadAndUpdateWrapErrors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mutableState = MutableStateFlow<Progress>(Progress.Complete)
|
||||||
|
val state = mutableState.asStateFlow()
|
||||||
|
|
||||||
|
private suspend fun emitProgress(progress: Progress, force: Boolean = false) {
|
||||||
|
if (force || mutableState.firstOrNull()?.javaClass != progress.javaClass)
|
||||||
|
mutableState.emit(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun versionIsNewer(lhs: String, rhs: String): Boolean {
|
||||||
|
val lhsParts = lhs.split(".")
|
||||||
|
val rhsParts = rhs.split(".")
|
||||||
|
if (lhsParts.isEmpty() || rhsParts.isEmpty())
|
||||||
|
throw InvalidParameterException("Version is empty")
|
||||||
|
|
||||||
|
for (i in 0 until max(lhsParts.size, rhsParts.size)) {
|
||||||
|
val lhsPart = if (i < lhsParts.size) lhsParts[i].toULong() else 0UL
|
||||||
|
val rhsPart = if (i < rhsParts.size) rhsParts[i].toULong() else 0UL
|
||||||
|
if (lhsPart == rhsPart)
|
||||||
|
continue
|
||||||
|
return lhsPart > rhsPart
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun versionOfFile(name: String): String? {
|
||||||
|
if (!name.startsWith(APK_NAME_PREFIX) || !name.endsWith(APK_NAME_SUFFIX))
|
||||||
|
return null
|
||||||
|
return name.substring(APK_NAME_PREFIX.length, name.length - APK_NAME_SUFFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifySignedFileList(signifyDigest: String): Map<String, Sha256Digest> {
|
||||||
|
val publicKeyBytes = Base64.decode(RELEASE_PUBLIC_KEY_BASE64, Base64.DEFAULT)
|
||||||
|
if (publicKeyBytes == null || publicKeyBytes.size != 32 + 10 || publicKeyBytes[0] != 'E'.code.toByte() || publicKeyBytes[1] != 'd'.code.toByte())
|
||||||
|
throw InvalidKeyException("Invalid public key")
|
||||||
|
val lines = signifyDigest.split("\n", limit = 3)
|
||||||
|
if (lines.size != 3)
|
||||||
|
throw InvalidParameterException("Invalid signature format: too few lines")
|
||||||
|
if (!lines[0].startsWith("untrusted comment: "))
|
||||||
|
throw InvalidParameterException("Invalid signature format: missing comment")
|
||||||
|
val signatureBytes = Base64.decode(lines[1], Base64.DEFAULT)
|
||||||
|
if (signatureBytes == null || signatureBytes.size != 64 + 10)
|
||||||
|
throw InvalidParameterException("Invalid signature format: wrong sized or missing signature")
|
||||||
|
for (i in 0..9) {
|
||||||
|
if (signatureBytes[i] != publicKeyBytes[i])
|
||||||
|
throw InvalidParameterException("Invalid signature format: wrong signer")
|
||||||
|
}
|
||||||
|
if (!Ed25519.verify(
|
||||||
|
lines[2].toByteArray(StandardCharsets.UTF_8),
|
||||||
|
signatureBytes.sliceArray(10 until 10 + 64),
|
||||||
|
publicKeyBytes.sliceArray(10 until 10 + 32)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw SecurityException("Invalid signature")
|
||||||
|
val hashes: MutableMap<String, Sha256Digest> = HashMap()
|
||||||
|
for (line in lines[2].split("\n").dropLastWhile { it.isEmpty() }) {
|
||||||
|
val components = line.split(" ", limit = 2)
|
||||||
|
if (components.size != 2)
|
||||||
|
throw InvalidParameterException("Invalid file list format: too few components")
|
||||||
|
hashes[components[1]] = Sha256Digest(components[0])
|
||||||
|
}
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Sha256Digest(hex: String) {
|
||||||
|
val bytes: ByteArray
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (hex.length != 64)
|
||||||
|
throw InvalidParameterException("SHA256 hashes must be 32 bytes long")
|
||||||
|
bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkForUpdates(): Pair<String, Sha256Digest> {
|
||||||
|
val connection = URL(LATEST_VERSION_URL).openConnection() as HttpURLConnection
|
||||||
|
connection.setRequestProperty("User-Agent", Application.USER_AGENT)
|
||||||
|
connection.connect()
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK)
|
||||||
|
throw IOException("File list could not be fetched: ${connection.responseCode}")
|
||||||
|
var fileListBytes = ByteArray(1024 * 512 /* 512 KiB */)
|
||||||
|
connection.inputStream.use {
|
||||||
|
val len = it.read(fileListBytes)
|
||||||
|
if (len <= 0)
|
||||||
|
throw IOException("File list is empty")
|
||||||
|
fileListBytes = fileListBytes.sliceArray(0 until len)
|
||||||
|
}
|
||||||
|
val fileList = verifySignedFileList(fileListBytes.decodeToString())
|
||||||
|
if (fileList.isEmpty())
|
||||||
|
throw InvalidParameterException("File list is empty")
|
||||||
|
var newestFile: String? = null
|
||||||
|
var newestVersion: String? = null
|
||||||
|
var newestFileHash: Sha256Digest? = null
|
||||||
|
for (file in fileList) {
|
||||||
|
val fileVersion = versionOfFile(file.key)
|
||||||
|
try {
|
||||||
|
if (fileVersion != null && (newestVersion == null || versionIsNewer(fileVersion, newestVersion))) {
|
||||||
|
newestVersion = fileVersion
|
||||||
|
newestFile = file.key
|
||||||
|
newestFileHash = file.value
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newestFile == null || newestFileHash == null)
|
||||||
|
throw InvalidParameterException("File list is empty")
|
||||||
|
return Pair(newestFile, newestFileHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadAndUpdate() = withContext(Dispatchers.IO) {
|
||||||
|
val receiver = InstallReceiver()
|
||||||
|
val context = Application.get().applicationContext
|
||||||
|
val pendingIntent = withContext(Dispatchers.Main) {
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context,
|
||||||
|
receiver,
|
||||||
|
IntentFilter(receiver.sessionId),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(receiver.sessionId).setPackage(context.packageName),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProgress(Progress.Rechecking)
|
||||||
|
val update = checkForUpdates()
|
||||||
|
val updateVersion = versionOfFile(checkForUpdates().first) ?: throw Exception("No versions returned")
|
||||||
|
if (!versionIsNewer(updateVersion, CURRENT_VERSION)) {
|
||||||
|
emitProgress(Progress.Complete)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProgress(Progress.Downloading(0UL, 0UL), true)
|
||||||
|
val connection = URL(APK_PATH_URL.format(update.first)).openConnection() as HttpURLConnection
|
||||||
|
connection.setRequestProperty("User-Agent", Application.USER_AGENT)
|
||||||
|
connection.connect()
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK)
|
||||||
|
throw IOException("Update could not be fetched: ${connection.responseCode}")
|
||||||
|
|
||||||
|
var downloadedByteLen: ULong = 0UL
|
||||||
|
val totalByteLen =
|
||||||
|
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) connection.contentLengthLong else connection.contentLength).toLong()
|
||||||
|
.toULong()
|
||||||
|
val fileBytes = ByteArray(1024 * 32 /* 32 KiB */)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true)
|
||||||
|
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
params.setAppPackageName(context.packageName) /* Enforces updates; disallows new apps. */
|
||||||
|
val session = installer.openSession(installer.createSession(params))
|
||||||
|
var sessionFailure = true
|
||||||
|
try {
|
||||||
|
val installDest = session.openWrite(receiver.sessionId, 0, -1)
|
||||||
|
|
||||||
|
installDest.use { dest ->
|
||||||
|
connection.inputStream.use { src ->
|
||||||
|
while (true) {
|
||||||
|
val readLen = src.read(fileBytes)
|
||||||
|
if (readLen <= 0)
|
||||||
|
break
|
||||||
|
|
||||||
|
digest.update(fileBytes, 0, readLen)
|
||||||
|
dest.write(fileBytes, 0, readLen)
|
||||||
|
|
||||||
|
downloadedByteLen += readLen.toUInt()
|
||||||
|
emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true)
|
||||||
|
|
||||||
|
if (downloadedByteLen >= 1024UL * 1024UL * 100UL /* 100 MiB */)
|
||||||
|
throw IOException("File too large")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProgress(Progress.Installing)
|
||||||
|
if (!digest.digest().contentEquals(update.second.bytes))
|
||||||
|
throw SecurityException("Update has invalid hash")
|
||||||
|
sessionFailure = false
|
||||||
|
} finally {
|
||||||
|
if (sessionFailure) {
|
||||||
|
session.abandon()
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.commit(pendingIntent.intentSender)
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadAndUpdateWrapErrors() {
|
||||||
|
try {
|
||||||
|
downloadAndUpdate()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Update failure", e)
|
||||||
|
emitProgress(Progress.Failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InstallReceiver : BroadcastReceiver() {
|
||||||
|
val sessionId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (sessionId != intent.action)
|
||||||
|
return
|
||||||
|
|
||||||
|
when (val status =
|
||||||
|
intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE_INVALID)) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0)
|
||||||
|
val userIntervention = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)!!
|
||||||
|
Application.getCoroutineScope().launch {
|
||||||
|
emitProgress(Progress.NeedsUserIntervention(userIntervention, id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> {
|
||||||
|
Application.getCoroutineScope().launch {
|
||||||
|
emitProgress(Progress.Complete)
|
||||||
|
}
|
||||||
|
context.applicationContext.unregisterReceiver(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0)
|
||||||
|
try {
|
||||||
|
context.applicationContext.packageManager.packageInstaller.abandonSession(id)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
}
|
||||||
|
val message =
|
||||||
|
intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "Installation error $status"
|
||||||
|
Application.getCoroutineScope().launch {
|
||||||
|
val e = Exception(message)
|
||||||
|
Log.e(TAG, "Update failure", e)
|
||||||
|
emitProgress(Progress.Failure(e))
|
||||||
|
}
|
||||||
|
context.applicationContext.unregisterReceiver(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun monitorForUpdates() {
|
||||||
|
if (installerIsGooglePlay())
|
||||||
|
return
|
||||||
|
|
||||||
|
Application.getCoroutineScope().launch(Dispatchers.IO) {
|
||||||
|
if (UserKnobs.updaterNewerVersionSeen.firstOrNull()?.let { versionIsNewer(it, CURRENT_VERSION) } == true)
|
||||||
|
return@launch
|
||||||
|
|
||||||
|
var waitTime = 15
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
val updateVersion = versionOfFile(checkForUpdates().first) ?: throw IllegalStateException("No versions returned")
|
||||||
|
if (versionIsNewer(updateVersion, CURRENT_VERSION)) {
|
||||||
|
Log.i(TAG, "Update available: $updateVersion")
|
||||||
|
UserKnobs.setUpdaterNewerVersionSeen(updateVersion)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to check for updates", e)
|
||||||
|
}
|
||||||
|
delay(waitTime.minutes)
|
||||||
|
waitTime = 45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UserKnobs.updaterNewerVersionSeen.onEach { ver ->
|
||||||
|
if (ver != null && versionIsNewer(
|
||||||
|
ver,
|
||||||
|
CURRENT_VERSION
|
||||||
|
) && UserKnobs.updaterNewerVersionConsented.firstOrNull()
|
||||||
|
?.let { versionIsNewer(it, CURRENT_VERSION) } != true
|
||||||
|
)
|
||||||
|
emitProgress(Progress.Available(ver))
|
||||||
|
}.launchIn(Application.getCoroutineScope())
|
||||||
|
|
||||||
|
UserKnobs.updaterNewerVersionConsented.onEach { ver ->
|
||||||
|
if (ver != null && versionIsNewer(ver, CURRENT_VERSION))
|
||||||
|
downloadAndUpdateWrapErrors()
|
||||||
|
}.launchIn(Application.getCoroutineScope())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installer(): String {
|
||||||
|
val context = Application.get().applicationContext
|
||||||
|
return try {
|
||||||
|
val packageName = context.packageName
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
pm.getInstallSourceInfo(packageName).installingPackageName ?: ""
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pm.getInstallerPackageName(packageName) ?: ""
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installerIsGooglePlay(): Boolean = installer() == "com.android.vending"
|
||||||
|
}
|
@ -88,4 +88,34 @@ object UserKnobs {
|
|||||||
it[RUNNING_TUNNELS] = runningTunnels
|
it[RUNNING_TUNNELS] = runningTunnels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val UPDATER_NEWER_VERSION_SEEN = stringPreferencesKey("updater_newer_version_seen")
|
||||||
|
val updaterNewerVersionSeen: Flow<String?>
|
||||||
|
get() = Application.getPreferencesDataStore().data.map {
|
||||||
|
it[UPDATER_NEWER_VERSION_SEEN]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setUpdaterNewerVersionSeen(newerVersionSeen: String?) {
|
||||||
|
Application.getPreferencesDataStore().edit {
|
||||||
|
if (newerVersionSeen == null)
|
||||||
|
it.remove(UPDATER_NEWER_VERSION_SEEN)
|
||||||
|
else
|
||||||
|
it[UPDATER_NEWER_VERSION_SEEN] = newerVersionSeen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val UPDATER_NEWER_VERSION_CONSENTED = stringPreferencesKey("updater_newer_version_consented")
|
||||||
|
val updaterNewerVersionConsented: Flow<String?>
|
||||||
|
get() = Application.getPreferencesDataStore().data.map {
|
||||||
|
it[UPDATER_NEWER_VERSION_CONSENTED]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setUpdaterNewerVersionConsented(newerVersionConsented: String?) {
|
||||||
|
Application.getPreferencesDataStore().edit {
|
||||||
|
if (newerVersionConsented == null)
|
||||||
|
it.remove(UPDATER_NEWER_VERSION_CONSENTED)
|
||||||
|
else
|
||||||
|
it[UPDATER_NEWER_VERSION_CONSENTED] = newerVersionConsented
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,6 +229,13 @@
|
|||||||
<string name="type_name_go_userspace">Go userspace</string>
|
<string name="type_name_go_userspace">Go userspace</string>
|
||||||
<string name="type_name_kernel_module">Kernel module</string>
|
<string name="type_name_kernel_module">Kernel module</string>
|
||||||
<string name="unknown_error">Unknown error</string>
|
<string name="unknown_error">Unknown error</string>
|
||||||
|
<string name="updater_avalable">An application update is available. Please update now.</string>
|
||||||
|
<string name="updater_action">Download & Update</string>
|
||||||
|
<string name="updater_rechecking">Fetching update metadata…</string>
|
||||||
|
<string name="updater_download_progress">Downloading update: %1$s / %2$s (%3$.2f%%)</string>
|
||||||
|
<string name="updater_download_progress_nototal">Downloading update: %s</string>
|
||||||
|
<string name="updater_installing">Installing update…</string>
|
||||||
|
<string name="updater_failure">Update failure: %s. Will retry momentarily…</string>
|
||||||
<string name="version_summary">%1$s backend %2$s</string>
|
<string name="version_summary">%1$s backend %2$s</string>
|
||||||
<string name="version_summary_checking">Checking %s backend version</string>
|
<string name="version_summary_checking">Checking %s backend version</string>
|
||||||
<string name="version_summary_unknown">Unknown %s version</string>
|
<string name="version_summary_unknown">Unknown %s version</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user