5 Commits
Author SHA1 Message Date
TMCandClaude Fable 5.2 04e78205dd Release script: do not take Android's own crash during an APK swap for the app's
When the release build is the emulator's home app, Android restarts it while
the files are still being replaced, and that start can fail inside
ActivityThread.handleBindApplication. The start-up check now waits for the swap
to settle, stops the app, and judges a fresh start.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:57:49 +01:00
TMC cef6929a41 Release 0.9.0 2026-11-12 14:36:21 +01:00
TMCandClaude Fable 5.2 fba65e091f Add quick launch: a swipe past the first or last home page opens a chosen app
Swiping on from the first home screen page, towards the page that is not
there, now does what the owner chose under Settings, Gestures, Quick launch:
open an app, or any other of the launcher's gesture actions. The last page has
a choice of its own.

The stretch the pages already show at either end is an edge effect; quick
launch replaces it with one that also counts how far the finger has pulled.
Lifting the finger after a pull of about a seventh of the screen, or after a
flick, does the action; a short pull or one that is taken back does nothing.
An app opens out of the screen edge the finger pulled from.

From Android 12, where the stretch is Android's own. Infinite scrolling takes
those swipes for itself, so the two do not combine.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:36:03 +01:00
TMC b696a376b2 Release 0.8.1 2026-11-12 13:52:28 +01:00
TMCandClaude Fable 5.2 bfc2a0ea36 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 <noreply@anthropic.com>
2026-11-12 13:52:16 +01:00
10 changed files with 366 additions and 7 deletions

No files matched your search

+7
View File
@@ -12,6 +12,13 @@
<!-- Lets the app hand a downloaded update to Android's installer. The owner still confirms each install. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
Since Android 17 an app needs this to reach a device on the network the phone itself is on.
The update server can be such a device: at the owner's home its name leads to its address
on the home network. Asked for only when a check for updates is stopped by it (About screen).
-->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application>
<!--
+7
View File
@@ -211,6 +211,13 @@ if ($NoSmokeTest) {
if ($LASTEXITCODE -ne 0 -or -not ($install -match 'Success')) {
throw "Could not install the APK on $emulator. If an older build signed with another key is installed there, uninstall it first: adb uninstall $AppId"
}
# When this app is the emulator's home app, Android restarts it the moment the old copy is
# gone, and that first start can die inside Android itself while the files are still being
# swapped (a NullPointerException in ActivityThread.handleBindApplication, with no line of
# this app in it). That is not the new build crashing: let the swap settle, then start
# from a clean slate and judge what happens from there.
Start-Sleep -Seconds 6
& $Adb -s $emulator shell am force-stop $AppId
& $Adb -s $emulator logcat -b crash -c
& $Adb -s $emulator shell am start -n $HomeActivity | Out-Null
Write-Host "Installed on $emulator and started; watching for $SmokeTestSeconds seconds..."
+7
View File
@@ -92,6 +92,12 @@
<string name="flexera_scrub_from_home">Scrub from the home screen</string>
<string name="flexera_scrub_from_home_description">Slide up or down along the alphabet\'s edge of the home screen to open the app list at a letter. Swipe up and hold does the same from anywhere.</string>
<!-- Quick launch: a swipe on past the first or the last home screen page (app.flexera.home.QuickLaunch) -->
<string name="flexera_quick_launch">Quick launch</string>
<string name="flexera_quick_launch_description">Swipe on from the first or the last home screen page, as if there were one more, to open an app or do something else. Has no effect while Infinite scrolling is on.</string>
<string name="flexera_quick_launch_first_page">Swipe past the first page</string>
<string name="flexera_quick_launch_last_page">Swipe past the last page</string>
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
<!-- Its name in the list of widgets -->
<string name="flexera_picture_widget">Pictures</string>
@@ -111,6 +117,7 @@
<!-- 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_local_network">%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.</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>
+167
View File
@@ -0,0 +1,167 @@
package app.flexera.home
import android.app.ActivityOptions
import android.content.Context
import android.content.pm.LauncherApps
import android.os.Build
import android.util.Log
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import app.lawnchair.LawnchairLauncher
import app.lawnchair.gestures.config.GestureHandlerConfig
import app.lawnchair.gestures.handlers.OpenAppTarget
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.Launcher
import com.android.launcher3.LauncherState
import com.android.launcher3.util.EdgeEffectCompat
/**
* Quick launch: a swipe on from the first home screen page, towards the page that is not there,
* opens an app the owner has chosen (or does another of the launcher's gesture actions); the
* same past the last page.
*
* The home screen already answers such a swipe by stretching and springing back. That stretch is
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
*/
object QuickLaunch {
private const val TAG = "QuickLaunch"
/** How far the finger has to pull past the page, as a share of the screen's width. */
private const val ARM_DISTANCE = 0.14f
/** A flick counts from this much pull, at [FLICK_DP_PER_SECOND] or faster. */
private const val FLICK_DISTANCE = 0.02f
private const val FLICK_DP_PER_SECOND = 600f
/**
* The edge effect for one side of the home screen pages. [atLeftEdge] says which side of
* the screen; which page that is the end of depends on the reading direction ([rtl]).
*/
@JvmStatic
fun edgeEffect(workspace: View, launcher: Launcher, atLeftEdge: Boolean, rtl: Boolean): EdgeEffectCompat =
Effect(workspace, launcher, firstPage = atLeftEdge != rtl, atLeftEdge = atLeftEdge)
/** What the owner has chosen for a swipe past the first or the last page. */
private fun choice(context: Context, firstPage: Boolean): GestureHandlerConfig {
val prefs = PreferenceManager2.getInstance(context)
return if (firstPage) {
prefs.firstPageSwipeGestureHandler.firstCached()
} else {
prefs.lastPageSwipeGestureHandler.firstCached()
}
}
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
val choice = choice(launcher, firstPage)
if (choice is GestureHandlerConfig.NoOp) return
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
if (target is OpenAppTarget.App) {
// The app opens out of the screen edge the finger pulled from, as near to a page
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
// the launcher's own making only for the phone's built-in launcher.
val root = launcher.rootView
val options = ActivityOptions.makeClipRevealAnimation(
root,
if (fromLeft) 0 else root.width - 1,
0,
1,
root.height,
)
try {
launcher.getSystemService(LauncherApps::class.java)
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
} catch (e: RuntimeException) {
// The chosen app is gone or cannot be started; nothing to open.
Log.w(TAG, "The quick launch app could not be opened", e)
}
return
}
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
}
private class Effect(
private val workspace: View,
private val launcher: Launcher,
private val firstPage: Boolean,
private val atLeftEdge: Boolean,
) : EdgeEffectCompat(workspace.context) {
/** How far the finger has pulled in this touch, as a share of the screen's width. */
private var pulled = 0f
/** Whether there is anything to do on this side; looked up when a pull begins. */
private var active = false
private var armed = false
private var flicked = false
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
if (pulled == 0f && deltaDistance > 0f) {
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
launcher.isInState(LauncherState.NORMAL) &&
!launcher.dragController.isDragging
}
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
val nowArmed = active && pulled >= ARM_DISTANCE
if (nowArmed != armed) {
armed = nowArmed
// A tick when the pull is far enough, and again when it is taken back.
tick()
}
return consumed
}
override fun onFlingVelocity(velocity: Int) {
// A pull at the left side is a finger moving right, which is a positive speed.
val outward = if (atLeftEdge) velocity else -velocity
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
}
override fun onRelease(ev: MotionEvent) {
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
val fire = lifted && active && (armed || flicked)
val tickNow = fire && !armed
reset()
super.onRelease(ev)
if (fire) {
if (tickNow) tick()
// After the page has finished handling this touch.
workspace.post { launch(launcher, firstPage, fromLeft = atLeftEdge) }
}
}
override fun onRelease() {
reset()
super.onRelease()
}
override fun finish() {
reset()
super.finish()
}
private fun reset() {
pulled = 0f
active = false
armed = false
flicked = false
}
private fun tick() {
val feedback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE
} else {
HapticFeedbackConstants.CLOCK_TICK
}
// The pages themselves have haptics switched off; the window's root has not.
launcher.rootView.performHapticFeedback(feedback)
}
}
}
+45 -1
View File
@@ -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
}
@@ -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") }
+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.8.0
versionName=0.9.0
@@ -874,6 +874,18 @@ class PreferenceManager2 @Inject constructor(
defaultValue = GestureHandlerConfig.NoOp,
)
// Flexera Launcher: quick launch. What a swipe on from the first home screen page, towards
// the page that is not there, does; and the same past the last page (app.flexera.home.QuickLaunch).
val firstPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
key = stringPreferencesKey("flexera_first_page_swipe_gesture_handler"),
defaultValue = GestureHandlerConfig.NoOp,
)
val lastPageSwipeGestureHandler = serializablePreference<GestureHandlerConfig>(
key = stringPreferencesKey("flexera_last_page_swipe_gesture_handler"),
defaultValue = GestureHandlerConfig.NoOp,
)
val autoUpdaterNightly = preference(
key = booleanPreferencesKey(name = "enable_nightly_auto_updater"),
defaultValue = if (BuildConfig.APPLICATION_ID.contains("nightly")) {
@@ -1,5 +1,6 @@
package app.lawnchair.ui.preferences.destinations
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -53,6 +54,23 @@ fun GesturePreferences(
label = stringResource(id = R.string.gesture_back_tap),
)
}
// Flexera Launcher: quick launch, a swipe on past the first or the last home screen page
// (app.flexera.home.QuickLaunch). It rides on Android's own page stretch, new in Android 12.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PreferenceGroup(
heading = stringResource(id = R.string.flexera_quick_launch),
description = stringResource(id = R.string.flexera_quick_launch_description),
) {
GestureHandlerPreference(
adapter = prefs.firstPageSwipeGestureHandler.getAdapter(),
label = stringResource(id = R.string.flexera_quick_launch_first_page),
)
GestureHandlerPreference(
adapter = prefs.lastPageSwipeGestureHandler.getAdapter(),
label = stringResource(id = R.string.flexera_quick_launch_last_page),
)
}
}
PreferenceGroup(heading = stringResource(id = R.string.sleep_mode_label)) {
ListPreference(
adapter = prefs.sleepMode.getAdapter(),
+11 -1
View File
@@ -372,6 +372,13 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
setHapticFeedbackEnabled(false);
initWorkspace();
// Flexera Launcher: quick launch. The stretch at either end of the pages doubles as a
// gesture that opens an app the owner has chosen (app.flexera.home.QuickLaunch). From
// Android 12, where that stretch is Android's own; before, Lawnchair draws one itself.
if (Utilities.ATLEAST_S) {
mEdgeGlowLeft = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, true, mIsRtl);
mEdgeGlowRight = app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, false, mIsRtl);
}
// Disable multitouch across the workspace/all apps/customize tray
setMotionEventSplittingEnabled(true);
@@ -1476,7 +1483,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
public void setLauncherOverlay(LauncherOverlayTouchProxy overlay) {
final EdgeEffectCompat newEffect;
if (overlay == null) {
newEffect = new EdgeEffectCompat(getContext());
// Flexera Launcher: without a feed, the first page's end is quick launch again.
newEffect = Utilities.ATLEAST_S
? app.flexera.home.QuickLaunch.edgeEffect(this, mLauncher, !mIsRtl, mIsRtl)
: new EdgeEffectCompat(getContext());
mOverlayEdgeEffect = null;
} else {
newEffect = mOverlayEdgeEffect = new OverlayEdgeEffect(getContext(), overlay);