Attach the feed window, and prove it without a launcher

11d747a · Gabriel Morell · 2026-09-02 21:19

20 files +204 -16
Message
{commit_body(@commit)}

Files changed

modified clients/android/README.md
+21 −9
@@ -86,15 +86,27 @@ before you ever see it. The Connection screen reports this build's hash for
86 86 that reason; it differs per keystore, so a debug build's is not a release
87 87 build's.
88 88
89 −Three ways past it, in the order they are worth trying:
90 −
91 −1. **A debug build of the launcher.** Lawnchair's `BridgeInfo.isSigned()`
92 − returns true unconditionally when `BuildConfig.DEBUG`, so a locally built
93 − Lawnchair accepts any provider. This is the way to test.
94 −2. **Get listed.** One line in Lawnchair's whitelist, package plus hash.
95 −3. **`pref_ignoreFeedWhitelist`.** Real, but it lives in Lawnchair's debug
96 − menu, whose own enable switch lives inside the debug menu -- so there is no
97 − way to reach it from a release build.
89 +There is no way past it from a stock build. `CustomBridgeInfo.isSigned()` is
90 +`ignoreWhitelist || (signatureHash != -1 && super.isSigned())`, and an unlisted
91 +package has `signatureHash == -1` -- so the `BuildConfig.DEBUG` shortcut inside
92 +`super.isSigned()` never applies to one, and a debug build of the launcher is
93 +no help by itself. `pref_ignoreFeedWhitelist` is the only lever, and it lives
94 +in a debug menu whose own enable switch lives inside that debug menu.
95 +
96 +So testing means a launcher built with this package listed. One line, next to
97 +the entry Neo Feed already has:
98 +
99 +```kotlin
100 +whitelist["io.neiam.apolloscrib"] = getSignatureHash(context, "io.neiam.apolloscrib")
101 +```
102 +
103 +`getSignatureHash` reads the hash off whatever is installed, so it matches a
104 +debug build and a release one without either being written down.
105 +
106 +Failing a launcher, `feed/OverlayHarnessActivity` in the debug build does what
107 +a launcher does -- binds the service, hands over its own window token, drives
108 +the scroll. It is how the window attach was got working, and it does not need
109 +anybody's whitelist.
98 110
99 111 ## Building
100 112
added clients/android/app/src/debug/AndroidManifest.xml
+11 −0
@@ -0,0 +1,11 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 + <application>
4 + <!-- A stand-in launcher for testing the feed overlay. Debug only:
5 + this manifest is not merged into a release build. -->
6 + <activity
7 + android:name=".feed.OverlayHarnessActivity"
8 + android:exported="true"
9 + android:label="Overlay harness" />
10 + </application>
11 +</manifest>
added clients/android/app/src/debug/java/io/neiam/apolloscrib/feed/OverlayHarnessActivity.kt
+133 −0
@@ -0,0 +1,133 @@
1 +package io.neiam.apolloscrib.feed
2 +
3 +import android.app.Activity
4 +import android.content.ComponentName
5 +import android.content.Context
6 +import android.content.Intent
7 +import android.content.ServiceConnection
8 +import android.os.Bundle
9 +import android.os.IBinder
10 +import android.util.Log
11 +import android.view.WindowManager
12 +import android.widget.Button
13 +import android.widget.LinearLayout
14 +import android.widget.TextView
15 +import com.google.android.libraries.launcherclient.ILauncherOverlay
16 +import com.google.android.libraries.launcherclient.ILauncherOverlayCallback
17 +
18 +/**
19 + * A stand-in launcher, for testing [FeedOverlayService] without one.
20 + *
21 + * Launchers gate feed providers on a signature whitelist they ship, so an
22 + * unlisted app cannot be selected as the feed at all -- which leaves the
23 + * riskiest part of the overlay, attaching a window to somebody else's token,
24 + * untestable through the real path.
25 + *
26 + * This does what a launcher does: binds the service, hands over its own window
27 + * token, and drives the scroll. Debug-only; it is a test rig, not a feature.
28 + */
29 +class OverlayHarnessActivity : Activity() {
30 +
31 + // Not `overlay`: inside an `apply` on a View that name resolves to
32 + // View.getOverlay(), and the shadowing is silent until something fails to
33 + // compile against the wrong type.
34 + private var client: ILauncherOverlay? = null
35 + private lateinit var status: TextView
36 +
37 + private val callback = object : ILauncherOverlayCallback.Stub() {
38 + override fun overlayScrollChanged(progress: Float) {
39 + Log.d(TAG, "overlayScrollChanged($progress)")
40 + runOnUiThread { status.text = "overlay reported scroll $progress" }
41 + }
42 +
43 + override fun overlayStatusChanged(state: Int) {
44 + Log.d(TAG, "overlayStatusChanged($state)")
45 + runOnUiThread { status.text = "overlay reported status $state" }
46 + }
47 + }
48 +
49 + private val connection = object : ServiceConnection {
50 + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
51 + client = ILauncherOverlay.Stub.asInterface(binder)
52 + Log.d(TAG, "bound: ${client != null}")
53 + attach()
54 + }
55 +
56 + override fun onServiceDisconnected(name: ComponentName?) {
57 + client = null
58 + }
59 + }
60 +
61 + override fun onCreate(savedInstanceState: Bundle?) {
62 + super.onCreate(savedInstanceState)
63 +
64 + status = TextView(this).apply { text = "binding…" }
65 + setContentView(
66 + LinearLayout(this).apply {
67 + orientation = LinearLayout.VERTICAL
68 + setPadding(48, 200, 48, 48)
69 + addView(status)
70 + addView(button("attach") { attach() })
71 + addView(button("scroll 0 → 1") { sweep() })
72 + addView(button("open") { client?.openOverlay(0) })
73 + addView(button("close") { client?.closeOverlay(0) })
74 + addView(button("detach") { client?.windowDetached(false) })
75 + }
76 + )
77 +
78 + bindService(
79 + Intent(this, FeedOverlayService::class.java),
80 + connection,
81 + Context.BIND_AUTO_CREATE
82 + )
83 + }
84 +
85 + private fun button(label: String, onClick: () -> Unit) = Button(this).apply {
86 + text = label
87 + setOnClickListener { runCatching(onClick).onFailure { Log.e(TAG, label, it) } }
88 + }
89 +
90 + /**
91 + * The launcher's half of the handshake: its own window's LayoutParams,
92 + * whose token is what lets the service put a window over this one.
93 + */
94 + private fun attach() {
95 + val overlay = client ?: return
96 + val lp = window.attributes
97 + val bundle = Bundle().apply {
98 + putParcelable("layout_params", lp)
99 + putParcelable("configuration", resources.configuration)
100 + putInt("client_options", 7)
101 + }
102 + Log.d(TAG, "windowAttached2 token=${lp.token}")
103 + overlay.windowAttached2(bundle, callback)
104 + overlay.setActivityState(ACTIVITY_STARTED or ACTIVITY_RESUMED)
105 + }
106 +
107 + /** A drag, as the launcher reports one. */
108 + private fun sweep() {
109 + val overlay = client ?: return
110 + overlay.startScroll()
111 + Thread {
112 + for (step in 0..20) {
113 + overlay.onScroll(step / 20f)
114 + Thread.sleep(16)
115 + }
116 + overlay.endScroll()
117 + }.start()
118 + }
119 +
120 + override fun onDestroy() {
121 + runCatching { client?.windowDetached(false) }
122 + runCatching { unbindService(connection) }
123 + super.onDestroy()
124 + }
125 +
126 + companion object {
127 + private const val TAG = "OverlayHarness"
128 +
129 + // The launcher's own activity-state bits.
130 + private const val ACTIVITY_STARTED = 1
131 + private const val ACTIVITY_RESUMED = 2
132 + }
133 +}
modified clients/android/app/src/main/java/io/neiam/apolloscrib/feed/OverlayPanel.kt
+39 −7
@@ -4,6 +4,7 @@ import android.animation.ValueAnimator
4 4 import android.content.Context
5 5 import android.graphics.PixelFormat
6 6 import android.os.IBinder
7 +import android.util.Log
7 8 import android.view.Gravity
8 9 import android.view.View
9 10 import android.view.WindowManager
@@ -89,19 +90,31 @@ class OverlayPanel(
89 90 windowManager.addView(root, layoutParams(token))
90 91 view = root
91 92 root.translationX = -width.toFloat()
92 − }.onFailure { host.moveTo(Lifecycle.State.CREATED) }
93 + }.onFailure {
94 + // Loudly: a window that will not attach is the whole feature
95 + // failing, and it fails silently otherwise -- the launcher goes on
96 + // scrolling a page that is not there.
97 + Log.e(TAG, "could not attach the overlay window", it)
98 + host.moveTo(Lifecycle.State.CREATED)
99 + }
93 100 }
94 101
95 102 /**
96 − * A child of the launcher's window.
103 + * A window belonging to the launcher's activity.
104 + *
105 + * The token is the whole trick: without it this is a window a service is
106 + * not allowed to add at all, and with it the window manager treats it as
107 + * the launcher's own.
97 108 *
98 − * `TYPE_APPLICATION_PANEL` plus the launcher's token is the whole trick:
99 − * without the token this is a window a service is not allowed to add, and
100 − * with it the window manager treats it as belonging to the launcher.
109 + * `TYPE_APPLICATION` rather than a sub-window type. What a launcher hands
110 + * over is `window.attributes.token`, which is its activity's app token,
111 + * and the sub-window types want the *window* token of a parent window --
112 + * ask for one with the other and the window manager refuses with
113 + * "Attempted to add window with token that is not a window".
101 114 */
102 115 private fun layoutParams(token: IBinder?) = WindowManager.LayoutParams().apply {
103 116 this.token = token
104 − type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
117 + type = WindowManager.LayoutParams.TYPE_APPLICATION
105 118 width = WindowManager.LayoutParams.MATCH_PARENT
106 119 height = WindowManager.LayoutParams.MATCH_PARENT
107 120 format = PixelFormat.TRANSLUCENT
@@ -110,7 +123,9 @@ class OverlayPanel(
110 123 WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED or
111 124 // Not focusable: the launcher keeps the key events, and this page
112 125 // has nothing to type into.
113 − WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
126 + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
127 + // Starts closed, so starts letting touches through to the launcher.
128 + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
114 129 softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
115 130 }
116 131
@@ -177,9 +192,26 @@ class OverlayPanel(
177 192 panel.translationX = -width * (1f - value)
178 193 panel.alpha = value
179 194 }
195 + // The window is full screen whatever the content is doing, so a closed
196 + // panel goes on swallowing every touch meant for the home screen
197 + // unless it is made untouchable. Translating the view off screen does
198 + // not move the window.
199 + setTouchable(value > 0f)
200 + }
201 +
202 + private fun setTouchable(touchable: Boolean) {
203 + val panel = view ?: return
204 + val params = panel.layoutParams as? WindowManager.LayoutParams ?: return
205 + val flag = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
206 + val updated = if (touchable) params.flags and flag.inv() else params.flags or flag
207 + if (updated != params.flags) {
208 + params.flags = updated
209 + runCatching { windowManager.updateViewLayout(panel, params) }
210 + }
180 211 }
181 212
182 213 companion object {
214 + private const val TAG = "OverlayPanel"
183 215 private const val SETTLE_MS = 200L
184 216 }
185 217 }

Parents: 80ad3c2