From bfc2a0ea360e4798a25b990fa4fd26b8c1e38e71 Mon Sep 17 00:00:00 2001 From: TMC Date: Thu, 12 Nov 2026 13:52:16 +0100 Subject: [PATCH] Ask for local network access when the update server is on the phone's own network On Android 17 an app that targets it may not connect to devices on the local network without the new ACCESS_LOCAL_NETWORK permission; a blocked connection is dropped and ends as a timeout. At the owner's home the update server's name leads to its address on the home network, so the check for updates failed there with "did not answer" while the same server answered every other device. The updater now recognises this case before it connects (Android 17, permission not granted, server address inside one of the phone's own network ranges) and says so at once. A tap on that message asks for the permission and runs the check again; a refusal that Android no longer asks about leads to the app's page in the phone's settings. Co-Authored-By: Claude Fable 5.2 --- flexera/AndroidManifest.xml | 7 ++ flexera/res/values/flexera_strings.xml | 1 + flexera/src/app/flexera/ui/FlexeraAbout.kt | 46 ++++++++- .../src/app/flexera/update/FlexeraUpdater.kt | 95 ++++++++++++++++++- 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/flexera/AndroidManifest.xml b/flexera/AndroidManifest.xml index a73e6190c4..11ad701e51 100644 --- a/flexera/AndroidManifest.xml +++ b/flexera/AndroidManifest.xml @@ -12,6 +12,13 @@ + + + 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. This phone could not find %1$s. Check the connection and any VPN or private DNS, then tap to try again. + %1$s is on the same network as this phone, and Android lets an app reach devices there only with your permission, which it calls Nearby devices. Tap to allow it. The check then runs again. %1$s did not answer. Check the connection and any VPN, then tap to try again. 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. %1$s answered with an error. Tap to try again later. diff --git a/flexera/src/app/flexera/ui/FlexeraAbout.kt b/flexera/src/app/flexera/ui/FlexeraAbout.kt index f8c9e46e5b..dba11959f0 100644 --- a/flexera/src/app/flexera/ui/FlexeraAbout.kt +++ b/flexera/src/app/flexera/ui/FlexeraAbout.kt @@ -1,9 +1,13 @@ package app.flexera.ui +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.provider.Settings import android.text.format.Formatter +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -246,6 +250,21 @@ fun FlexeraAbout(modifier: Modifier = Modifier) { @Composable private fun UpdateStatusRow(state: UpdateState) { val context = LocalContext.current + // Android 17 asks the owner before an app may reach devices on the local network, and an + // update server at home is one. The answer starts the check again; a "no" that Android no + // longer asks about leads to the app's page in the phone's settings instead. + val askForLocalNetwork = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val activity = context.findActivity() + if (granted || activity == null || + activity.shouldShowRequestPermissionRationale(FlexeraUpdater.LOCAL_NETWORK_PERMISSION) + ) { + FlexeraUpdater.check(context) + } else { + openAppSettings(context) + } + } when (state) { UpdateState.Idle -> ClickablePreference( label = stringResource(id = R.string.flexera_update_check), @@ -296,7 +315,13 @@ private fun UpdateStatusRow(state: UpdateState) { ClickablePreference( label = stringResource(id = R.string.flexera_update_check), subtitle = checkFailureText(state), - onClick = { FlexeraUpdater.check(context) }, + onClick = { + if (state.problem == CheckProblem.LOCAL_NETWORK) { + askForLocalNetwork.launch(FlexeraUpdater.LOCAL_NETWORK_PERMISSION) + } else { + FlexeraUpdater.check(context) + } + }, ) } else { ClickablePreference( @@ -393,6 +418,7 @@ private fun checkFailureText(state: UpdateState.Failed): String { 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.LOCAL_NETWORK -> stringResource(id = R.string.flexera_update_failed_local_network, host) CheckProblem.NO_ANSWER, null -> stringResource(id = R.string.flexera_update_failed_no_answer, host) } val detail = state.detail ?: return reason @@ -445,3 +471,21 @@ private fun openNotificationSettings(context: Context) { context.startActivity(intent) } } + +/** Opens this app's page in the phone's settings, where its permissions are. */ +private fun openAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${context.packageName}".toUri()) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } +} + +/** The activity this context belongs to, if it belongs to one. */ +private fun Context.findActivity(): Activity? { + var current: Context? = this + while (current != null) { + if (current is Activity) return current + current = (current as? ContextWrapper)?.baseContext + } + return null +} diff --git a/flexera/src/app/flexera/update/FlexeraUpdater.kt b/flexera/src/app/flexera/update/FlexeraUpdater.kt index 3dd847028f..72f872e007 100644 --- a/flexera/src/app/flexera/update/FlexeraUpdater.kt +++ b/flexera/src/app/flexera/update/FlexeraUpdater.kt @@ -9,7 +9,9 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.provider.Settings import android.util.Log @@ -23,6 +25,8 @@ import com.android.launcher3.R import java.io.File import java.io.IOException import java.net.HttpURLConnection +import java.net.InetAddress +import java.net.NetworkInterface import java.net.URL import java.net.UnknownHostException import javax.net.ssl.SSLException @@ -74,6 +78,14 @@ enum class CheckProblem { /** The server was found but did not answer in time, or refused the connection. */ NO_ANSWER, + /** + * The server's address is on the network the phone itself is on (a server at home, reached + * over the home Wi-Fi), and Android has not been told to let this app reach devices there. + * Since Android 17 that takes a permission of its own; without it the connection is + * dropped without a word and looks like a server that does not answer. + */ + LOCAL_NETWORK, + /** The encrypted connection could not be set up. */ NOT_SECURE, @@ -216,7 +228,12 @@ object FlexeraUpdater { nextAutomaticCheckAt = System.currentTimeMillis() + RETRY_INTERVAL_MS scope.launch { - val result = runCatching { fetchLatestRelease(app) } + val result = runCatching { + // Android drops a connection it does not allow without a word, and the check + // would wait out its time limit. Say at once what is needed instead. + if (blockedFromLocalNetwork(app)) throw LocalNetworkBlockedException() + fetchLatestRelease(app) + } prefs(app).edit() .putLong(KEY_LAST_ATTEMPT, System.currentTimeMillis()) .putBoolean(KEY_LAST_ATTEMPT_OK, result.isSuccess) @@ -227,7 +244,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 failedCheck(error) + mutableState.value = if (automatic) before else failedCheck(app, error) return@launch } @@ -346,17 +363,87 @@ object FlexeraUpdater { 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) + private class LocalNetworkBlockedException : IOException("Android does not let this app reach the local network") + + /** + * The permission Android 17 asks of an app before it may reach devices on the local + * network. Named by its text, because older Android versions, which the app also runs on, + * do not know it. + */ + const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK" + + /** The first Android version that keeps apps from the local network: Android 17. */ + private const val LOCAL_NETWORK_SDK = 37 + + /** + * True when Android is keeping this app from the update server because the server is on + * the phone's own network and the owner has not allowed local network access. Looks the + * server's name up, so not for the main thread. + */ + private fun blockedFromLocalNetwork(context: Context): Boolean { + if (Build.VERSION.SDK_INT < LOCAL_NETWORK_SDK) return false + if (context.checkSelfPermission(LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED) return false + return try { + // Looking a name up is allowed without the permission, also at a resolver at home. + InetAddress.getAllByName(URL(SERVER).host).any(::isOnOwnNetwork) + } catch (e: IOException) { + false + } catch (e: SecurityException) { + false + } + } + + /** + * True for an address that this phone reaches directly, without a router in between: one + * inside the range of one of its own network connections. That is what Android counts as + * the local network. + */ + private fun isOnOwnNetwork(address: InetAddress): Boolean { + if (address.isLoopbackAddress) return false + if (address.isLinkLocalAddress) return true + val target = address.address + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false + for (network in interfaces) { + if (network.isLoopback || !network.isUp) continue + for (own in network.interfaceAddresses) { + val base = own.address?.address ?: continue + if (base.size != target.size) continue + var bits = own.networkPrefixLength.toInt() + if (bits <= 0) continue + var same = true + for (i in base.indices) { + if (bits <= 0) break + val mask = if (bits >= 8) 0xFF else (0xFF shl (8 - bits)) and 0xFF + if ((base[i].toInt() and mask) != (target[i].toInt() and mask)) { + same = false + break + } + bits -= 8 + } + if (same) return true + } + } + return false + } /** 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) { + private fun failedCheck(context: Context, error: Throwable): UpdateState.Failed { + val kind = when (error) { is NoNetworkPermissionException, is SecurityException -> CheckProblem.NO_PERMISSION + is LocalNetworkBlockedException -> CheckProblem.LOCAL_NETWORK 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 } + // A server that did not answer may still be one that Android kept this app from, where + // the look before the check could not tell (the network changed in between, say). + val problem = if (kind == CheckProblem.NO_ANSWER && blockedFromLocalNetwork(context)) { + CheckProblem.LOCAL_NETWORK + } else { + kind + } // 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") }