2 Commits
Author SHA1 Message Date
TMC 494364ffa2 Release 0.6.1 2026-11-12 10:43:09 +01:00
TMCandClaude Fable 5.2 80a56c01c9 Say why a check for updates failed
The owner's phone answered a manual check with "Couldn't reach the update server" while
the same published version found the update on the emulator and the server answered from
the PC. The message gave no way to tell a missing network permission from a VPN, a name
that does not resolve, or a server error.

- A failed check now carries what kind of failure it was (no network permission, server
  not found, no answer, no secure connection, server error, unreadable answer) and the
  error's own words. The About screen says the kind in plain words with what to try, and
  shows the words under it as "Details".
- The no-permission case is told apart before any request is made; some Android systems
  let the owner switch the network off per app.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:43:07 +01:00
4 changed files with 102 additions and 9 deletions

No files matched your search

+9 -1
View File
@@ -90,7 +90,15 @@
<string name="flexera_update_check_idle">Flexera Launcher also checks on its own, once a day</string>
<string name="flexera_update_checking">Checking for updates…</string>
<string name="flexera_update_up_to_date">You have the latest version</string>
<string name="flexera_update_check_failed">Couldn\'t reach the update server. Check your connection, then tap to try again.</string>
<!-- Why a check for updates failed. %1$s is the update server's name, such as git.example.org -->
<string name="flexera_update_failed_no_permission">Android does not let Flexera Launcher use the network on this phone. Allow it in the phone\'s settings for this app, under Permissions, Network, then tap to try again.</string>
<string name="flexera_update_failed_not_found">This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again.</string>
<string name="flexera_update_failed_no_answer">%1$s did not answer. Check the connection and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_not_secure">A secure connection to %1$s could not be made. Check the phone\'s date and time and any VPN, then tap to try again.</string>
<string name="flexera_update_failed_server_error">%1$s answered with an error. Tap to try again later.</string>
<string name="flexera_update_failed_bad_answer">The answer from %1$s could not be read. Tap to try again later.</string>
<!-- %1$s is the error in the system\'s own words, for whoever looks for the cause -->
<string name="flexera_update_failed_detail">Details: %1$s</string>
<!-- %1$s is a version number such as 0.2.0 -->
<string name="flexera_update_download">Download version %1$s</string>
<!-- %1$s is a file size such as 24 MB -->
+21 -1
View File
@@ -47,6 +47,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.flexera.update.CheckProblem
import app.flexera.update.FlexeraRelease
import app.flexera.update.FlexeraUpdater
import app.flexera.update.UpdateFailure
@@ -294,7 +295,7 @@ private fun UpdateStatusRow(state: UpdateState) {
if (release == null) {
ClickablePreference(
label = stringResource(id = R.string.flexera_update_check),
subtitle = stringResource(id = R.string.flexera_update_check_failed),
subtitle = checkFailureText(state),
onClick = { FlexeraUpdater.check(context) },
)
} else {
@@ -379,6 +380,25 @@ private fun QrCode(text: String, description: String, modifier: Modifier = Modif
}
}
/**
* Says why a check for updates failed and what to do about it, then the error's own words, which
* are what someone looking for the cause will ask for.
*/
@Composable
private fun checkFailureText(state: UpdateState.Failed): String {
val host = FlexeraUpdater.SERVER_HOST
val reason = when (state.problem) {
CheckProblem.NO_PERMISSION -> stringResource(id = R.string.flexera_update_failed_no_permission)
CheckProblem.SERVER_NOT_FOUND -> stringResource(id = R.string.flexera_update_failed_not_found, host)
CheckProblem.NOT_SECURE -> stringResource(id = R.string.flexera_update_failed_not_secure, host)
CheckProblem.SERVER_ERROR -> stringResource(id = R.string.flexera_update_failed_server_error, host)
CheckProblem.BAD_ANSWER -> stringResource(id = R.string.flexera_update_failed_bad_answer, host)
CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host)
}
val detail = state.detail ?: return reason
return reason + "\n" + stringResource(id = R.string.flexera_update_failed_detail, detail)
}
@Composable
private fun downloadSizeText(context: Context, release: FlexeraRelease): String =
if (release.apkSize > 0) {
@@ -24,6 +24,8 @@ import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
/** One published version of Flexera Launcher. */
@@ -56,6 +59,31 @@ enum class UpdateFailure {
WRONG_FILE,
}
/**
* Why a check for updates failed, as far as the phone can tell. The About screen says it in
* words, because "could not reach the server" alone leaves the owner guessing between the
* connection, a VPN, the server and the phone's own settings.
*/
enum class CheckProblem {
/** Android does not let this app use the network (a per-app switch on some systems). */
NO_PERMISSION,
/** The server's name could not be looked up. */
SERVER_NOT_FOUND,
/** The server was found but did not answer in time, or refused the connection. */
NO_ANSWER,
/** The encrypted connection could not be set up. */
NOT_SECURE,
/** The server answered, with an error. */
SERVER_ERROR,
/** The server answered with something that is not a release. */
BAD_ANSWER,
}
/** Where the updater currently stands. The About screen shows one row per state. */
sealed interface UpdateState {
/** Nothing has been checked since the launcher started. */
@@ -72,8 +100,17 @@ sealed interface UpdateState {
data class ReadyToInstall(val release: FlexeraRelease, val file: File) : UpdateState
/** [release] is set when the failure happened after a newer version had been found. */
data class Failed(val failure: UpdateFailure, val release: FlexeraRelease? = null) : UpdateState
/**
* [release] is set when the failure happened after a newer version had been found. For a
* failed check, [problem] says why and [detail] carries the error's own words, for
* whoever has to find the cause.
*/
data class Failed(
val failure: UpdateFailure,
val release: FlexeraRelease? = null,
val problem: CheckProblem? = null,
val detail: String? = null,
) : UpdateState
}
/**
@@ -190,7 +227,7 @@ object FlexeraUpdater {
Log.w(TAG, "Update check failed", error)
// A background check that fails stays quiet: the About screen keeps what it showed
// before, including an update that is already waiting. It tries again in an hour.
mutableState.value = if (automatic) before else UpdateState.Failed(UpdateFailure.CHECK)
mutableState.value = if (automatic) before else failedCheck(error)
return@launch
}
@@ -302,13 +339,41 @@ object FlexeraUpdater {
private fun isBusy(): Boolean =
mutableState.value.let { it is UpdateState.Checking || it is UpdateState.Downloading }
/** The server's name without the scheme, for messages. */
@JvmField
val SERVER_HOST: String = SERVER.substringAfter("://")
private class NoNetworkPermissionException : IOException("This app has no network permission")
private class ServerErrorException(status: Int) : IOException("The server answered HTTP $status")
private class BadAnswerException(message: String) : IOException(message)
/** Turns the error of a failed check into a state that says what kind of failure it was. */
private fun failedCheck(error: Throwable): UpdateState.Failed {
val problem = when (error) {
is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION
is UnknownHostException -> CheckProblem.SERVER_NOT_FOUND
is SSLException -> CheckProblem.NOT_SECURE
is ServerErrorException -> CheckProblem.SERVER_ERROR
is BadAnswerException, is JSONException -> CheckProblem.BAD_ANSWER
else -> CheckProblem.NO_ANSWER
}
// Android's own error classes keep their names in a release build; this file's are
// renamed by the shrinker, and their messages say everything anyway.
val name = error.javaClass.name.takeIf { it.startsWith("java") || it.startsWith("android") || it.startsWith("org.json") }
?.substringAfterLast('.')
val detail = listOfNotNull(name, error.message).joinToString(": ").take(MAX_DETAIL_LENGTH)
return UpdateState.Failed(UpdateFailure.CHECK, problem = problem, detail = detail.ifEmpty { null })
}
private const val MAX_DETAIL_LENGTH = 200
/** Returns the latest published release, or null when nothing has been published yet. */
private fun fetchLatestRelease(context: Context): FlexeraRelease? {
val connection = open(context, LATEST_RELEASE_URL)
try {
val status = connection.responseCode
if (status == HttpURLConnection.HTTP_NOT_FOUND) return null
if (status != HttpURLConnection.HTTP_OK) throw IOException("Update server answered HTTP $status")
if (status != HttpURLConnection.HTTP_OK) throw ServerErrorException(status)
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
val tag = json.getString("tag_name")
@@ -326,7 +391,7 @@ object FlexeraUpdater {
apkSize = asset.optLong("size", 0L),
)
}
throw IOException("Release $tag has no APK attached")
throw BadAnswerException("Release $tag has no APK attached")
} finally {
connection.disconnect()
}
@@ -373,7 +438,7 @@ object FlexeraUpdater {
}
private fun open(context: Context, url: String): HttpURLConnection {
if (!FlexeraNetwork.hasInternetPermission(context)) throw IOException("No INTERNET permission")
if (!FlexeraNetwork.hasInternetPermission(context)) throw NoNetworkPermissionException()
return (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.6.0
versionName=0.6.1