Skip to content

Repository files navigation

OrangePet

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).

Package

  • Namespace / applicationId: com.orangepet.app
  • Minimum SDK: 26
  • Compile / Target SDK: 35

Tech stack (pinned)

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.

How it works

  • MainActivity checks Settings.canDrawOverlays(), sends the user to ACTION_MANAGE_OVERLAY_PERMISSION if needed, and starts FloatingPetService once permission is confirmed. canDrawOverlays() is rechecked on every onResume() 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 (specialUse type) that attaches a ComposeView directly to WindowManager. Because a window-attached ComposeView has no Activity to inherit lifecycle/ saved-state/view-model owners from, the service implements LifecycleOwner, SavedStateRegistryOwner, and ViewModelStoreOwner itself and installs them on the view tree before calling setContent.
  • All visual state lives in one immutable PetUiState (see PetState.kt), exposed as a StateFlow that the Compose UI collects. Every behavior function is just a sequence of StateFlow updates plus delay() calls — there is no separate animation system to keep in sync.
  • Exactly one master behavior coroutine Job runs 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.

Lifecycle fix

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.

Behavior state machine

PetBehavior (PetState.kt) has ten states. Priority, highest first:

  1. CHARGING — absolute priority. Triggered by ACTION_POWER_CONNECTED (and confirmed via BatteryManager.EXTRA_STATUS on every ACTION_BATTERY_CHANGED broadcast, in case the connect/disconnect broadcast is missed). Movement stops, the pet shows orange_pet_happy, and does a gentle repeating bounce for as long as the device stays plugged in.

  2. FAINTED — battery ≤ 30% and not charging. All motion and effects stop immediately, the overlay position is preserved, and orange_pet_faint is shown until the battery recovers above 30% or charging starts.

  3. 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).

  4. 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.

Charging reaction

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.

Notification reactions (optional)

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.

Pet art

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:

  1. Replace the PNG files directly (keep the same file names), or
  2. If you'd rather use vector placeholders for any of them, delete the PNG first and add an .xml vector drawable with the exact same base name instead (e.g. delete orange_pet_blink.png before adding orange_pet_blink.xml). Never leave an .xml and a .png with the same base resource name in the tree at once — Android's resource compiler treats that as a duplicate-resource build error.
  3. 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, and R.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.

Testing

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 test

Mapping 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.

Building

./gradlew --no-daemon clean assembleDebug

The debug APK is produced at:

app/build/outputs/apk/debug/app-debug.apk

Continuous integration

.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.

Permissions

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.

Known environment limitation

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 compile PetState.kt (now including the new YAWNING enum value) and PetMath.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.png sprite 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 new runWalking/playEdgeReaction animation math, and the new runYawn/runSleeping sequencing were all traced line by line — but because this file depends on android.* 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages