4 Commits

Author SHA1 Message Date
e004862b00 Bump version to 1.40.2-typing.3
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Fixes on top of -typing.2: the widget configure activity crash, the search bar
keeping the keyboard focus (and the IME) on the home screen after an app switch,
and the TextFieldValue update in SearchBar moving out of the composition.
2026-09-10 20:46:11 +02:00
ff3cab9531 Keep the search bar from holding the keyboard focus on the home screen
Returning to the launcher from another app left the search bar's text field
focused, with the on-screen keyboard up. Typing on the physical keyboard then
went into the search bar - the IME commits characters to the focused text field
- while the search results never appeared, because the key event handler
deliberately leaves keys alone while the text field has the focus. Coming home
looked like the launcher had stopped responding to the keyboard; it happens with
a freshly started launcher and a plain app switch, with no search ever opened.

Give that focus up again one frame after the launcher is resumed, and hide the
on-screen keyboard, as long as the home screen is what is shown. Only on a
resume (pauseTime > 0), so that a search component used as the home screen can
still focus itself when the launcher starts.

Also set isSearchBarFocused back to false when the text field loses the focus,
like upstream does. LauncherSearchBar requests focus whenever that flag is true,
so only ever setting it to true made the text field focus itself again after it
had been unfocused - which is what kept the on-screen keyboard around.

Clear the search query before resetting the page in the resume path, so that a
stale query is gone even if the page transition is interrupted.

Note: the focus fix is a workaround for the framework/IME handing the focus back
to the text field; it is not verified on the device yet.
2026-09-10 20:41:05 +02:00
79f229de6d SearchBar: adopt outside text changes in a side effect
SearchBar keeps a TextFieldValue so that the cursor ends up behind text that was
changed from the outside (a search started by typing on a physical keyboard, or
a reset search). That value was updated during composition, which is an
anti-pattern: the value read earlier in the same composition is what gets drawn,
so the write only schedules another recomposition pass of the search bar - and
it can be dropped entirely if the composition does not complete.

Do the same update in a SideEffect, after the composition. No behaviour change
for the user.
2026-09-10 20:41:01 +02:00
73c3683740 Don't crash when a widget has no configure activity
A widget can advertise WIDGET_FEATURE_RECONFIGURABLE without providing a
configure activity (or the app providing it was uninstalled since). Tapping
"Configure widget" then made
AppWidgetHost.startAppWidgetConfigureActivityForResult throw, which killed the
launcher:

  android.content.ActivityNotFoundException: No Activity found to handle null
    at android.app.Activity.startIntentSenderForResult
    at android.appwidget.AppWidgetHost.startAppWidgetConfigureActivityForResult
    at ConfigureWidgetSheetKt.ConfigureAppWidget$lambda$10$1$0(ConfigureWidgetSheet.kt:812)

This happened three times on the Titan 2 on 2026-09-10 (19:55, 19:57 and 20:03,
the last one 28 seconds after boot). Catch ActivityNotFoundException, and
SecurityException for a configure activity that refuses to be started, and log
instead. Upstream main still has the same unguarded call.
2026-09-10 20:40:57 +02:00
4 changed files with 64 additions and 21 deletions

View File

@@ -34,8 +34,8 @@ android {
applicationId = "de.mm20.launcher2"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091001
versionName = "1.40.2-typing.2"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091002
versionName = "1.40.2-typing.3"
signingConfig = signingConfigs.getByName("debug")
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -143,8 +144,14 @@ fun SearchBar(
val textFieldValue = remember {
mutableStateOf(TextFieldValue(value, TextRange(value.length)))
}
if (textFieldValue.value.text != value) {
textFieldValue.value = TextFieldValue(value, TextRange(value.length))
// Characters the user typed are already in this value; onValueChange below keeps the cursor
// where the user put it. Text that changed from the outside - the search was reset, or a
// character was inserted by the physical keyboard handler - is adopted here, with the cursor at
// the end. This must not write state during composition, so it runs in a side effect after it.
SideEffect {
if (textFieldValue.value.text != value) {
textFieldValue.value = TextFieldValue(value, TextRange(value.length))
}
}
LauncherCard(

View File

@@ -63,6 +63,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
@@ -79,6 +80,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalViewConfiguration
@@ -988,6 +990,7 @@ internal fun LauncherScaffold(
val lifecycleOwner = LocalLifecycleOwner.current
val activity = LocalActivity.current as AppCompatActivity
val view = LocalView.current
val focusManager = LocalFocusManager.current
val wallpaperManager = remember(activity) { WallpaperManager.getInstance(activity) }
@@ -1136,10 +1139,27 @@ internal fun LauncherScaffold(
// The search does not survive leaving the launcher, not even for a short app
// switch: coming back should always show a fresh search.
if (!state.isLocked) {
// Clear the query first: the page transition below animates and must not
// be what keeps a stale query around.
searchVM.reset()
if (state.currentComponent?.survivesPause != true) {
state.reset()
}
searchVM.reset()
}
}
// The framework hands the keyboard focus - and with it the on-screen keyboard -
// back to the search bar's text field whenever the launcher's window regains
// focus, even though no search is open. The home screen would then sit behind an
// on-screen keyboard and the physical keyboard would type into the search bar
// without the search results ever opening, because the characters are delivered
// to the focused text field instead of the key event handler. Give that focus up
// again, one frame after the resume, so that it also works when the framework
// grants the focus while the window is coming back.
if (activity.pauseTime > 0L) {
withFrameNanos { }
if (state.currentComponent !is SearchComponent) {
focusManager.clearFocus()
activity.hideSoftKeyboard()
}
}
awaitCancellation()
@@ -1409,8 +1429,11 @@ internal fun LauncherScaffold(
// as usual from now on.
state.isTypingSearch = false
scope.launch { state.onSearchBarTap() }
state.isSearchBarFocused = true
}
// Keep this in sync with the real focus state. It must go back to false when
// the text field loses focus, otherwise the launcher keeps asking for it
// (LauncherSearchBar requests focus whenever this is true).
state.isSearchBarFocused = it
},
onKeyboardActionGo = if (launchOnEnter) {
{ searchVM.launchBestMatchOrAction(activity) }

View File

@@ -4,9 +4,11 @@ import android.app.Activity
import android.app.ActivityOptions
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
@@ -809,21 +811,32 @@ fun ColumnScope.ConfigureAppWidget(
bottom = 8.dp,
),
onClick = {
appWidgetHost.startAppWidgetConfigureActivityForResult(
lifecycleOwner as Activity,
widget.config.widgetId,
0,
0,
if (Build.VERSION.SDK_INT < 34) {
null
} else {
ActivityOptions.makeBasic()
.setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
)
.toBundle()
}
)
try {
appWidgetHost.startAppWidgetConfigureActivityForResult(
lifecycleOwner as Activity,
widget.config.widgetId,
0,
0,
if (Build.VERSION.SDK_INT < 34) {
null
} else {
ActivityOptions.makeBasic()
.setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
)
.toBundle()
}
)
} catch (e: ActivityNotFoundException) {
// A widget can advertise WIDGET_FEATURE_RECONFIGURABLE without providing a
// configure activity (or the app providing it was uninstalled). Starting it
// then throws, which would crash the launcher.
Log.w("MM20", "No configure activity for widget ${widget.config.widgetId}", e)
} catch (e: SecurityException) {
// The configure activity refused to be started, e.g. because the app owning
// the widget is not allowed to start activities from the background.
Log.w("MM20", "Widget configure activity refused to start", e)
}
}) {
Text(
stringResource(id = R.string.widget_config_appwidget_configure)