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.
The shortcut bloom is computed in three passes, each building on the last:
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).
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:
force = (center − pos) × 0.025 × decaydecay = 1 − iteration / maxIterations, so the system anneals toward a stable configurationThe final positions are cached. Bloom only recomputes when the user changes the sticker count or layout settings.
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.
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.
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.
Bloom splits the overlay into two coordinated windows:
| Window | Size | Flags | Responsibility |
|---|---|---|---|
| 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.
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" />
canRetrieveWindowContent="false" — Bloom cannot read what other apps are showing. This is enforced by Android; the permission isn't granted.canPerformGestures="false" — Bloom never calls dispatchGesture(). It only fires global actions via performGlobalAction().flagRequestFilterKeyEvents, no flagRequestEnhancedWebAccessibility, no typeAllMask — only the single event type it actually needs.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.
A sticker is the smallest unit of user-facing functionality. Each sticker is a tuple of (id, icon, label, action). Categories:
Flashlight (CameraManager.setTorchMode), auto-rotate (Settings.System.ACCELEROMETER_ROTATION), DND (NotificationListenerService.requestInterruptionFilter via Bloom's listener registered solely for this toggle — single mechanism, no fallback; permission grant required at first use, see Privacy Policy §3), 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.
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).
Screenshot (GLOBAL_ACTION_TAKE_SCREENSHOT), lock screen (GLOBAL_ACTION_LOCK_SCREEN), notification panel, quick settings panel.
Six Vision stickers cover both screen-capture and live-camera flows. All processing is on-device — nothing leaves the device unless the user explicitly taps Share. See Privacy Policy §4 for the full disclosure.
Each of OCR / image share / QR / colour picker shows a 3-mode sheet on tap (current screen / pick from gallery / take a new photo). Magnifier adds the same with a live-camera mode; mirror is camera-only.
Screen capture (AccessibilityService.takeScreenshot) — silent, single-frame, on tap. No MediaProjection consent dialog, no foreground service. Bloom is already an accessibility service, so it uses the Android 11+ takeScreenshot() API directly. FLAG_SECURE windows blocked by the OS itself.
takeScreenshot → Bitmap → ML Kit TextRecognition (Korean recognizer covers Latin too) → result sheet (copy / share).takeScreenshot → CanHub Cropper (drag handles, free aspect ratio, JPEG 95) → Intent.ACTION_SEND via FileProvider.takeScreenshot → ML Kit BarcodeScanning → first match → URL / Email / Phone / Geo intent dispatch.takeScreenshot → centre 30%×30% sub-bitmap → AndroidX Palette dominant swatch → HEX + nearest-of-11 Korean colour name.Gallery (PhotoPicker) — Android system picker, no permission needed. Same post-process pipelines as above.
Live camera (CameraX) — single-shot or live preview:
ProcessCameraProvider + back camera + ImageCapture.takePicture on shutter press → same post-process pipelines.Preview + zoom slider whose range is set dynamically from cameraInfo.zoomState (per-device maxZoomRatio) + freeze-frame.takeScreenshot or PhotoPicker) → custom ZoomableImageView with pinch / drag / double-tap reset.Preview + scaleX = -1f (true mirror) + optional screen-brightness boost (1.0f + FLAG_KEEP_SCREEN_ON), persisted across re-entry.Bloom never calls an AI API of its own. All Vision processing is local: ML Kit (text + barcode), AndroidX Palette, AndroidX CameraX, CanHub Cropper. No OpenAI bill, no Anthropic bill, no server logs. You choose which AI / app to invoke after the fact via the share sheet.
User-defined shortcuts: any installed app, any deep link (Intent URI), any URL. Configured in Settings → Sticker editor.
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.
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).
| Component | Tech |
|---|---|
| Language | Kotlin |
| UI framework | Android Views (not Compose) |
| Min / target SDK | 35 / 35 (Android 15) |
| Overlay | AccessibilityService + WindowManager |
| OCR (roadmap) | Google ML Kit TextRecognition (on-device, planned) |
| Ads | Google AdMob (banner + rewarded) |
| GDPR | Google User Messaging Platform (UMP) 4.0.0 |
| Crash reporting | Sentry 8.39.0 (PII off, 90-day retention, 1h dedup) |
| Persistence | SharedPreferences (local only) |
| Build | Gradle 8.11.1, AGP 8.7.3 |
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.