A photo frame for Meta Portal that displays a Google Photos public shared album.
No sign-in, no OAuth, no Google API. The app fetches a public share page, downloads the photos to local storage, and renders from disk — so the frame keeps working when the network is down, and there are no credentials to expire or leak.
The Google Photos Library API was ruled out deliberately: the March 2025 restriction removed read access to a user's existing library. Confirmed empirically —
403 PERMISSION_DENIED "Request had insufficient authentication scopes."Seedocs/plans/2026-08-17-portalgallery-photo-source-design.mdfor the full rationale and the three adversarial reviews of it.
In Google Photos: open the album → Share → Create link. You want a
https://photos.app.goo.gl/... URL.
A photos.google.com/album/... URL will not work — that is your private owner-side
view and requires signing in. The app rejects it with an explanatory message.
python3 tools/check_album_ordering.py "https://photos.app.goo.gl/YOURLINK"Confirms the album is publicly readable, and reports photo count, date range and ordering. Ordering matters — see Known limits below.
Per device, no rebuild (the usual way):
A=com.example.portalgallery/.ui.slideshow.SlideshowActivity
# Replace the configured set with one album
adb shell am start -n $A -e album_url "https://photos.app.goo.gl/YOURLINK"
# Or several at once, up to 5
adb shell am start -n $A -e album_url "https://photos.app.goo.gl/AAA,https://photos.app.goo.gl/BBB"
# Add one without re-typing the others
adb shell am start -n $A -e album_add "https://photos.app.goo.gl/CCC"
# Remove one
adb shell am start -n $A -e album_remove "https://photos.app.goo.gl/CCC"Persists to SharedPreferences and takes precedence over the built-in default.
Invalid, duplicate, and over-the-limit entries are rejected individually with a reason in logcat — the valid ones in the same list still apply.
Change the built-in default (survives pm clear, applies to fresh installs): add it
to local.properties, which is gitignored — a share link is effectively a capability and
must never be committed.
portalgallery.albumUrl=https://photos.app.goo.gl/YOURLINKThen reinstall. A fresh clone builds with an empty default, which is correct: whoever clones this sets their own album.
Reset to the built-in default:
adb shell pm clear com.example.portalgallery # also deletes downloaded photosSee what is currently set:
adb shell run-as com.example.portalgallery \
cat /data/data/com.example.portalgallery/shared_prefs/portal_gallery.xmlThe frame keeps showing the old photos until the new album syncs — it never blanks during a switch. After a fully successful sync, photos belonging to no configured album are deleted, so albums do not accumulate on disk.
Resolution order: SharedPreferences → BuildConfig.DEFAULT_ALBUM_URL → nothing.
Items from every album are pooled and shuffled together; duplicates shared between albums are stored once, since media ids are globally unique.
One unreachable album never costs you its photos. Each album is fetched independently, and pruning only runs when every album was read successfully. Otherwise the frame carries forward everything already on disk — because "not in any album I could read" and "not in any album" are very different claims, and acting on the first would delete a fifth of the library over a single failed HTTP request, silently, while every other album kept working.
The 5-album cap is a deliberate ceiling: each album contributes up to ~300 items, so five is already ~1500 photos and several hundred megabytes once videos are enabled.
Long-press the frame to open settings. No adb, no laptop.
| Setting | Notes |
|---|---|
| Transition | Crossfade, Slide, Zoom, Cut, or Random |
| Transition speed | 200–3000 ms |
| Slow pan & zoom | Ken Burns drift across each photo; also keeps pixels moving on an always-on panel |
| Time per photo | 3–120 s |
| Quiet hours | Enable, and set sleep/wake times with a picker |
| Sleep now | Same as the adb command; clears at the next scheduled boundary |
| Album | Read-only — URL, photo count, orientation split, last sync |
Changes apply from the next photo. The slideshow reads preferences live, so nothing needs restarting.
The album URL is intentionally not editable here: typing a share link on a wall-mounted touchscreen is unpleasant, and a typo silently breaks the frame. Change it over adb.
Off by default. Enable in on-device settings.
Shared albums mix photos and video. Before this existed, clips displayed as silent frozen poster frames — the app requested a sized still for every item, and for a video that is what the URL returns.
Detection uses a metadata key. Nothing obvious marks an item as video: no MIME type,
no "video" string, and the =dv suffix returns an MP4 for stills too, because Google
synthesises video from Motion Photos. What does mark them is key 76647426 in the
entry's trailing metadata object, whose value is [durationMs, null, width, height, …] —
so clip length comes free with the detection.
An earlier version got this wrong in an instructive way. It used the media sub-array length: videos have 10 elements, stills 12. That matched perfectly on the first album — 7 short entries, 7 videos — and was simply luck. The second album has 13 short entries of which only 7 are video; the rest are ordinary 4:3 stills and a panorama, which would have been downloaded as MP4 and played as clips. A short sub-array is necessary but not sufficient.
Both rules are undocumented structure and will eventually break, so the golden tests pin the counts, the aspect ratios, and the durations. One test exists specifically to assert the old heuristic would over-detect, so nobody reintroduces it.
Size. Videos download via =dv at original quality — the size suffix that bounds
photos does not apply. One clip measured 38 MB against a 4.2 MB figure in the album
metadata, so expect the library to grow several times over. This is why it is opt-in.
Playback. Clips play in full, then advance; the interval timer is suspended so nothing is cut off mid-action. Pausing pauses the video.
Audio is gated on presence. Sound plays only while someone is actually in view, so the frame never talks to an empty house — and because an empty room sleeps the frame, there is no separate night-time rule to get wrong. With presence detection off, clips play silently and the settings screen says so rather than pretending sound is on.
Off by default. Enable in on-device settings; the frame then wakes when someone is in the room and sleeps after an empty room for N minutes (default 5).
Two-stage detection. A cheap frame-difference check samples one frame a second; only when that fires does the TFLite person detector run. Motion alone would sleep on someone sitting still watching photos and would trip on pets and curtains; running inference on every frame would burn a neural net forever on a wall-mounted device.
Nothing leaves the device. No frame is written to disk or transmitted. The only output is a timestamp.
| Condition | Result |
|---|---|
| Manual sleep/wake | Wins over everything, until the next scheduled boundary |
| Camera working, someone in view | Awake — even during quiet hours |
| Camera working, room empty | Asleep — even at midday |
| Camera unavailable | Falls back to the quiet-hours schedule |
So while presence is running the schedule does nothing. That is deliberate: the schedule is the fallback for when the camera cannot answer — privacy shutter closed, permission denied, detection switched off, hardware busy. Treating an unanswerable camera as "nobody is here" would sleep the frame with nothing able to wake it.
Closing Portal's physical shutter is therefore a safe, hard off-switch: presence goes
UNAVAILABLE and the frame reverts to schedule behaviour.
Android 9 disconnects the camera from any app whose process has no foreground activity and no foreground service. When the frame sleeps, the panel powers off, the Activity is stopped, the process goes to background — and the system revokes camera access.
The symptom is a very specific asymmetry: the frame sleeps on absence correctly and then can never wake, because the only thing that could notice someone arriving is the camera, and it died with the screen.
PresenceService exists solely to hold the process foreground. It does not own the
camera or the detector; process-level foreground state is what the restriction keys on.
The persistent notification is the price Android charges for that status — set to minimum
importance so it stays silent and collapsed in the shade rather than appearing on the
frame.
If the camera is lost anyway (another app taking it at higher priority), the detector retries every 30 seconds rather than staying dead until the app restarts.
deploy-portal.sh grants it, since a wall-mounted frame should not throw runtime
permission dialogs:
adb shell pm grant com.example.portalgallery android.permission.CAMERAWithout it, presence reports unavailable and the schedule takes over.
Off by default — a frame that unexpectedly goes dark reads as broken.
A=com.example.portalgallery/.ui.slideshow.SlideshowActivity
# Sleep midnight to 7am local (setting the window turns the schedule on)
adb shell am start -n $A -e sleep_start "00:00" -e sleep_end "07:00"
# Turn the schedule off / back on without losing the window
adb shell am start -n $A -e sleep off
adb shell am start -n $A -e sleep on
# Sleep or wake right now, until the next scheduled boundary
adb shell am start -n $A -e command sleep
adb shell am start -n $A -e command wakeTimes are 24-hour HH:mm in the device's local zone, so a Portal set to Pacific sleeps
on Pacific and follows DST. Windows crossing midnight (22:00 → 07:00) work.
Behaviour asleep: the slideshow stops, the screen goes black immediately, brightness
drops to the floor, and KEEP_SCREEN_ON is released so the device's own timeout can
power the panel down. Syncing continues — the frame wakes with fresh photos.
Portal ships its own screensaver (com.facebook.aloha…HomeDreamService) with
screensaver_enabled=1. Releasing KEEP_SCREEN_ON would therefore start that dream
rather than powering the panel down — quiet hours would swap your photos for Portal's
ambient screen instead of going dark.
deploy-portal.sh disables it as part of deployment. Manually:
adb shell settings put secure screensaver_enabled 0 # panel can power down
adb shell settings put secure screensaver_enabled 1 # restore Portal's screensaver
# or: ./tools/deploy-portal.sh --restore-screensaverThe panel goes fully dark screen_off_timeout after sleep begins — 5 minutes on
Portal+ (settings get system screen_off_timeout). The screen is black from the moment
sleep starts; only the backlight lingers.
Sleeping is easy; waking is not, for two reasons that both bite:
- Once the panel powers down the Activity is paused, so any timer tied to its lifecycle
stops. An early version evaluated the schedule on a 60-second
Handlertick and cancelled it inonPause— the frame slept correctly and then had nothing left running to wake it. FLAG_KEEP_SCREEN_ONonly prevents a screen from sleeping. Nothing in the window flags turns a dark panel back on.
So enterSleep() schedules an exact AlarmManager alarm at the wake boundary. The
receiver takes a SCREEN_BRIGHT_WAKE_LOCK with ACQUIRE_CAUSES_WAKEUP, which does power
the display on, then brings the frame forward. The alarm fires 5 seconds past the
boundary so clock skew cannot leave the schedule still reading "asleep" and send it
straight back down with the alarm already spent.
Known gap: alarms do not survive a reboot. If the Portal restarts during quiet hours
the frame stays dark until someone taps it. Reboots are rare; a BOOT_COMPLETED
receiver would close this, and works on API 28.
Tapping a sleeping frame wakes it until the next scheduled boundary, so quiet hours cannot be permanently disabled by accident.
A manual sleep/wake lasts only until the next scheduled transition, then normal
behaviour resumes.
./tools/deploy-portal.sh # debug build (default)
RELEASE=1 ./tools/deploy-portal.sh # signed, R8-shrunk build
./tools/deploy-portal.sh --facts-only # capture device info, change nothing
./tools/verify.sh "<share link>" # same flow against an emulator| Debug | Release | |
|---|---|---|
| StrictMode | on (logs main-thread disk/network) | off |
| R8 shrink + obfuscate | off | on |
| Signing | debug keystore | keystore.properties |
Debug is the default. Prove a change works there first: R8 failures in this app are
runtime failures, not build failures — obfuscated Gson field names would invalidate the
photo index, and a renamed enum constant would reset the transition setting. Both are
covered by app/proguard-rules.pro, but neither would announce itself at build time.
keystore.properties and portalgallery-release.jks are gitignored. If the file is
absent the build still configures; it just cannot produce an installable release APK.
Back up the keystore outside the repo. Android identifies an app by its signature — lose the key and you cannot update an installed build, only uninstall and reinstall, which wipes the downloaded photo library and every setting.
Switching between debug and release also changes the signature, so the first switch
needs an uninstall. deploy-portal.sh uninstalls anyway.
| Script | Purpose |
|---|---|
tools/check_album_ordering.py |
Is the album public? How many photos, what order? |
tools/scrub_fixture.py |
Turn a captured page into a committable test fixture |
tools/verify.sh |
End-to-end emulator check |
tools/deploy-portal.sh |
Sideload to Portal + capture device facts |
Never commit a raw album capture. It contains the live share link, ?key= tokens,
contributor names, and Google account ids. .gitignore blocks album-fixture.html;
use the scrubber to produce app/src/test/resources/shared_album_fixture.html.
./gradlew :app:testDebugUnitTestGolden-file tests against a real (scrubbed) share page. They pin the photo count, the orientation split, the size-suffix rule, and the fail-loudly-never-empty contract. When Google changes the page structure these are what tell you — refresh the fixture with the scrubber and the diff shows exactly what moved.
Checked against Meta's own Portal skill (meta-quest/agentic-tools), which documents constraints that are easy to get wrong and hard to diagnose.
| Constraint | Status here |
|---|---|
minSdk ≤ 28 |
26 ✓ |
targetSdk > 29 |
34 — fine for porting, verified upstream to 36 |
MAIN + LAUNCHER intent-filter |
Present ✓ |
PNG icon in mipmap-xxxhdpi/ |
Added — see tools/make_icon.py |
android:icon on the launcher activity |
Added ✓ |
| No GMS | ML Kit removed; TFLite instead — see below |
| Top 64 dp system overlay | Slideshow is dark and full-bleed; settings needs review |
| Far-field mic unavailable to sideloaded apps | Why voice control was dropped |
Raw Camera2 frames available |
Why presence detection is viable |
Portal ships without Google Mobile Services (pm list packages | grep -c gms → 0).
This project used ML Kit first, and it was the wrong call in an instructive way. The
com.google.mlkit:face-detection artifact was chosen specifically because the bundled
model variant should not need Play Services. Its POM says otherwise:
com.google.android.gms:play-services-base
com.google.android.gms:play-services-basement
com.google.android.gms:play-services-tasks
com.google.firebase:firebase-components
Bundling the model does not bundle away the GMS shim. On Portal that fails at class
load — and as NoClassDefFoundError, an Error, which a catch (Exception) around
frame analysis would not have caught. It also weighed ~39 MB, about 85% of the APK.
It is now TFLite (EfficientDet-Lite0, person class), which is what Meta's Portal
guidance recommends. Zero GMS or Firebase references across tensorflow-lite,
tflite-support and tflite-task-vision, verified against their POMs, and a 4.5 MB
model in assets/.
A side benefit: TFLite inference is synchronous on the analysis thread, which removes
the whole class of bug around handing an ImageProxy to an async callback and closing
it out from under the detector.
If the model still fails to load, presence degrades to motion-only rather than disappearing — motion counts as presence on its own, and the detector only sharpens it. Motion-only means someone perfectly still for the entire absence timeout reads as absent; with the default 5 minutes that is rarely a problem.
- 300 photos per album. The share page returns a 300-item prefix with a continuation token. Reading further means reverse-engineering an internal RPC, which this project does not do. Keep a frame album under 300 and the limit never applies.
- Ordering follows the album's sort order. An album sorted newest-first puts new
photos in the visible 300; sorted oldest-first, new photos land beyond it and never
appear. Sorting is by capture date, so an old photo added today still sorts to its
original date. Check with
check_album_ordering.py. - Orientation filter. The frame shows only photos matching its physical orientation. This can cut the visible set substantially (the reference album is 56% portrait). If no photo matches, the filter is abandoned rather than showing a black screen.
- No in-app settings yet. Album URL and interval are adb-only. The slideshow interval is fixed at 8s.
- Nothing restarts the app if the OS kills the process.
DreamServiceor HOME takeover would fix it; both need testing on the actual Portal. - This depends on undocumented page structure and will break when Google changes it. When that happens the frame keeps showing the last synced set — it degrades, it does not go blank.