ui: handle update signatures

Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
Jason A. Donenfeld 2023-05-01 16:24:41 +02:00
parent 914917036e
commit d6ad7d11d0
10 changed files with 3123 additions and 15 deletions

View File

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
@ -103,6 +104,7 @@
<intent-filter>
<action android:name="android.intent.action.ACTION_SHUTDOWN" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>

View File

@ -22,6 +22,7 @@ import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.configStore.FileConfigStore
import com.wireguard.android.model.TunnelManager
import com.wireguard.android.updater.Updater
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.wireguard.android.util.UserKnobs
@ -115,6 +116,7 @@ class Application : android.app.Application() {
Log.e(TAG, Log.getStackTraceString(e))
}
}
Updater.monitorForUpdates()
if (BuildConfig.DEBUG) {
StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())

View File

@ -8,15 +8,27 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.wireguard.android.activity.MainActivity
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.updater.Updater
import com.wireguard.android.util.applicationScope
import kotlinx.coroutines.launch
class BootShutdownReceiver : BroadcastReceiver() {
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 {
if (Application.getBackend() !is WgQuickBackend) return@launch
val action = intent.action ?: return@launch
val tunnelManager = Application.getTunnelManager()
if (Intent.ACTION_BOOT_COMPLETED == action) {
Log.i(TAG, "Broadcast receiver restoring state (boot)")

View File

@ -33,6 +33,7 @@ import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowC
import com.wireguard.android.databinding.TunnelListFragmentBinding
import com.wireguard.android.databinding.TunnelListItemBinding
import com.wireguard.android.model.ObservableTunnel
import com.wireguard.android.updater.SnackbarUpdateShower
import com.wireguard.android.util.ErrorMessages
import com.wireguard.android.util.QrCodeFromFileScanner
import com.wireguard.android.util.TunnelImporter
@ -122,6 +123,8 @@ class TunnelListFragment : BaseFragment() {
backPressedCallback = requireActivity().onBackPressedDispatcher.addCallback(this) { actionMode?.finish() }
backPressedCallback?.isEnabled = false
SnackbarUpdateShower.attachToActivity(requireActivity(), binding?.mainContainer!!, binding?.createFab)
return binding?.root
}

View File

@ -14,6 +14,7 @@ import android.widget.Toast
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.wireguard.android.R
import com.wireguard.android.updater.Updater
import com.wireguard.android.util.ErrorMessages
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 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. */
if (installer == "com.android.vending") {
if (Updater.installerIsGooglePlay()) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.donate_title)
.setMessage(R.string.donate_google_play_disappointment)

File diff suppressed because it is too large Load Diff

View File

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

View 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"
}

View File

@ -88,4 +88,34 @@ object UserKnobs {
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
}
}
}

View File

@ -229,6 +229,13 @@
<string name="type_name_go_userspace">Go userspace</string>
<string name="type_name_kernel_module">Kernel module</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 &amp; 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_checking">Checking %s backend version</string>
<string name="version_summary_unknown">Unknown %s version</string>