Compare commits
6 Commits
v1.40.2-ty
...
e004862b00
| Author | SHA1 | Date | |
|---|---|---|---|
| e004862b00 | |||
| ff3cab9531 | |||
| 79f229de6d | |||
| 73c3683740 | |||
| e17903f40f | |||
| 75f869c094 |
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal 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.
|
||||
@@ -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() ?: 2026091002
|
||||
versionName = "1.40.2-typing.3"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -1132,12 +1135,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 +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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user