diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec65e3d643..f2ca13ba7d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,21 @@ [versions] -agp = "9.4.1" -kotlin = "2.4.20" -okhttp = "5.5.0" +agp = "9.5.0" +kotlin = "2.4.21" +okhttp = "5.5.1" ktfmt = "0.27.0" coroutines = "1.11.0" lifecycle = "2.11.0" coil = "3.6.3" # Material 3 Expressive is not in any stable material3 release; the expressive -# APIs graduate through the 1.5.0 alpha line. Pinned explicitly OVER the Compose -# BOM, which resolves material3 to 1.4.0. -m3 = "1.5.0-alpha29" -nav3 = "1.2.0" -navigationevent = "1.1.2" +# APIs graduate through the 1.5.0 line, which has reached rc. Pinned explicitly +# OVER the Compose BOM, which resolves material3 to 1.4.0. +# +# This pin also decides compose-ui and compose-foundation: material3 names its +# own minimum for them and Gradle takes whichever is higher, the BOM's or this +# one's. Check what a material3 bump brings with it. +m3 = "1.5.0-rc01" +nav3 = "1.2.1" +navigationevent = "1.2.0" # Governs every androidx.compose.* artifact below; none of them pin a version. compose-bom = "2026.09.00" @@ -32,7 +36,7 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.13.0" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" } # Needed to make the in-app browser honour the app's own light/dark choice. -androidx-webkit = { group = "androidx.webkit", name = "webkit", version = "1.17.1" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version = "1.18.0" } # Compose. The BOM governs every artifact here — do not pin these individually, # or BOM alignment is silently defeated. @@ -57,7 +61,7 @@ androidx-navigationevent-compose = { group = "androidx.navigationevent", name = coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.12.0" } rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version = "2.0.1" } @@ -67,7 +71,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-dnsoverhttps = { group = "com.squareup.okhttp3", name = "okhttp-dnsoverhttps", version.ref = "okhttp" } agp-apksig = { group = "com.android.tools.build", name = "apksig", version.ref = "agp" } -gson = { module = "com.google.code.gson:gson", version = "2.14.0" } +gson = { module = "com.google.code.gson:gson", version = "2.15.0" } # The libxposed API sources vendored under xposed/libxposed and services/libxposed carry # @SinceApi and @InternalApi from API 102 onwards. Both are CLASS-retained metadata with no # runtime behaviour, so this is compileOnly everywhere and never reaches a device. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef..9fa7f28828 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9b7cb4af38..91c4fc64aa 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index a51ec4f588..3185a43f78 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/ContextClick.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/ContextClick.kt index 8510ea724d..2c06366f4e 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/ContextClick.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/ContextClick.kt @@ -1,5 +1,8 @@ package org.matrix.vector.ui +import android.app.Activity +import android.view.MotionEvent +import android.view.Window import androidx.compose.foundation.combinedClickable import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass @@ -7,36 +10,117 @@ import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.pointerInput /** - * A row's whole gesture set: tap it, hold it — and, from a mouse or a touchpad, right-click it. + * A row's whole gesture set: tap it, hold it, right-click it. * - * A tablet with a keyboard case is a pointer device, and the way to ask a row for its menu with a - * pointer is the secondary button: a right click, or a two-finger tap on the touchpad. Compose has - * no notion of that gesture. `combinedClickable` reads a press from any button as the beginning of - * an ordinary tap and reaches [onLongClick] only by timing a held finger, so on a touchpad every - * row here answered a right click by doing whatever a left click does, and whatever the hold was - * for could not be reached at all. The View-based manager never had to say any of this: a `View` - * opens its registered context menu on a secondary press by itself, which is why the gesture worked - * before the manager was written in Compose. - * - * So the secondary press is caught here and handed to [onLongClick] — the same action, because the - * two gestures mean the same thing, one asked with a finger and one asked with a pointer. + * The secondary button means what the hold means, so it runs onLongClick: a pointer asks a row for + * its menu with a right click or a two-finger tap on a touchpad. Compose reads a press from any + * button as the start of an ordinary tap, so the button is recognised here instead — from the + * pointer event where Compose reports it, and from the window's own events otherwise, which is what + * watchSecondaryButton is for. Which row the pointer is over stays Compose's answer. */ fun Modifier.contextClickable(onClick: () -> Unit, onLongClick: (() -> Unit)? = null): Modifier = - combinedClickable(onClick = onClick, onLongClick = onLongClick) + combinedClickable( + onClick = { + // A click made with the secondary button belongs to the menu, not to the row. + if (onLongClick != null && SecondaryButton.claim()) onLongClick() else onClick() + }, + onLongClick = onLongClick, + ) .then(if (onLongClick == null) Modifier else Modifier.onSecondaryPress(onLongClick)) /** - * The secondary button's press, taken before anything underneath can read it as a tap. + * Watch this activity's window for the secondary button, for as long as it lives. + * + * Call it once per host that draws rows. It wraps the window's callback to read the button off + * every event and pass all of them on untouched, answering and consuming nothing. A dialog or a + * popup is its own window, so rows inside one are covered by the pointer pass alone. + */ +fun Activity.watchSecondaryButton() { + // A window with no callback is allowed, and there is nothing to wrap then. + val inner = window?.callback ?: return + window.callback = SecondaryButtonCallback(inner) +} + +/** + * The window's callback, wrapped to read the button off the events it passes on. * - * On [Initial][PointerEventPass.Initial], the pass that runs before the one `combinedClickable` - * listens on — the same way [org.matrix.vector.ui.navigation.PanelBar] gets ahead of the item it - * wraps — and every event of the press is consumed, which is what keeps the click from landing: - * foundation asks for a press nothing else has taken, and for a release nothing else has taken. + * Both dispatch methods, because a press arrives on both paths: in the touch stream carrying the + * button state, and as its own ACTION_BUTTON_PRESS among the generic motion events. The reading + * happens on both sides of the dispatch, since Compose fires the click inside it and the gesture + * ends with the same event. + */ +private class SecondaryButtonCallback(private val inner: Window.Callback) : + Window.Callback by inner { + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + SecondaryButton.arriving(event) + val handled = inner.dispatchTouchEvent(event) + SecondaryButton.left(event) + return handled + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + SecondaryButton.arriving(event) + val handled = inner.dispatchGenericMotionEvent(event) + SecondaryButton.left(event) + return handled + } +} + +/** + * Whether the pointer's secondary button is down, as the platform last said. + * + * One flag for one pointer. Any event carrying or naming the button sets it, and it is let go of + * three ways: claimed by the click made with it, cleared when that gesture ends, and cleared by the + * next press that does not carry it. + */ +private object SecondaryButton { + + private var held = false + + /** Before the hierarchy sees the event, so a click inside the dispatch reads the truth. */ + fun arriving(event: MotionEvent) { + when { + event.carriesSecondaryButton -> held = true + event.actionMasked == MotionEvent.ACTION_DOWN -> held = false + } + } + + /** After it, because the click lands inside the dispatch and the gesture ends with it. */ + fun left(event: MotionEvent) { + if ( + event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + held = false + } + } + + /** The answer, taken rather than read: one press is one context click. */ + fun claim(): Boolean = held.also { held = false } +} + +/** + * Whether this event has the secondary button on it. + * + * buttonState is which buttons are held as of the event. actionButton is the one an + * ACTION_BUTTON_PRESS or ACTION_BUTTON_RELEASE announces, and is no button at all on every other + * action, so the action itself needs no asking about. A stylus counts, its barrel button standing + * in for the right one. + */ +private val MotionEvent.carriesSecondaryButton: Boolean + get() { + val secondary = MotionEvent.BUTTON_SECONDARY or MotionEvent.BUTTON_STYLUS_SECONDARY + return buttonState and secondary != 0 || actionButton and secondary != 0 + } + +/** + * The secondary press, taken before anything underneath can read it as a tap. * - * The action runs on the way down rather than on the release, matching the platform: a right click - * on a `View` opens its context menu the moment the button goes down. It runs on the first event - * that reports the button held rather than on the press event alone, because a pointer that - * announces its buttons a moment after it announces the touch would otherwise be missed. + * On the Initial pass, which runs before the one combinedClickable listens on, and every event of + * the press is consumed: foundation asks for a press nothing else has taken. The action runs on the + * way down, on the first event that reports the button held, so a pointer that announces its + * buttons a moment after its touch is not missed. */ private fun Modifier.onSecondaryPress(action: () -> Unit): Modifier = pointerInput(action) { diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt index ccba13ef40..32f9e4c54a 100644 --- a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.demo -import kotlinx.coroutines.launch -import androidx.lifecycle.lifecycleScope import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler @@ -28,11 +26,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import org.matrix.vector.ipc.IManagerService import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ui.VectorApp import org.matrix.vector.manager.ui.theme.LocalizedContent import org.matrix.vector.manager.ui.theme.VectorTheme +import org.matrix.vector.ui.watchSecondaryButton /** * The way in to the scripted states, and the reason this whole thing is a separate source set. @@ -45,12 +46,12 @@ import org.matrix.vector.manager.ui.theme.VectorTheme * `org.matrix.vector.manager.demo` in a release APK's classes and finding nothing. * * It hosts the app itself rather than launching MainActivity, and that is not a stylistic choice. - * The first version did launch it, and every scenario silently did nothing: `ParasiticManagerHooker` - * intercepts the manager activity starting and hands the *real* binder to `Constants.setBinder`, - * overwriting whatever was bound a moment earlier. Even "no daemon at all" came up reporting a - * healthy framework — the failure mode a test harness can least afford, since it looks like a pass. - * Rendering VectorApp here means no manager activity is ever launched, so nothing re-binds behind - * us. + * The first version did launch it, and every scenario silently did nothing: + * `ParasiticManagerHooker` intercepts the manager activity starting and hands the *real* binder to + * `Constants.setBinder`, overwriting whatever was bound a moment earlier. Even "no daemon at all" + * came up reporting a healthy framework — the failure mode a test harness can least afford, since + * it looks like a pass. Rendering VectorApp here means no manager activity is ever launched, so + * nothing re-binds behind us. */ class DemoActivity : ComponentActivity() { @@ -80,6 +81,8 @@ class DemoActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() ServiceLocator.attach(this) + // The same watch the real host installs. + watchSecondaryButton() lifecycleScope.launch { ServiceLocator.service.collect { current -> @@ -116,8 +119,7 @@ class DemoActivity : ComponentActivity() { pinned = null return scenario } - pinned = - if (!scenario.connected) null else FakeManagerService(scenario, realService) + pinned = if (!scenario.connected) null else FakeManagerService(scenario, realService) pinning = true ServiceLocator.bind(pinned) return scenario @@ -150,7 +152,9 @@ private fun ScenarioList(onPick: (DemoScenario) -> Unit) { ListItem( modifier = Modifier.clickable { onPick(scenario) }, supportingContent = { Text(scenario.summary) }, - ) { Text(scenario.title) } + ) { + Text(scenario.title) + } } } } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt index 94cb565652..d9dd9851f4 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt @@ -15,6 +15,7 @@ import org.matrix.vector.manager.ui.theme.LocalizedContent import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.manager.ui.theme.VectorTheme import org.matrix.vector.ui.LocalDialogLocalizer +import org.matrix.vector.ui.watchSecondaryButton /** * The only activity. @@ -69,6 +70,9 @@ class MainActivity : ComponentActivity() { // offers a destination the reader may have left behind hours ago. DeepLink.offerFromCreate(intent) + // What lets a row tell a right click from a tap. + watchSecondaryButton() + setContent { LocalizedContent { // Installed once, around everything. A dialog or a sheet is its own window, and