mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138d9d63ab |
+11
-1
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline selection
|
||||
## Inverted timeline scrolling
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
@@ -183,6 +183,16 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of
|
||||
moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling
|
||||
inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
./gradlew :paseo-scroll:connectedDebugAndroidTest \
|
||||
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.bridge.JavaOnlyArray
|
||||
import com.facebook.react.bridge.JavaOnlyMap
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||
import com.facebook.react.uimanager.ReactStylesDiffMap
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
class NestedScrollActivity : Activity()
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NestedScrollTest {
|
||||
@Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child ->
|
||||
val before = parent.scrollY
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 600)
|
||||
assertEquals(600, child.scrollY)
|
||||
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||
assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY)
|
||||
child.scrollTo(0, 0)
|
||||
assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null))
|
||||
assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
|
||||
@Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child ->
|
||||
val before = parent.scrollY
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 600)
|
||||
assertEquals(600, child.scrollY)
|
||||
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||
assertEquals(before + 100, parent.scrollY)
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
|
||||
@Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child ->
|
||||
val before = parent.scrollY
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 100)
|
||||
assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null))
|
||||
assertEquals(100, child.scrollY)
|
||||
assertEquals(before, parent.scrollY)
|
||||
child.stopNestedScroll()
|
||||
parent.scrollTo(0, before + 100)
|
||||
assertEquals(before + 100, parent.scrollY)
|
||||
}
|
||||
|
||||
@Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true)
|
||||
|
||||
@Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false)
|
||||
|
||||
private fun checkFling(inverted: Boolean) = withNestedScroll(
|
||||
inverted = inverted,
|
||||
afterFrames = { parent ->
|
||||
if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY)
|
||||
else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400)
|
||||
}
|
||||
) { parent, child ->
|
||||
parent.scrollTo(0, 400)
|
||||
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||
child.scrollTo(0, 600)
|
||||
assertEquals(600, child.scrollY)
|
||||
assertTrue(child.dispatchNestedFling(0f, 1500f, false))
|
||||
child.stopNestedScroll()
|
||||
}
|
||||
|
||||
private fun withNestedScroll(
|
||||
inverted: Boolean,
|
||||
afterFrames: ((ReactScrollView) -> Unit)? = null,
|
||||
check: (ReactScrollView, ReactScrollView) -> Unit
|
||||
) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val activity = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
lateinit var parent: ReactScrollView
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = ScrollTestContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
parent = manager.createViewInstance(context)
|
||||
val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true }
|
||||
val panelContent = View(context)
|
||||
child.addView(panelContent, FrameLayout.LayoutParams(300, 800))
|
||||
val content = FrameLayout(context)
|
||||
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 })
|
||||
parent.addView(content, FrameLayout.LayoutParams(300, 2000))
|
||||
panelContent.measure(exact(300), exact(800))
|
||||
panelContent.layout(0, 0, 300, 800)
|
||||
child.measure(exact(300), exact(200))
|
||||
child.layout(0, 500, 300, 700)
|
||||
content.measure(exact(300), exact(2000))
|
||||
content.layout(0, 0, 300, 2000)
|
||||
parent.measure(exact(300), exact(400))
|
||||
parent.layout(0, 0, 300, 400)
|
||||
// The same transform React Native applies to an inverted FlatList on Android.
|
||||
manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of(
|
||||
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0))
|
||||
)))
|
||||
activity.setContentView(parent, ViewGroup.LayoutParams(300, 400))
|
||||
parent.scrollTo(0, 400)
|
||||
check(parent, child)
|
||||
}
|
||||
if (afterFrames != null) {
|
||||
// Let the framework's real fling animation advance, with an ordinary-parent control.
|
||||
SystemClock.sleep(500)
|
||||
instrumentation.runOnMainSync { afterFrames(parent) }
|
||||
}
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { activity.finish() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||
}
|
||||
+1
-2
@@ -8,7 +8,6 @@ import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.bridge.BridgeReactContext
|
||||
import com.facebook.react.bridge.JavaOnlyArray
|
||||
import com.facebook.react.bridge.JavaOnlyMap
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
@@ -70,7 +69,7 @@ class ScrollSelectionTest {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = BridgeReactContext(appContext)
|
||||
val reactContext = ScrollTestContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -2,6 +2,7 @@ package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
@@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||
private val transformValues = FloatArray(9)
|
||||
|
||||
private val isInverted: Boolean
|
||||
get() {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
return transformValues[Matrix.MSCALE_Y] < 0
|
||||
}
|
||||
|
||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||
// movement to user gestures and explicit scroll commands.
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
||||
if (isInverted) return 0
|
||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
|
||||
// A nested panel's remaining delta uses its own visual direction. Consuming it in
|
||||
// an inverted timeline moves the timeline the opposite way when the panel hits an edge.
|
||||
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
|
||||
if (isInverted) return
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
|
||||
}
|
||||
|
||||
override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
|
||||
if (isInverted && !consumed) return true
|
||||
return super.onNestedFling(target, velocityX, velocityY, consumed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,84 +250,6 @@ describe("cursor usage source", () => {
|
||||
expect(cursor.status).toBe("available");
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["modern state.vscdb", "v1"],
|
||||
["modern state.vscdb", "v99"],
|
||||
["legacy cursorAuthStatus", "v1"],
|
||||
["auth.json", "v1"],
|
||||
["CURSOR_ACCESS_TOKEN", "v1"],
|
||||
["CURSOR_TOKEN", "v1"],
|
||||
])("encrypted %s (%s)", (store, version) => {
|
||||
beforeEach(() => {
|
||||
const encrypted = `cursor-safe-storage:${version}:${Buffer.from("ciphertext").toString("base64")}`;
|
||||
if (store === "modern state.vscdb") {
|
||||
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": encrypted });
|
||||
} else if (store === "legacy cursorAuthStatus") {
|
||||
writeCursorStateDb(homeDir, {
|
||||
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: encrypted }), "utf8"),
|
||||
});
|
||||
} else if (store === "auth.json") {
|
||||
writeCursorAuthJson(homeDir, encrypted);
|
||||
} else {
|
||||
process.env[store] = encrypted;
|
||||
}
|
||||
});
|
||||
|
||||
it("discovers no account when the only credential is encrypted", async () => {
|
||||
expect(await discover()).toEqual([]);
|
||||
});
|
||||
|
||||
it("never sends an encrypted bearer token when fetching from a stored locator", async () => {
|
||||
let input: Parameters<typeof fetchUsage>[0];
|
||||
if (store === "modern state.vscdb" || store === "legacy cursorAuthStatus") {
|
||||
input = {
|
||||
store: "sqlite",
|
||||
locator: join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
|
||||
};
|
||||
} else if (store === "auth.json") {
|
||||
input = { store: "file", locator: join(homeDir, ".config", "cursor", "auth.json") };
|
||||
} else {
|
||||
input = { store: "env", locator: store };
|
||||
}
|
||||
const requests: RequestInit[] = [];
|
||||
fetchApi = async (_url, init) => {
|
||||
requests.push(init ?? {});
|
||||
return new Response(null, { status: 401 });
|
||||
};
|
||||
|
||||
await expect(fetchUsage(input, fetchApi)).rejects.toThrow(
|
||||
"Cursor login store no longer exists",
|
||||
);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers and uses auth.json after skipping an encrypted desktop token", async () => {
|
||||
writeCursorStateDb(homeDir, {
|
||||
"cursorAuth/accessToken": `cursor-safe-storage:v1:${Buffer.from("ciphertext").toString("base64")}`,
|
||||
});
|
||||
writeCursorAuthJson(homeDir, "cursor_cli_jwt");
|
||||
expect(await discover()).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
||||
input: { store: "file", locator: join(homeDir, ".config", "cursor", "auth.json") },
|
||||
},
|
||||
]);
|
||||
const authorizations: string[] = [];
|
||||
fetchApi = async (_url, init) => {
|
||||
authorizations.push(new Headers(init?.headers).get("Authorization") ?? "");
|
||||
return jsonResponse({
|
||||
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
|
||||
billingCycleStart: null,
|
||||
billingCycleEnd: null,
|
||||
});
|
||||
};
|
||||
|
||||
const cursor = findProvider(await service().listUsage(), "cursor");
|
||||
expect(authorizations).toEqual(["Bearer cursor_cli_jwt"]);
|
||||
expect(cursor.status).toBe("available");
|
||||
});
|
||||
|
||||
it("omits unreadable state.vscdb", async () => {
|
||||
const directory = join(homeDir, ".config", "Cursor", "User", "globalStorage");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
|
||||
@@ -27,11 +27,9 @@ function toIsoStringOrNull(timestampMs: number): string | null {
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). The modern
|
||||
// `cursorAuth/accessToken` value is a plain JWT or encrypted at rest. Any
|
||||
// `cursor-safe-storage:` value (any version) is Electron safeStorage ciphertext
|
||||
// and is skipped. Older builds kept a JSON blob under `cursorAuthStatus`.
|
||||
// Read it with node:sqlite so we don't
|
||||
// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep
|
||||
// the access token as a plain JWT string under `cursorAuth/accessToken`; older builds
|
||||
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
|
||||
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
|
||||
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
|
||||
// Headless hosts (VPS, cursor-agent only) have no desktop db; their session lives in
|
||||
@@ -108,21 +106,15 @@ function readItemTableValue(db: CursorStateDatabase, key: string): string | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function usableCursorToken(value: string | null | undefined): string | null {
|
||||
const token = value?.trim();
|
||||
// Electron safeStorage ciphertext cannot authenticate an API request.
|
||||
return token && !token.startsWith("cursor-safe-storage:") ? token : null;
|
||||
}
|
||||
|
||||
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
|
||||
const modern = usableCursorToken(readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY));
|
||||
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
|
||||
if (modern) return modern;
|
||||
|
||||
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
|
||||
if (legacy) {
|
||||
try {
|
||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
|
||||
return usableCursorToken(parsed.accessToken);
|
||||
if (parsed.accessToken) return parsed.accessToken;
|
||||
} catch {
|
||||
// ignore a malformed legacy blob
|
||||
}
|
||||
@@ -157,7 +149,7 @@ async function readCursorTokenFromAuthJson(path: string): Promise<string | null>
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
return usableCursorToken(parsed.accessToken);
|
||||
return parsed.accessToken?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -238,7 +230,7 @@ export async function fetchUsage(
|
||||
}
|
||||
|
||||
async function readToken(input: UsageInput): Promise<string | undefined> {
|
||||
if (input.store === "env") return usableCursorToken(process.env[input.locator]) ?? undefined;
|
||||
if (input.store === "env") return process.env[input.locator];
|
||||
const token =
|
||||
input.store === "sqlite"
|
||||
? await readCursorTokenFromSqlite(input.locator)
|
||||
|
||||
Reference in New Issue
Block a user