7 Commits

Author SHA1 Message Date
5c1e9c5bf2 Handle physical keyboard keys on the scaffold state that is rendered
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Build Nightly APK / build (push) Has been cancelled
The key event handler was installed on the activity together with the state
instance it captured, so it could keep talking to a state that is no longer the
one that is drawn. That happens in practice: the scaffold state is recreated
when one of its rememberSaveable keys changes, which it does right after the
launcher starts (the window is measured, and the state is restored after the
configuration change), and the activity then still holds the handler of the
previous state. Typing on the home screen set the search query and started the
page transition on the detached state, so the search page never appeared, while
the query showed up in the search bar.

The handler is now created without the state as a key and asks for the current
state through a provider, and the activity's handler is refreshed on every
composition instead of only when the handler instance changes.

Verified on the Titan 2: typing on the home screen opens the search page with
the character inserted, further characters append, the text field stays
unfocused and the on-screen keyboard stays hidden.
2026-09-10 21:44:46 +02:00
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
e17903f40f Clear the search when the launcher is left, end keyboard search on dismiss
Some checks failed
Trigger F-Droid repository rebuild / trigger (release) Has been cancelled
Two fixes on top of the type-to-search feature:

- The search no longer survives leaving the launcher. Upstream only resets the
  search when the launcher was in the background for more than five seconds, so
  a quick app switch or a fast return from a launched app left the old query on
  screen. The search page is now always reset, whatever the pause was.
- Dismissing the search page ends the keyboard driven search. Without that the
  flag stayed set, so opening the search later (gesture or search bar) did not
  focus the text field and the soft keyboard stayed away.

Version 1.40.2-typing.2.
2026-09-10 12:03:00 +02:00
75f869c094 Add notes for this fork (type-to-search design, build, release, install) 2026-09-10 11:59:00 +02:00
8 changed files with 186 additions and 31 deletions

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

87
CLAUDE.md Normal file
View File

@@ -0,0 +1,87 @@
# Kvaesitso (fork with type-to-search)
Fork of [Kvaesitso](https://github.com/MM2-0/Kvaesitso) that adds type-to-search for phones with a
physical keyboard. Built for, and installed on, a Unihertz Titan 2 Elite.
origin = https://gitea.haugesenspil.dk/jonas/Kvaesitso (our fork — push here)
upstream = https://github.com/MM2-0/Kvaesitso (pull upstream changes from here)
The phone also has this repository checked out in its Termux home: `~/sources/Kvaesitso`.
## The feature
Pressing a printable key on the physical keyboard while the launcher home screen is shown opens the
search page and inserts the character. Backspace deletes, enter launches the highlighted result.
Only the home screen (and the open search page) reacts; the widgets, notifications and recents
pages do not. Setting: *Settings → Search → "Search on typing"* (on by default, stored as
`searchOnTyping`).
## How it works, and why it is built this way
`SharedLauncherActivity.dispatchKeyEvent` is the only place that sees hardware key events while no
view has focus, so the handler is installed there (`launcherKeyEventHandler`, registered by
`LauncherScaffold` while it is shown). The handler itself is
`app/ui/.../ui/launcher/searchbar/TypeToSearchHandler.kt`.
Two constraints shape the design:
1. **The search bar's text field stays unfocused while the keyboard drives the search.** A focused
Compose text field makes the system show the on-screen keyboard. There is no public API to
prevent that: `View.setShowSoftInputOnFocus` is a hidden API (`TextView`'s version is public) and
reflection fails with `NoSuchMethodException`, and hiding the IME reactively makes it flash.
Leaving the field unfocused keeps the IME away completely — which is why the handler inserts and
deletes the characters itself instead of relying on the text field.
2. **No key may be lost.** Characters can arrive before the text field would have focus, so the
handler keeps handling keys until the field reports real focus
(`LauncherScaffoldState.isSearchBarTextFieldFocused`). `LauncherScaffoldState.isTypingSearch`
marks a keyboard-driven search: while it is set, `SearchComponent.onPreActivate` does not focus
the field and the handler owns the keys, including backspace and enter. Tapping the search bar
clears the flag and hands over to the normal editable field.
`SearchBar` keeps a `TextFieldValue` instead of a `String` so that changing the text from the
outside moves the cursor to the end. Without that, the next typed character landed in front of the
text.
The search is also cleared whenever the launcher is left, even for a short app switch — upstream
keeps it for returns within five seconds, which leaves a stale query on screen.
## Building
JAVA_HOME=~/jdk21 ANDROID_HOME=~/android-sdk \
./gradlew :app:app:assembleDefaultRelease
Result: `app/app/build/outputs/apk/default/release/app-default-release.apk`, applicationId
`de.mm20.launcher2.release`.
The `release` build type signs with the `local` signing config, which reads `keystore.properties`
in the repository root (git-ignored) or the `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS` and
`KEY_PASSWORD` environment variables. The desktop key is
`~/android-keystores/kvaesitso-release.jks`; the phone has its own copy under
`~/android-keystores/` in the Termux home. Termux itself has no Android SDK, so build there only
after installing one (or build on the desktop and copy the APK over).
## Releasing (the phone updates through Obtanium)
1. Raise `versionCode` above the installed one and set a new `versionName` in
`app/app/build.gradle.kts` (`versionName` may keep the upstream version, e.g. `1.40.2-typing.2`).
2. Build the release APK (see above) and check the signature: `apksigner verify --verbose`.
3. Tag it and push `main` and the tag to `origin` (Gitea needs a token: the desktop keeps it in
`~/.config/gitea/token`).
4. Create a Gitea release for that tag and attach the APK as `Kvaesitso-v<versionName>-Signed.apk`.
The repository is public so Obtanium can read it anonymously.
5. The phone's Obtanium entry for `de.mm20.launcher2.release` points at this Gitea repo with
`overrideSource: "Codeberg"`, `versionDetection: true` and `apkFilterRegEx: "Kvaesitso-v"`. It
picks the release up on the next check. The config file lives on the phone at
`/sdcard/Android/data/dev.imranr.obtainium.fdroid/files/app_data/de.mm20.launcher2.release.json`
(force-stop Obtanium before editing it as root).
## Installing on the phone by hand
The phone already runs this build. The official build is signed with the upstream key, so it cannot
be updated in place and Obtanium cannot install over it either. The procedure is: back up
`/data/user/0/de.mm20.launcher2.release` with root, `pm uninstall` (a `-k` uninstall keeps the
package record *and* its signature, so the next install then fails with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`), install this APK, then copy the data back — `chown` to the
new app uid and `chcon` the label that installd created for the fresh data directory. The rest of
the phone (bootloader, Magisk, microG, rescue steps) is documented in `~/titan2-elite/CLAUDE.md` on
the desktop.

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() ?: 2026091000
versionName = "1.40.2-typing.1"
versionCode = System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull() ?: 2026091003
versionName = "1.40.2-typing.4"
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

@@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -63,6 +64,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 +81,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 +991,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) }
@@ -1071,14 +1075,25 @@ internal fun LauncherScaffold(
// Open the search when the user starts typing on a physical keyboard. The handler is
// installed on the activity, so it also receives key events while no text field is focused.
val typeToSearchHandler = rememberTypeToSearchHandler(state, searchVM)
DisposableEffect(typeToSearchHandler) {
val launcherActivity = activity as? SharedLauncherActivity
launcherActivity?.launcherKeyEventHandler = typeToSearchHandler
//
// The scaffold state is recreated when one of its keys changes - the window size after a
// rotation, or the state that rememberSaveable restores after the activity was recreated -
// so the handler must not keep a reference to one particular instance: it reads the current
// state through this holder, which the composition keeps up to date, and the activity's
// handler is refreshed on every composition. Without that, the handler can end up opening
// the search on a state that is no longer the one that is rendered, which looks as if the
// key press did nothing.
val currentState = remember { mutableStateOf(state) }
SideEffect {
if (currentState.value !== state) currentState.value = state
}
val typeToSearchHandler = rememberTypeToSearchHandler({ currentState.value }, searchVM)
SideEffect {
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = typeToSearchHandler
}
DisposableEffect(Unit) {
onDispose {
if (launcherActivity?.launcherKeyEventHandler === typeToSearchHandler) {
launcherActivity.launcherKeyEventHandler = null
}
(activity as? SharedLauncherActivity)?.launcherKeyEventHandler = null
}
}
@@ -1132,12 +1147,31 @@ internal fun LauncherScaffold(
} else {
activity.onBackPressedDispatcher.onBackPressed()
}
} else if (activity.pauseTime > 0L && System.currentTimeMillis() - activity.pauseTime > 5000L) {
} else if (activity.pauseTime > 0L && (System.currentTimeMillis() - activity.pauseTime > 5000L || state.currentComponent is SearchComponent)) {
// 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()
@@ -1407,8 +1441,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

@@ -115,5 +115,8 @@ internal class SearchComponent(
override fun onPreDismiss(state: LauncherScaffoldState) {
super.onPreDismiss(state)
state.isSearchBarFocused = false
// Leaving the search page ends the keyboard driven search, so that opening the search
// again focuses the text field as usual.
state.isTypingSearch = false
}
}

View File

@@ -58,6 +58,9 @@ internal fun KeyEvent.isEnter(): Boolean {
* [SharedLauncherActivity.launcherKeyEventHandler]. It is called for every key event before the
* event is dispatched to the view hierarchy, so it also works while no text field has focus.
*
* The scaffold state the handler works on is passed as a provider rather than as a value, because
* the state is replaced when the window size changes or the launcher is recreated.
*
* While the search is driven by the physical keyboard, the search bar's text field is deliberately
* left unfocused: a focused text field would make the system show the on-screen keyboard, and the
* user is typing on a physical keyboard anyway. The handler therefore inserts and deletes the
@@ -68,7 +71,7 @@ internal fun KeyEvent.isEnter(): Boolean {
*/
@Composable
internal fun rememberTypeToSearchHandler(
state: LauncherScaffoldState,
stateProvider: () -> LauncherScaffoldState,
searchVM: SearchVM,
): (KeyEvent) -> Boolean {
val scope = rememberCoroutineScope()
@@ -76,8 +79,12 @@ internal fun rememberTypeToSearchHandler(
val sheetManager = LocalBottomSheetManager.current
val overlayManager = LocalOverlayManager.current
return remember(state, searchVM, activity, sheetManager, overlayManager) {
// Deliberately not keyed on the scaffold state: the state is recreated when the window is
// resized or the launcher is recreated, and a key must always be handled by the state that is
// rendered at that moment. stateProvider hands out that state.
return remember(searchVM, activity, sheetManager, overlayManager) {
fun handle(event: KeyEvent): Boolean {
val state = stateProvider()
// Read the preference here rather than capturing it in the composition, so that the
// current value is always used.
if (!searchVM.searchOnTyping.value || state.isLocked) return false

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)