neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Attach the feed window, and prove it without a launcher
11d747a · Gabriel Morell · 2026-09-02 21:19
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