This update (on top of v2): fixed a real lifecycle-ordering bug where the overlay could render its first frame and then never visibly animate again (see "Lifecycle fix" below), improved the walking animation to a proper footstep cadence with a deliberate pivot-turn, and added a yawn-before-sleep animation. Nothing else changed — no new architecture, no new permissions, no new dependencies.
A Shimeji-style floating desktop pet for Android. OrangePet renders a small
sprite in a TYPE_APPLICATION_OVERLAY window that sits above other apps and
runs a cute, self-directed behavior state machine — walking, idling, blinking,
hopping, looking around, showing a heart, yawning, sleeping — instead of
pacing back and forth on a fixed loop. It also reacts to charging and to
incoming messages, and always freezes in a faint "resting" state when the
battery drops to 30% or below (unless it's charging).
- Namespace / applicationId:
com.orangepet.app - Minimum SDK: 26
- Compile / Target SDK: 35
| Component | Version |
|---|---|
| Kotlin | 2.0.21 |
| Kotlin Compose plugin | 2.0.21 |
| Android Gradle Plugin | 8.7.3 |
| Gradle wrapper | 8.9 |
| Compose BOM | 2024.12.01 |
| Core KTX | 1.15.0 |
| Activity Compose | 1.10.0 |
| Lifecycle | 2.8.7 |
| Saved State | 1.2.1 |
| Coroutines Android | 1.9.0 |
| Java | 17 |
This version changes no build/tooling versions from the previous release —
only app/src/main/java/com/orangepet/app/*.kt, AndroidManifest.xml,
strings.xml, and the drawable set changed.
- MainActivity checks
Settings.canDrawOverlays(), sends the user toACTION_MANAGE_OVERLAY_PERMISSIONif needed, and startsFloatingPetServiceonce permission is confirmed.canDrawOverlays()is rechecked on everyonResume()and treated as the single source of truth, since the settings screen's Activity result is not reliable across OEMs. It also offers an optional "Enable message reactions" button (see Notification reactions below). - FloatingPetService is a foreground service (
specialUsetype) that attaches aComposeViewdirectly toWindowManager. Because a window-attachedComposeViewhas no Activity to inherit lifecycle/ saved-state/view-model owners from, the service implementsLifecycleOwner,SavedStateRegistryOwner, andViewModelStoreOwneritself and installs them on the view tree before callingsetContent. - All visual state lives in one immutable
PetUiState(seePetState.kt), exposed as aStateFlowthat the Compose UI collects. Every behavior function is just a sequence ofStateFlowupdates plusdelay()calls — there is no separate animation system to keep in sync. - Exactly one master behavior coroutine
Jobruns at a time (behaviorJob). Battery/charging changes and incoming-message events cancel whatever is currently running and start the appropriate replacement job; nothing is ever layered on top of anything else.
initializeOverlay() now moves the service's Lifecycle to STARTED
before attaching the ComposeView to WindowManager, not after.
Compose's Recomposer only actively recomposes once the associated
Lifecycle reaches STARTED — attaching first and starting the lifecycle
a moment later risked the very first frame rendering from the default
state and then never visibly updating again, since nothing was actually
recomposing. That would look exactly like "the pet appears but never
animates", regardless of which behavior was technically running
underneath. This was a real bug, not a guess: the exact same ordering
issue was found and fixed the same way in a parallel version of this
project, and the fix here is the identical reordering.
PetBehavior (PetState.kt) has ten states. Priority, highest first:
-
CHARGING — absolute priority. Triggered by
ACTION_POWER_CONNECTED(and confirmed viaBatteryManager.EXTRA_STATUSon everyACTION_BATTERY_CHANGEDbroadcast, in case the connect/disconnect broadcast is missed). Movement stops, the pet showsorange_pet_happy, and does a gentle repeating bounce for as long as the device stays plugged in. -
FAINTED — battery ≤ 30% and not charging. All motion and effects stop immediately, the overlay position is preserved, and
orange_pet_faintis shown until the battery recovers above 30% or charging starts. -
NOTIFICATION_REACTION — a one-shot interrupt (see below) that plays over whatever normal behavior was running, then hands control back to the normal scheduler (or the charging loop, if charging started in the meantime).
-
Normal scheduler — while battery is fine and the device isn't charging, a weighted-random scheduler repeatedly picks one of:
Behavior Weight Typical duration Idle (breathing) 30% 2–5 s Walking 30% 3–8 s, or until an edge is reached Blinking 15% 100–180 ms (sometimes a double-blink) Looking around 10% ~0.8–1.4 s total Hopping 7% 500–900 ms per hop, 1–2 hops Showing a heart 5% 1–1.5 s Sleeping 3% 5–10 s The same "special" behavior (anything other than idle/walking) is never picked twice in a row.
Walking moves the real overlay window, not just the image inside a fixed-position view. Velocity eases toward a target speed each frame (simple exponential smoothing) instead of jumping by a fixed pixel step every tick, giving an organic accelerate/cruise feel instead of a robotic slide. The bounce is a proper footstep cadence rather than a floaty wobble: it always lifts up from the baseline, its rate tracks actual speed (quick steps while moving fast, settling down right after a turn), and each footfall gets a subtle squash-on-landing / stretch-on-lift. A small forward lean appears while accelerating. Reaching either edge plays a pivot-hop — a brief lift that flips the pet to face the new direction mid-air — instead of a plain pause-and-turn.
Blinking uses a real orange_pet_blink drawable (closed-eye artwork
derived from the base sprite) rather than a scale-squash fallback, since a
real asset is included in this repo.
Hopping and showing a heart both animate purely inside the
transparent overlay window (which is taller than the pet — see below) using
translationY/alpha, so nothing is ever clipped and no second
WindowManager window is created.
Yawning → Sleeping: right before each nap, the pet plays a single
smooth stretch-and-yawn arc (a small lift, a slight backward tilt, and the
orange_pet_yawn open-mouth/squinting sprite) and only then settles into
the existing rest tilt with "Z" → "Zz" → "Zzz" cycling above it. "Tired"
in this app means "about to sleep" — the yawn is the visible cue for that,
rather than a fully independent random behavior of its own.
FloatingPetService registers one BroadcastReceiver for three actions —
ACTION_BATTERY_CHANGED, ACTION_POWER_CONNECTED, and
ACTION_POWER_DISCONNECTED — so charging state is always known without a
second receiver. Charging always overrides low-battery fainting: if the
pet is plugged in while at 20%, it shows the happy bounce, not the faint
sprite.
If — and only if — the user explicitly grants "Notification access" in
system settings, PetNotificationListenerService (a standard Android
NotificationListenerService) detects message-style notifications
(Notification.CATEGORY_MESSAGE, or a small set of known messaging-app
package name fragments as a fallback) and forwards a contentless signal
to FloatingPetService via an in-process event bus.
Privacy: the listener never reads, stores, or forwards a notification's
title, text, or sender — only the fact that a qualifying notification
arrived. The pet's reaction is always the same fixed line
("Should I reply for that msg? 💬"), never anything derived from the
message itself. This feature is fully optional: the app works exactly the
same without it, MainActivity only shows a small "off (optional)" status
line and a settings shortcut, and there is no uses-permission for it —
notification access is a special access grant, enforced by the system via
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
on the service declaration itself.
Six drawables ship in this repo, all PNGs derived from the sprite supplied with the project (no placeholders needed):
| Resource | Used for |
|---|---|
orange_pet.png |
Default / walking / idle / hopping / etc. |
orange_pet_blink.png |
BLINKING state (closed-eye variant) |
orange_pet_happy.png |
CHARGING state (smiling + blush variant) |
orange_pet_faint.png |
FAINTED state (reduced-opacity variant) |
orange_pet_yawn.png |
YAWNING state (squinting + open-mouth variant, plays right before sleep) |
ic_launcher_foreground.xml |
App launcher icon only |
If you want to swap in different art later:
- Replace the PNG files directly (keep the same file names), or
- If you'd rather use vector placeholders for any of them, delete the PNG
first and add an
.xmlvector drawable with the exact same base name instead (e.g. deleteorange_pet_blink.pngbefore addingorange_pet_blink.xml). Never leave an.xmland a.pngwith the same base resource name in the tree at once — Android's resource compiler treats that as a duplicate-resource build error. - No Kotlin changes are required either way — all five are referenced
only via
R.drawable.orange_pet,R.drawable.orange_pet_blink,R.drawable.orange_pet_happy,R.drawable.orange_pet_faint, andR.drawable.orange_pet_yawn.
The overlay window itself is sized 128dp × 180dp (wider than tall) so the heart, sleep text, and chat bubble all have transparent room to render above the 128dp pet image without clipping, while staying far short of a full-screen surface.
PetMath.kt holds every piece of logic that doesn't need the Android
framework (boundary clamping, priority-bucket selection, weighted behavior
selection), specifically so it can be exercised by plain JUnit4 —
app/src/test/java/com/orangepet/app/PetMathTest.kt — without an emulator,
device, or Robolectric. Run it with:
./gradlew testMapping to the behaviors called out in the spec:
| # | Requirement | How it's verified |
|---|---|---|
| 1 | Low battery always produces FAINTED | PetMathTest — priorityBucket_lowBatteryNotCharging_isFainted |
| 2 | No normal behavior while battery is low | Same test; reconcilePriorityBucket() only ever calls switchToFainted() for that bucket |
| 3 | Horizontal position stays within 0..maximumX |
PetMathTest — clampX_* tests |
| 4 | Direction reverses at screen boundaries | runWalking() calls playEdgeReaction() and flips direction exactly at the clampX boundary |
| 5 | Only one behavior scheduler can run | By construction: behaviorJob is a single var, always cancel()-ed before reassignment |
| 6 | Low battery cancels movement immediately | switchToFainted() cancels behaviorJob before anything else |
| 7 | Returning to normal battery starts one scheduler | reconcilePriorityBucket() only starts a new job when the bucket actually changes |
| 8 | Temporary visual effects reset after completion | Every run*() function ends by resetting the fields it changed (see e.g. the end of runShowingHeart, runSleeping) |
| 9 | Service destruction cancels all behavior work | onDestroy() step 1: behaviorJob?.cancel(); step 6: serviceScope.cancel() |
| 10 | WindowManager isn't updated after removal | applyOverlayPosition() and onDestroy() both gate on isOverlayAttached |
Items 5, 6, 9, and 10 are architectural guarantees that need a real
Service/WindowManager to exercise directly, so they're enforced by
construction and documented in code rather than covered by a JVM-only
test — see the "Known environment limitation" section for exactly what
could and couldn't be compiler-verified in the sandbox this was built in.
./gradlew --no-daemon clean assembleDebugThe debug APK is produced at:
app/build/outputs/apk/debug/app-debug.apk
.github/workflows/android.yml builds a debug APK on every push to main
and on manual dispatch, using the committed Gradle wrapper (Gradle 8.9), and
uploads the result as the OrangePet-debug-apk artifact.
| Permission | Reason |
|---|---|
SYSTEM_ALERT_WINDOW |
Draw the pet overlay above other apps |
FOREGROUND_SERVICE |
Run the overlay as a foreground service |
FOREGROUND_SERVICE_SPECIAL_USE |
Declare the specialUse foreground service type used for the persistent overlay |
POST_NOTIFICATIONS |
Show the required Android 13+ foreground-service notification |
No new uses-permission entries were added for this version.
PetNotificationListenerService is gated by system-enforced
BIND_NOTIFICATION_LISTENER_SERVICE on the service declaration and by the
user's explicit "Notification access" grant in system settings — not by an
app-requested permission.
This project was generated and reviewed in a sandbox that has no Android
SDK and no network access to dl.google.com / maven.google.com /
services.gradle.org. ./gradlew assembleDebug cannot complete in that
environment: the wrapper itself downloads real Gradle 8.9 from
services.gradle.org, which the sandbox's network policy blocks (confirmed
directly: ./gradlew --version fails with HTTP 403 from the egress
proxy at that host), and even with Gradle present, resolving the Compose/
AndroidX dependencies requires Google's Maven repository, which is equally
unreachable.
What was genuinely verified in this environment, rather than just asserted:
- A real, unmodified Kotlin 2.0.21 compiler (fetched from
github.com/JetBrains/kotlin's official release assets) was used to compilePetState.kt(now including the newYAWNINGenum value) andPetMath.kt— the two files with no Android framework dependency — and both compiled cleanly with zero errors and zero warnings. - The Gradle wrapper jar/scripts are the genuine, unmodified 8.9 wrapper —
re-verified by checksum against the copy originally fetched for this
project (
sha256: 498495120a03...— unchanged). - Every brace/parenthesis in every changed file was balance-checked, and
the new
orange_pet_yawn.pngsprite was verified programmatically (correct dimensions, expected pixel changes only in the eye/mouth region, no corruption elsewhere) since this sandbox can't reliably render a visual preview. FloatingPetService.kt(the file every change in this pass touched) was reviewed by hand: the lifecycle-fix reordering, the newrunWalking/playEdgeReactionanimation math, and the newrunYawn/runSleepingsequencing were all traced line by line — but because this file depends onandroid.*and Jetpack Compose classes that only exist in the Android SDK / Google's Maven repo, it could not be compiled in this sandbox. Treat it as carefully written and reviewed, not as compiler-verified.
Build this project in an environment with the Android SDK and normal
internet access, or rely on the included GitHub Actions workflow, which
runs on ubuntu-latest with full SDK/network access and will be the first
place the complete app actually gets compiled.