Shelter runs two copies of itself. That’s the whole trick.

Not many Android apps do this. Shelter, the open-source work profile manager, installs itself in both the main profile and the work profile, then wires them together with cross-profile intents. One copy has DevicePolicyManager authority inside the work profile. The other sits in the main profile and talks to it. No root. No ADB. Just two instances of the same app, one on each side of the profile wall, passing signed intents back and forth.

I spent an afternoon reading through the entire codebase (~5,100 lines of Java across 25 files). Here’s how it works.

The core loop: bind, cross, bind

When you open Shelter, MainActivity kicks off a three-step dance:

  1. Bind to the local ShelterService — this one lists your apps and handles UI requests. Runs in the main profile, nothing special.
  2. Ping the work profile — fires a TRY_START_SERVICE intent across the boundary. If work mode is off, you get a prompt. If the profile doesn’t exist at all, you’re unprovisioned and Shelter tells you.
  3. Bind to the work profile’s ShelterService — now we’re talking. This one’s the profile owner. It can call setApplicationHidden(), manage cross-profile widgets, enforce policies. The first service was a librarian. This one’s the bouncer.

Both services share the same AIDL interface. IShelterService is implemented twice, once without profile-owner powers (main), once with (work). The UI calls whichever side makes sense. Freeze an app? Work profile. Clone from main to work? Cross the boundary.

DummyActivity: the translucent traffic cop

Here’s where it gets clever. Android won’t let you fire arbitrary binder calls across profiles, but it will forward activity intents, as long as they’re in the crossProfileIntentFilter whitelist. Shelter exploits this with DummyActivity, a translucent activity that never shows any UI.

DummyActivity sits in both profiles and handles about a dozen custom intent actions: INSTALL_PACKAGE, UNINSTALL_PACKAGE, UNFREEZE_AND_LAUNCH, START_FILE_SHUTTLE, and so on. When one profile wants something from the other, it wraps the request as an intent, signs it, and sends it to DummyActivity on the other side.

Authentication is TOFU: Trust On First Use. The first profile to set up generates an HMAC-SHA256 key and sends it across as an auth_key extra. The other side grabs that key and trusts it permanently. From then on, every intent carries a timestamp plus a signature. Timestamps expire after 30 seconds, so replaying an old intent doesn’t work.

There’s also a same-process shortcut. When ShelterService needs to launch DummyActivity for an install or uninstall, it registers the intent through a static volatile long with a 5-second validity window. No signature check needed, and since both components share a process, it’s fine.

How cloning actually works

“Cloning” an app sounds like it duplicates an APK. It doesn’t. What happens depends on whether the app is a system app:

  • System apps (pre-installed): Android already has the APK available in the work profile, just disabled. Shelter calls DevicePolicyManager.enableSystemApp() and the app appears. No file transfer, no install session, nothing.
  • User-installed apps: Shelter reads the APK path from the source profile, then uses PackageInstaller to install it into the work profile. On Android 10+, ACTION_INSTALL_PACKAGE is deprecated, so Shelter does manual session management: open a PackageInstaller.Session, pipe the APK bytes through, commit with a PendingIntent callback.

The APK file itself crosses the profile boundary through Shelter’s FileProviderProxy, which opens a file descriptor in the source profile and wraps it as a content URI accessible from the other side. It’s a one-way door: once installed in the work profile, the app has its own separate storage, its own data, its own lifecycle.

Cloning only goes main → work, not the reverse. You can’t promote a work app back to main.

Freezing: the cheapest isolation you can get

“Freezing” an app in Shelter is literally a single API call:

mPolicyManager.setApplicationHidden(adminComponent, packageName, true);

This hides the app from the launcher, prevents it from running, and stops it from receiving broadcasts. It’s not freezing a process. It’s telling the system to act like the app was never installed.

The auto-freeze feature (FreezeService) is a screen-off listener with a configurable delay. When the screen locks, it sets an AlarmManager alarm. If the screen comes back on before the alarm fires, the freeze gets cancelled. If the alarm goes off, it iterates through the auto-freeze list and hides every app in it.

There’s an optional “don’t freeze foreground apps” mode that snapshots UsageStatsManager at screen-lock time and skips any app that was recently in the foreground. Reasonable idea, though the implementation relies on a 1-second inactivity threshold that feels arbitrary.

The static sAppToFreeze list inside FreezeService is synchronized on the class lock, but there’s no protection against the process getting killed mid-operation. If the system decides to reap Shelter between the alarm firing and the loop finishing, some apps stay unfrozen. Not a catastrophic bug, but it means the auto-freeze guarantee is soft.

File sharing across the boundary

Shelter has two mechanisms for moving files between profiles:

File Shuttle lets you browse the work profile’s storage from the system Files app. It’s a DocumentsProvider (CrossProfileDocumentsProvider) that proxies requests to FileShuttleService, which handles the actual filesystem operations. The service has a 10-second inactivity timer: if nothing happens for 10 seconds, it unbinds and stops itself. Keeps the AIDL connection from hanging around.

UriForwardProxy is simpler and more specialized. It opens a file descriptor in one profile and hands a content URI to the other. Used exclusively for APK installation: the APK gets read in the main profile and piped to PackageInstaller in the work profile.

Neither mechanism gives you transparent shared storage. Files still live in separate sandboxes. The shuttle is purely for manual transfers.

Setup: the provisioning dance

Getting Shelter set up is a multi-step wizard that ultimately calls DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE. This hands control to Android’s system provisioning UI, which creates the work profile and sets Shelter as its profile owner.

The tricky part is what happens after provisioning finishes. On Android 8+, Shelter hooks into ACTION_PROVISIONING_SUCCESSFUL via FinalizeActivity — an activity intent, which is way more reliable than a broadcast receiver. That activity runs the final configuration: enabling policies, setting intent filters, hiding Shelter’s own launcher icon inside the work profile. On Android 7 and below, the same work fires from ShelterDeviceAdminReceiver.onProfileProvisioningComplete(), but broadcast receivers are flaky for complex operations, so it delegates to DummyActivity through a notification tap.

One thing that caught my eye: the device_admin.xml file declares zero policies:

<device-admin>
    <uses-policies>
    </uses-policies>
</device-admin>

All policy enforcement happens programmatically in Utility.enforceWorkProfilePolicies(). No static declarations to work around. Shelter has complete runtime control over what’s active and when.

KillerService: the dirty hack that holds it together

Android activities don’t get notified when the user swipes them from recents. This is a problem for Shelter because the work-profile ShelterService runs as a foreground service, and if the main activity disappears without cleanup, that service keeps running. Orphaned notification and all.

The fix is KillerService, a sticky service that watches onTaskRemoved, onTrimMemory(TRIM_MEMORY_BACKGROUND), and onDestroy. Any of these fires killEverything(), which calls stopShelterService() on both instances and then System.exit(0) on the work-profile process.

The code’s own comments call this “a dirty hack,” and they’re right. But it works. The main activity also calls finish() on onTrimMemory(TRIM_MEMORY_BACKGROUND), so the whole thing is aggressively eager to die. Better to clean up than to leave orphaned services running.

What Shelter can’t do

Some of these are Android limitations. Some are design choices.

  • Can’t freeze apps in the main profile. Shelter is only the work profile owner, not the device owner. It has no authority over the main profile.
  • Can’t clone apps both ways. Work → main cloning isn’t supported.
  • No batch clone. You clone apps one at a time through context menus. The multi-select mode only handles unfreeze shortcuts.
  • No data migration between profiles. Cloning installs a fresh copy of the app. Your data doesn’t come with it.
  • No backup/restore of freeze lists. Your auto-freeze configuration lives in SharedPreferences with no export capability.
  • No MIUI support for non-system app cloning. Xiaomi blocks it at the OS level. Shelter shows a warning but can’t override it.
  • Fragile lifecycle management. The KillerService approach relies on Android calling lifecycle methods reliably, which isn’t guaranteed under memory pressure.
  • No work profile backup. If you reset your device or re-provision the work profile, you’re starting from scratch.

The project is in maintenance mode

The README is upfront about this. Shelter’s author considers the app feature-complete given the APIs available. The commitment is to keep it working on new Android versions, not to add features. Looking at the code, I think that’s fair. Shelter already covers the full surface area of what unprivileged Work Profile APIs allow.

The codebase is clean, well-commented, and surprisingly compact. About 5,100 lines of Java, no DI frameworks, no Kotlin, no reactive libraries. Just AIDL services, intents, and DevicePolicyManager calls. You can read the whole thing in an afternoon and come out understanding exactly how it works.

I wish more projects were like this.