@@ -18,7 +18,9 @@ import androidx.core.content.IntentCompat
18
18
import com.wireguard.android.Application
19
19
import com.wireguard.android.BuildConfig
20
20
import com.wireguard.android.util.UserKnobs
21
+ import kotlinx.coroutines.CoroutineScope
21
22
import kotlinx.coroutines.Dispatchers
23
+ import kotlinx.coroutines.Job
22
24
import kotlinx.coroutines.delay
23
25
import kotlinx.coroutines.flow.MutableStateFlow
24
26
import kotlinx.coroutines.flow.asStateFlow
@@ -41,12 +43,16 @@ import kotlin.time.Duration.Companion.seconds
41
43
42
44
object Updater {
43
45
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"
45
48
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 " ) + " -"
47
50
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 )
50
56
51
57
sealed class Progress {
52
58
object Complete : Progress()
@@ -89,7 +95,7 @@ object Updater {
89
95
90
96
class Failure (val error : Throwable ) : Progress() {
91
97
fun retry () {
92
- Application .getCoroutineScope() .launch {
98
+ updaterScope .launch {
93
99
downloadAndUpdateWrapErrors()
94
100
}
95
101
}
@@ -104,29 +110,61 @@ object Updater {
104
110
mutableState.emit(progress)
105
111
}
106
112
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
119
151
}
120
- return false
121
152
}
122
153
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 ? {
124
157
if (! name.startsWith(APK_NAME_PREFIX ) || ! name.endsWith(APK_NAME_SUFFIX ))
125
158
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
+ }
127
164
}
128
165
129
- private fun verifySignedFileList (signifyDigest : String ): Map <String , Sha256Digest > {
166
+ private fun verifySignedFileList (signifyDigest : String ): List <Update > {
167
+ val updates = ArrayList <Update >(1 )
130
168
val publicKeyBytes = Base64 .decode(RELEASE_PUBLIC_KEY_BASE64 , Base64 .DEFAULT )
131
169
if (publicKeyBytes == null || publicKeyBytes.size != 32 + 10 || publicKeyBytes[0 ] != ' E' .code.toByte() || publicKeyBytes[1 ] != ' d' .code.toByte())
132
170
throw InvalidKeyException (" Invalid public key" )
@@ -149,59 +187,31 @@ object Updater {
149
187
)
150
188
)
151
189
throw SecurityException (" Invalid signature" )
152
- val hashes: MutableMap <String , Sha256Digest > = HashMap ()
153
190
for (line in lines[2 ].split(" \n " ).dropLastWhile { it.isEmpty() }) {
154
191
val components = line.split(" " , limit = 2 )
155
192
if (components.size != 2 )
156
193
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 ])))
169
197
}
198
+ return updates
170
199
}
171
200
172
- private fun checkForUpdates (): Pair < String , Sha256Digest > {
201
+ private fun checkForUpdates (): Update ? {
173
202
val connection = URL (LATEST_VERSION_URL ).openConnection() as HttpURLConnection
174
203
connection.setRequestProperty(" User-Agent" , Application .USER_AGENT )
175
204
connection.connect()
176
205
if (connection.responseCode != HttpURLConnection .HTTP_OK )
177
- throw IOException (" File list could not be fetched: ${ connection.responseCode} " )
206
+ throw IOException (connection.responseMessage )
178
207
var fileListBytes = ByteArray (1024 * 512 /* 512 KiB */ )
179
208
connection.inputStream.use {
180
209
val len = it.read(fileListBytes)
181
210
if (len <= 0 )
182
211
throw IOException (" File list is empty" )
183
212
fileListBytes = fileListBytes.sliceArray(0 until len)
184
213
}
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 }
205
215
}
206
216
207
217
private suspend fun downloadAndUpdate () = withContext(Dispatchers .IO ) {
@@ -224,14 +234,14 @@ object Updater {
224
234
225
235
emitProgress(Progress .Rechecking )
226
236
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 ) {
229
238
emitProgress(Progress .Complete )
230
239
return @withContext
231
240
}
232
241
233
242
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
235
245
connection.setRequestProperty(" User-Agent" , Application .USER_AGENT )
236
246
connection.connect()
237
247
if (connection.responseCode != HttpURLConnection .HTTP_OK )
@@ -246,7 +256,8 @@ object Updater {
246
256
emitProgress(Progress .Downloading (downloadedByteLen, totalByteLen), true )
247
257
248
258
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 )
250
261
if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .S )
251
262
params.setRequireUserAction(PackageInstaller .SessionParams .USER_ACTION_NOT_REQUIRED )
252
263
params.setAppPackageName(context.packageName) /* Enforces updates; disallows new apps. */
@@ -275,7 +286,7 @@ object Updater {
275
286
}
276
287
277
288
emitProgress(Progress .Installing )
278
- if (! digest.digest().contentEquals(update.second .bytes))
289
+ if (! digest.digest().contentEquals(update.hash .bytes))
279
290
throw SecurityException (" Update has invalid hash" )
280
291
sessionFailure = false
281
292
} finally {
@@ -305,10 +316,17 @@ object Updater {
305
316
return
306
317
307
318
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
+ )) {
309
323
PackageInstaller .STATUS_PENDING_USER_ACTION -> {
310
324
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
+ )!!
312
330
Application .getCoroutineScope().launch {
313
331
emitProgress(Progress .NeedsUserIntervention (userIntervention, id))
314
332
}
@@ -328,7 +346,8 @@ object Updater {
328
346
} catch (_: SecurityException ) {
329
347
}
330
348
val message =
331
- intent.getStringExtra(PackageInstaller .EXTRA_STATUS_MESSAGE ) ? : " Installation error $status "
349
+ intent.getStringExtra(PackageInstaller .EXTRA_STATUS_MESSAGE )
350
+ ? : " Installation error $status "
332
351
Application .getCoroutineScope().launch {
333
352
val e = Exception (message)
334
353
Log .e(TAG , " Update failure" , e)
@@ -344,40 +363,40 @@ object Updater {
344
363
if (installerIsGooglePlay())
345
364
return
346
365
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
+ )
349
370
return @launch
350
371
351
372
var waitTime = 15
352
373
while (true ) {
353
374
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() )
358
379
return @launch
359
380
}
360
- } catch (e: Throwable ) {
361
- Log .e(TAG , " Failed to check for updates" , e)
381
+ } catch (_: Throwable ) {
362
382
}
363
383
delay(waitTime.minutes)
364
384
waitTime = 45
365
385
}
366
386
}
367
387
368
388
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
374
391
)
375
392
emitProgress(Progress .Available (ver))
376
393
}.launchIn(Application .getCoroutineScope())
377
394
378
395
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
+ }
381
400
}.launchIn(Application .getCoroutineScope())
382
401
}
383
402
0 commit comments