Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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.
Expand All @@ -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" }

Expand All @@ -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.
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 41 additions & 11 deletions gradlew.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 106 additions & 22 deletions manager-ui/src/main/kotlin/org/matrix/vector/ui/ContextClick.kt
Original file line number Diff line number Diff line change
@@ -1,42 +1,126 @@
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
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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() {

Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading