Skip to content

Commit 44b04a4

Browse files
committed
ui: update cleanups
Signed-off-by: Jason A. Donenfeld <[email protected]>
1 parent 5dd30e9 commit 44b04a4

File tree

1 file changed

+98
-79
lines changed
  • ui/src/main/java/com/wireguard/android/updater

1 file changed

+98
-79
lines changed

ui/src/main/java/com/wireguard/android/updater/Updater.kt

Lines changed: 98 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import androidx.core.content.IntentCompat
1818
import com.wireguard.android.Application
1919
import com.wireguard.android.BuildConfig
2020
import com.wireguard.android.util.UserKnobs
21+
import kotlinx.coroutines.CoroutineScope
2122
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.Job
2224
import kotlinx.coroutines.delay
2325
import kotlinx.coroutines.flow.MutableStateFlow
2426
import kotlinx.coroutines.flow.asStateFlow
@@ -41,12 +43,16 @@ import kotlin.time.Duration.Companion.seconds
4143

4244
object Updater {
4345
private const val TAG = "WireGuard/Updater"
44-
private const val LATEST_VERSION_URL = "https://download.wireguard.com/android-client/latest.sig"
46+
private const val LATEST_VERSION_URL =
47+
"https://download.wireguard.com/android-client/latest.sig"
4548
private const val APK_PATH_URL = "https://download.wireguard.com/android-client/%s"
46-
private const val APK_NAME_PREFIX = BuildConfig.APPLICATION_ID + "-"
49+
private val APK_NAME_PREFIX = BuildConfig.APPLICATION_ID.removeSuffix(".debug") + "-"
4750
private const val APK_NAME_SUFFIX = ".apk"
48-
private const val RELEASE_PUBLIC_KEY_BASE64 = "RWTAzwGRYr3EC9px0Ia3fbttz8WcVN6wrOwWp2delz4el6SI8XmkKSMp"
49-
private val CURRENT_VERSION = BuildConfig.VERSION_NAME.removeSuffix("-debug")
51+
private const val RELEASE_PUBLIC_KEY_BASE64 =
52+
"RWTAzwGRYr3EC9px0Ia3fbttz8WcVN6wrOwWp2delz4el6SI8XmkKSMp"
53+
private val CURRENT_VERSION = Version(BuildConfig.VERSION_NAME.removeSuffix("-debug"))
54+
55+
private val updaterScope = CoroutineScope(Job() + Dispatchers.IO)
5056

5157
sealed class Progress {
5258
object Complete : Progress()
@@ -89,7 +95,7 @@ object Updater {
8995

9096
class Failure(val error: Throwable) : Progress() {
9197
fun retry() {
92-
Application.getCoroutineScope().launch {
98+
updaterScope.launch {
9399
downloadAndUpdateWrapErrors()
94100
}
95101
}
@@ -104,29 +110,61 @@ object Updater {
104110
mutableState.emit(progress)
105111
}
106112

107-
private fun versionIsNewer(lhs: String, rhs: String): Boolean {
108-
val lhsParts = lhs.split(".")
109-
val rhsParts = rhs.split(".")
110-
if (lhsParts.isEmpty() || rhsParts.isEmpty())
111-
throw InvalidParameterException("Version is empty")
112-
113-
for (i in 0 until max(lhsParts.size, rhsParts.size)) {
114-
val lhsPart = if (i < lhsParts.size) lhsParts[i].toULong() else 0UL
115-
val rhsPart = if (i < rhsParts.size) rhsParts[i].toULong() else 0UL
116-
if (lhsPart == rhsPart)
117-
continue
118-
return lhsPart > rhsPart
113+
private class Sha256Digest(hex: String) {
114+
val bytes: ByteArray
115+
116+
init {
117+
if (hex.length != 64)
118+
throw InvalidParameterException("SHA256 hashes must be 32 bytes long")
119+
bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
120+
}
121+
}
122+
123+
@OptIn(ExperimentalUnsignedTypes::class)
124+
private class Version(version: String) : Comparable<Version> {
125+
val parts: ULongArray
126+
127+
init {
128+
val strParts = version.split(".")
129+
if (strParts.isEmpty())
130+
throw InvalidParameterException("Version has no parts")
131+
parts = ULongArray(strParts.size)
132+
for (i in parts.indices) {
133+
parts[i] = strParts[i].toULong()
134+
}
135+
}
136+
137+
override fun toString(): String {
138+
return parts.joinToString(".")
139+
}
140+
141+
override fun compareTo(other: Version): Int {
142+
for (i in 0 until max(parts.size, other.parts.size)) {
143+
val lhsPart = if (i < parts.size) parts[i] else 0UL
144+
val rhsPart = if (i < other.parts.size) other.parts[i] else 0UL
145+
if (lhsPart > rhsPart)
146+
return 1
147+
else if (lhsPart < rhsPart)
148+
return -1
149+
}
150+
return 0
119151
}
120-
return false
121152
}
122153

123-
private fun versionOfFile(name: String): String? {
154+
private class Update(val fileName: String, val version: Version, val hash: Sha256Digest)
155+
156+
private fun versionOfFile(name: String): Version? {
124157
if (!name.startsWith(APK_NAME_PREFIX) || !name.endsWith(APK_NAME_SUFFIX))
125158
return null
126-
return name.substring(APK_NAME_PREFIX.length, name.length - APK_NAME_SUFFIX.length)
159+
return try {
160+
Version(name.substring(APK_NAME_PREFIX.length, name.length - APK_NAME_SUFFIX.length))
161+
} catch (_: Throwable) {
162+
null
163+
}
127164
}
128165

129-
private fun verifySignedFileList(signifyDigest: String): Map<String, Sha256Digest> {
166+
private fun verifySignedFileList(signifyDigest: String): List<Update> {
167+
val updates = ArrayList<Update>(1)
130168
val publicKeyBytes = Base64.decode(RELEASE_PUBLIC_KEY_BASE64, Base64.DEFAULT)
131169
if (publicKeyBytes == null || publicKeyBytes.size != 32 + 10 || publicKeyBytes[0] != 'E'.code.toByte() || publicKeyBytes[1] != 'd'.code.toByte())
132170
throw InvalidKeyException("Invalid public key")
@@ -149,59 +187,31 @@ object Updater {
149187
)
150188
)
151189
throw SecurityException("Invalid signature")
152-
val hashes: MutableMap<String, Sha256Digest> = HashMap()
153190
for (line in lines[2].split("\n").dropLastWhile { it.isEmpty() }) {
154191
val components = line.split(" ", limit = 2)
155192
if (components.size != 2)
156193
throw InvalidParameterException("Invalid file list format: too few components")
157-
hashes[components[1]] = Sha256Digest(components[0])
158-
}
159-
return hashes
160-
}
161-
162-
private class Sha256Digest(hex: String) {
163-
val bytes: ByteArray
164-
165-
init {
166-
if (hex.length != 64)
167-
throw InvalidParameterException("SHA256 hashes must be 32 bytes long")
168-
bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
194+
/* If version is null, it's not a file we understand, but still a legitimate entry, so don't throw. */
195+
val version = versionOfFile(components[1]) ?: continue
196+
updates.add(Update(components[1], version, Sha256Digest(components[0])))
169197
}
198+
return updates
170199
}
171200

172-
private fun checkForUpdates(): Pair<String, Sha256Digest> {
201+
private fun checkForUpdates(): Update? {
173202
val connection = URL(LATEST_VERSION_URL).openConnection() as HttpURLConnection
174203
connection.setRequestProperty("User-Agent", Application.USER_AGENT)
175204
connection.connect()
176205
if (connection.responseCode != HttpURLConnection.HTTP_OK)
177-
throw IOException("File list could not be fetched: ${connection.responseCode}")
206+
throw IOException(connection.responseMessage)
178207
var fileListBytes = ByteArray(1024 * 512 /* 512 KiB */)
179208
connection.inputStream.use {
180209
val len = it.read(fileListBytes)
181210
if (len <= 0)
182211
throw IOException("File list is empty")
183212
fileListBytes = fileListBytes.sliceArray(0 until len)
184213
}
185-
val fileList = verifySignedFileList(fileListBytes.decodeToString())
186-
if (fileList.isEmpty())
187-
throw InvalidParameterException("File list is empty")
188-
var newestFile: String? = null
189-
var newestVersion: String? = null
190-
var newestFileHash: Sha256Digest? = null
191-
for (file in fileList) {
192-
val fileVersion = versionOfFile(file.key)
193-
try {
194-
if (fileVersion != null && (newestVersion == null || versionIsNewer(fileVersion, newestVersion))) {
195-
newestVersion = fileVersion
196-
newestFile = file.key
197-
newestFileHash = file.value
198-
}
199-
} catch (_: Throwable) {
200-
}
201-
}
202-
if (newestFile == null || newestFileHash == null)
203-
throw InvalidParameterException("File list is empty")
204-
return Pair(newestFile, newestFileHash)
214+
return verifySignedFileList(fileListBytes.decodeToString()).maxByOrNull { it.version }
205215
}
206216

207217
private suspend fun downloadAndUpdate() = withContext(Dispatchers.IO) {
@@ -224,14 +234,14 @@ object Updater {
224234

225235
emitProgress(Progress.Rechecking)
226236
val update = checkForUpdates()
227-
val updateVersion = versionOfFile(checkForUpdates().first) ?: throw Exception("No versions returned")
228-
if (!versionIsNewer(updateVersion, CURRENT_VERSION)) {
237+
if (update == null || update.version <= CURRENT_VERSION) {
229238
emitProgress(Progress.Complete)
230239
return@withContext
231240
}
232241

233242
emitProgress(Progress.Downloading(0UL, 0UL), true)
234-
val connection = URL(APK_PATH_URL.format(update.first)).openConnection() as HttpURLConnection
243+
val connection =
244+
URL(APK_PATH_URL.format(update.fileName)).openConnection() as HttpURLConnection
235245
connection.setRequestProperty("User-Agent", Application.USER_AGENT)
236246
connection.connect()
237247
if (connection.responseCode != HttpURLConnection.HTTP_OK)
@@ -246,7 +256,8 @@ object Updater {
246256
emitProgress(Progress.Downloading(downloadedByteLen, totalByteLen), true)
247257

248258
val installer = context.packageManager.packageInstaller
249-
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
259+
val params =
260+
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
250261
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
251262
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
252263
params.setAppPackageName(context.packageName) /* Enforces updates; disallows new apps. */
@@ -275,7 +286,7 @@ object Updater {
275286
}
276287

277288
emitProgress(Progress.Installing)
278-
if (!digest.digest().contentEquals(update.second.bytes))
289+
if (!digest.digest().contentEquals(update.hash.bytes))
279290
throw SecurityException("Update has invalid hash")
280291
sessionFailure = false
281292
} finally {
@@ -305,10 +316,17 @@ object Updater {
305316
return
306317

307318
when (val status =
308-
intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE_INVALID)) {
319+
intent.getIntExtra(
320+
PackageInstaller.EXTRA_STATUS,
321+
PackageInstaller.STATUS_FAILURE_INVALID
322+
)) {
309323
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
310324
val id = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, 0)
311-
val userIntervention = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)!!
325+
val userIntervention = IntentCompat.getParcelableExtra(
326+
intent,
327+
Intent.EXTRA_INTENT,
328+
Intent::class.java
329+
)!!
312330
Application.getCoroutineScope().launch {
313331
emitProgress(Progress.NeedsUserIntervention(userIntervention, id))
314332
}
@@ -328,7 +346,8 @@ object Updater {
328346
} catch (_: SecurityException) {
329347
}
330348
val message =
331-
intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "Installation error $status"
349+
intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
350+
?: "Installation error $status"
332351
Application.getCoroutineScope().launch {
333352
val e = Exception(message)
334353
Log.e(TAG, "Update failure", e)
@@ -344,40 +363,40 @@ object Updater {
344363
if (installerIsGooglePlay())
345364
return
346365

347-
Application.getCoroutineScope().launch(Dispatchers.IO) {
348-
if (UserKnobs.updaterNewerVersionSeen.firstOrNull()?.let { versionIsNewer(it, CURRENT_VERSION) } == true)
366+
updaterScope.launch {
367+
if (UserKnobs.updaterNewerVersionSeen.firstOrNull()
368+
?.let { Version(it) > CURRENT_VERSION } == true
369+
)
349370
return@launch
350371

351372
var waitTime = 15
352373
while (true) {
353374
try {
354-
val updateVersion = versionOfFile(checkForUpdates().first) ?: throw IllegalStateException("No versions returned")
355-
if (versionIsNewer(updateVersion, CURRENT_VERSION)) {
356-
Log.i(TAG, "Update available: $updateVersion")
357-
UserKnobs.setUpdaterNewerVersionSeen(updateVersion)
375+
val update = checkForUpdates() ?: continue
376+
if (update.version > CURRENT_VERSION) {
377+
Log.i(TAG, "Update available: ${update.version}")
378+
UserKnobs.setUpdaterNewerVersionSeen(update.version.toString())
358379
return@launch
359380
}
360-
} catch (e: Throwable) {
361-
Log.e(TAG, "Failed to check for updates", e)
381+
} catch (_: Throwable) {
362382
}
363383
delay(waitTime.minutes)
364384
waitTime = 45
365385
}
366386
}
367387

368388
UserKnobs.updaterNewerVersionSeen.onEach { ver ->
369-
if (ver != null && versionIsNewer(
370-
ver,
371-
CURRENT_VERSION
372-
) && UserKnobs.updaterNewerVersionConsented.firstOrNull()
373-
?.let { versionIsNewer(it, CURRENT_VERSION) } != true
389+
if (ver != null && Version(ver) > CURRENT_VERSION && UserKnobs.updaterNewerVersionConsented.firstOrNull()
390+
?.let { Version(it) > CURRENT_VERSION } != true
374391
)
375392
emitProgress(Progress.Available(ver))
376393
}.launchIn(Application.getCoroutineScope())
377394

378395
UserKnobs.updaterNewerVersionConsented.onEach { ver ->
379-
if (ver != null && versionIsNewer(ver, CURRENT_VERSION))
380-
downloadAndUpdateWrapErrors()
396+
if (ver != null && Version(ver) > CURRENT_VERSION)
397+
updaterScope.launch {
398+
downloadAndUpdateWrapErrors()
399+
}
381400
}.launchIn(Application.getCoroutineScope())
382401
}
383402

0 commit comments

Comments
 (0)