← Back to Bloom

How Bloom works

Last updated:

Bloom turns a long-press on a floating button into a Voronoi tessellation of your most-used shortcuts. This page walks through the pieces end to end, from seeding math to the Samsung workaround.

1. The layout pipeline

The shortcut bloom is computed in three passes, each building on the last:

  1. Phyllotaxis seed generation — place N points on the golden-angle spiral
  2. Force packing — 200 iterations of attraction + repulsion to fill space without overlap
  3. Delaunay → Voronoi — convert point positions into a cell tessellation

1.1 Phyllotaxis seeding

Sunflowers pack seeds in a spiral where each new seed is offset by the golden angle (137.5077…°). This is the tightest known packing for points on a disk without lattice repetition — every radial and every angular slice has a different density profile, so the eye never locks onto a grid pattern. Bloom uses the exact same rule:

angle[i]    = i × 137.50776405°
distance[i] = scaleFactor × √i
x[i]        = centerX + distance[i] × cos(angle[i])
y[i]        = centerY + distance[i] × sin(angle[i])

scaleFactor is tuned to 38 dp in the shipping app (down from 52 dp in the first prototype — reducing dead space between seeds).

1.2 Force packing

Raw phyllotaxis positions are a good initial distribution, but some cells sit too close and some too far. A 200-iteration physics pass nudges them into a tighter blob:

The final positions are cached. Bloom only recomputes when the user changes the sticker count or layout settings.

1.3 Voronoi tessellation

Once positions are fixed, a Delaunay triangulation is computed in-memory and inverted into a Voronoi diagram. Each cell polygon is drawn with Canvas.drawPath(). The entire bloom area is clipped to a circular mask with Canvas.clipPath(), so the outer cells appear to "terminate at the bloom edge".

Touch targeting. Hit-testing is not point-in-polygon. Instead, for every tap coordinate, Bloom finds the nearest seed via squared-distance comparison. That is the Voronoi definition, so the result is identical but the lookup is O(N) with no polygon math. No dead gaps between icons.

2. The two-window overlay pattern

A naive overlay uses a single WindowManager window with WRAP_CONTENT bounds that resize as the bloom opens. This is what most floating-launcher apps do — and it runs into a Samsung One UI bug.

2.1 The Samsung bug

On One UI 5.x through 7.0, when an accessibility overlay window changes size mid-animation, the compositor inserts a 300 ms fade on the entire window that cannot be suppressed via windowAnimations = 0 or layoutAnimation = null. This causes Bloom's petals to flash in and out during open/close animations. It is a reproducible regression filed against Samsung internally; fixes have not shipped as of April 2026.

2.2 The workaround

Bloom splits the overlay into two coordinated windows:

WindowSizeFlagsResponsibility
VisualWindow Full screen, fixed FLAG_NOT_TOUCHABLE Draws Voronoi cells + icons. Size never changes, so One UI never animates it.
TouchWindow Dynamic, matches bloom content Transparent, no background Captures touches. Resizes as the bloom opens, but has nothing visual to animate.

An OnLayoutChangeListener on the content view keeps the TouchWindow's bounds synchronized with the visible Voronoi region. The user sees one unified bloom; under the hood, the animation runs in the visual layer while the touch layer silently follows.

3. Accessibility service configuration

Bloom's accessibility_service_config.xml is configured for minimum privilege:

<accessibility-service
    android:accessibilityEventTypes="typeWindowStateChanged"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:canPerformGestures="false"
    android:canRetrieveWindowContent="false"
    android:description="@string/accessibility_service_description"
    android:notificationTimeout="100"
    android:settingsActivity="app.nlap.bloom.ui.SettingsActivity" />

This matters for Google Play review. Starting 2026-01-28, Android introduces App Protection Mode that revokes AccessibilityService privileges from apps that declare overly broad flags without a legitimate accessibility use case. Bloom declares only what it uses and documents why.

4. The stickers

A sticker is the smallest unit of user-facing functionality. Each sticker is a tuple of (id, icon, label, action). Categories:

4.1 System toggles

Flashlight (CameraManager.setTorchMode), auto-rotate (Settings.System.ACCELEROMETER_ROTATION), DND (NotificationManager.setInterruptionFilter), brightness slider, volume slider, Wi-Fi / Bluetooth (via settings-panel intent on Android 10+).

Flashlight includes a CameraManager.TorchCallback so that external apps toggling the torch (e.g., a widget, or the system quick settings) update Bloom's sticker state. No polling, no drift.

4.2 Navigation

Home, back, recents, last-app — all via performGlobalAction (GLOBAL_ACTION_HOME etc.). Last-app is implemented as two quick consecutive GLOBAL_ACTION_RECENTS fires (Android does not expose a direct API).

4.3 Utility

Screenshot (GLOBAL_ACTION_TAKE_SCREENSHOT), lock screen (GLOBAL_ACTION_LOCK_SCREEN), notification panel, quick settings panel.

4.4 AI bridge (no server cost)

The AI bridge is the only "intelligent" sticker category, and Bloom runs it entirely on-device. Nothing leaves your phone unless you send it to an AI app:

Bloom never calls an AI API of its own. This keeps the app free to run (no OpenAI bill, no Anthropic bill) and keeps your data on-device. You choose which AI to invoke, per-query, via the share sheet.

4.5 Custom stickers

User-defined shortcuts: any installed app, any deep link (Intent URI), any URL. Configured in Settings → Sticker editor.

5. PRO system

Bloom's floating overlay requires an active PRO pass to launch. The rule is simple:

PRO active  = System.currentTimeMillis() < pro_expiry_timestamp
Activate    = pro_expiry_timestamp = now + 24 hours

Watching one full rewarded AdMob video activates 24 hours of PRO. No subscription, no in-app purchase, no account. Wall-clock based — Bloom intentionally does not use SystemClock.elapsedRealtime() defense against clock rollback, because the attack payoff (skipping one ad) is not worth the complexity or the false positives when a user legitimately changes time zones.

6. GDPR / UMP consent

In the EEA, UK, and Switzerland, Bloom uses Google's User Messaging Platform SDK to collect GDPR consent before any AdMob request fires. AdConsentManager wraps the UMP APIs; the consent form appears on the first app launch and can be re-opened from Settings → Manage privacy preferences. Outside of GDPR jurisdictions, the "Manage privacy preferences" option is hidden entirely.

All ad loaders (banner, rewarded) gate on ConsentInformation.canRequestAds(). If consent hasn't been obtained or was denied, no ad request is made — the user still sees Bloom, just without ads, and rewarded video is replaced by an equivalent local grant (to keep the feature accessible).

7. Tech stack summary

ComponentTech
LanguageKotlin
UI frameworkAndroid Views (not Compose)
Min / target SDK35 / 35 (Android 15)
OverlayAccessibilityService + WindowManager
OCRGoogle ML Kit TextRecognition (on-device)
AdsGoogle AdMob (banner + rewarded)
GDPRGoogle User Messaging Platform (UMP) 4.0.0
Crash reportingSentry (anonymous)
PersistenceSharedPreferences (local only)
BuildGradle 8.11.1, AGP 8.7.3

8. Open questions and roadmap

Bloom ships a camera-sticker roadmap for later phases: magnifier, color picker, live translate, business-card scanner, front-camera mirror, QR code, live OCR, ML Kit Document Scanner. CAMERA permission is not declared today — it will be declared the moment the first camera sticker ships, with a Prominent Disclosure screen and an updated Play Console Data Safety declaration.

The gesture-navigation flicker on Android 15 launcher transitions remains an OS-level limitation. Bloom detects it and degrades gracefully; a full fix requires changes Google has not exposed to third-party apps.