Skip to content

Commit 49c68d2

Browse files
committed
General: Add the Play Console declarations and their demo recorder
Play requires, per declared foreground service type, a functionality description, a statement of user impact when the task is deferred or interrupted, and a link to a video demonstrating the feature. None of that existed, and it gates review — including for closed testing. Adds fastlane/play-declarations/ as the copy-paste source for the specialUse foreground-service declaration, app access, and data safety, plus the tooling that regenerates the demo video: label-based UI driving over adb, screenrecord capture, and an ffmpeg pass for title card, timed captions and end card. The scripts are committed, the .mp4 is not. The recording demonstrates the unplug stop condition rather than the 100% one. That is the condition which cannot work without a running foreground service (Android does not deliver the disconnect to a dormant app), and it needs no simulated charge progression, so nothing on screen is faked beyond the disconnect. The recorder reads the underlying system setting before, during and after the take and fails the run unless it observed protected -> lifted -> protected, so a take that did not demonstrate the feature is never silently produced. It also clears the notification shade, mutes and disables Wi-Fi for the take, because the finished video goes to Google and would otherwise carry the user's notifications and network name. Store listing: drop the per-manufacturer support list. It goes stale with every OEM system update and cannot promise anything reliable; the app already determines support on the device at runtime. Privacy policy: cover the opt-in charging history, which was added after the policy was last written and was not described by it.
1 parent 6dba6d5 commit 49c68d2

10 files changed

Lines changed: 635 additions & 6 deletions

File tree

PRIVACY_POLICY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Debug-log recording is optional and starts only after the user confirms it. A re
1414

1515
Amply uses a foreground service while a user-initiated temporary full-charge session, the optional reconnect gesture, or the optional charge alarm is active. The service observes battery level and charger connection state solely to manage one-time full charging, restore the selected protective charging policy, and — when the charge alarm is enabled — notify you to unplug once your chosen level is reached. The battery details shown in the app (level, temperature, voltage, current, health, and similar values) are read on-device from the system battery status and are never collected or transmitted.
1616

17+
Charging history is optional and starts only after you turn it on. While it is enabled, Amply records charge measurements such as battery level, temperature, voltage, current, and charger state in its private app database to draw the in-app charge curves. These records stay on the device, are removed automatically after the retention period you choose, and can be deleted by you at any time.
18+
1719
On unsupported devices you can request charge-control support. This is opt-in. The report contains device and build details (manufacturer, brand, model, device and product codes, build fingerprint), Android version, battery and charger state, Amply's version and distribution channel, and the results of Amply's compatibility check — no personal identifiers such as serial numbers, account data, or contact details. Requesting support on GitHub shows the full report first and then opens a prefilled public issue page; the report is transmitted to GitHub as part of that page's URL as soon as it opens, but becomes public only if you submit the issue. Alternatively, the same report can be handed to an email app of your choice as a text attachment addressed to the developer.
1820

1921
Last updated: 2026-07-20.

fastlane/metadata/android/en-US/full_description.txt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ Useful on any device — no ADB, Shizuku, or special permissions needed:
1616
* Battery details: a live view of level, temperature, voltage, current, charge cycles, and health.
1717
* Protection guide: on devices Amply can't control directly, it points you to your phone's own built-in battery-protection setting.
1818

19-
Direct charge control is verified on a limited set of devices:
19+
Which devices can Amply control directly?
2020

21-
* Pixel 6a and newer on Android 15+.
22-
* Samsung Galaxy on verified One UI versions.
23-
* Xiaomi, Redmi and POCO on HyperOS 2.
24-
* OnePlus, Oppo and Realme on ColorOS 15 (requires Shizuku).
21+
Charge protection is built by each manufacturer, and it changes with system updates — so a list here would be out of date the moment it's written. Pixel devices generally have good support. Everywhere else it depends on your specific phone and its system version, and no list can promise you an answer.
2522

26-
Direct control needs a one-time setup: grant WRITE_SECURE_SETTINGS via Shizuku, or from a computer over ADB. Amply walks you through each step. Other devices stay diagnostics-only for now — Amply still shows what it finds and lets you contribute your device data to help expand support.
23+
So Amply doesn't guess: install it and open it. It checks your device on the spot and tells you what it can and can't do, before you set anything up.
24+
25+
If direct control isn't available, Amply is still useful — battery details, the charge alarm, and a guide to your phone's own protection setting — and you can contribute your device data to help expand support.
26+
27+
Direct control needs a one-time setup: grant WRITE_SECURE_SETTINGS via Shizuku, or from a computer over ADB. Amply walks you through each step.
2728

2829
Amply is experimental, free, and open source (GPL-3.0).
2930

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Play Console — App access
2+
3+
Form: Play Console → App content → **App access**
4+
5+
Amply has no login, no account, and no region lock, so **"All functionality is available without
6+
special access"** is the correct answer for the *access* question itself. But Amply's headline
7+
feature is gated by the device and by a permission a reviewer cannot grant from the phone, so add
8+
the instructions below so a reviewer does not conclude the app is broken.
9+
10+
Paste as an instruction entry (name it e.g. "Direct charge control — device gated"):
11+
12+
---
13+
14+
Amply needs no account or login. Every screen is reachable immediately after install.
15+
16+
Two things about this app will look unusual during review:
17+
18+
**1. The main feature depends on the device.** Amply controls the charge-protection setting built
19+
into the phone's own system software. That setting exists only on certain manufacturers and system
20+
versions, so on any other device Amply deliberately reports that it cannot control charging and
21+
falls back to read-only information. This is intended behaviour, not an error. It is stated in the
22+
store listing and on the app's first screen. On a device without support you can still review the
23+
battery details screen, the charge alarm, the settings, and the guided setup — none of them need
24+
any grant.
25+
26+
**2. Direct control requires `WRITE_SECURE_SETTINGS`, granted from a computer.** The permission is
27+
declared in the manifest but is not grantable from a phone UI; the user grants it once over ADB
28+
from a computer, or through Shizuku. Amply cannot and does not obtain it by itself, and it works
29+
without it (with reduced functionality). Amply's setup screen shows the exact command.
30+
31+
To review the full feature set on a supported device (a Pixel 6a or newer running Android 15 or
32+
newer), grant it once with the phone connected to a computer:
33+
34+
adb shell pm grant eu.darken.amply android.permission.WRITE_SECURE_SETTINGS
35+
36+
The app then enables its charge-policy controls; no restart or further setup is needed.
37+
38+
---
39+
40+
## Related
41+
42+
The `WRITE_SECURE_SETTINGS` declaration is also the reason the app's typed privileged boundary
43+
matters: Amply never executes shell strings and writes only an explicit allowlist of settings keys.
44+
That is repo documentation, not something the Console asks for, but it is the honest answer if
45+
review comes back asking what the app does with the permission.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Play Console — Data safety
2+
3+
Form: Play Console → App content → **Data safety**
4+
5+
Source of truth for these answers: [`PRIVACY_POLICY.md`](../../PRIVACY_POLICY.md) (published at
6+
<https://amply.darken.eu/privacy>). If one changes, change both.
7+
8+
## Answers
9+
10+
| Question | Answer |
11+
|---|---|
12+
| Does your app collect or share any of the required user data types? | **No** |
13+
| Is all of the user data collected by your app encrypted in transit? | n/a (nothing collected) |
14+
| Do you provide a way for users to request that their data is deleted? | n/a (nothing collected) |
15+
| Does your app contain ads? | **No** |
16+
| Does your app have in-app purchases? | **No** |
17+
| Data used for tracking / advertising | **None** |
18+
19+
Privacy policy URL: `https://amply.darken.eu/privacy`
20+
21+
## Why "no data collected" is the correct answer
22+
23+
Play defines collection as transmitting data off the device. Amply transmits nothing on its own:
24+
no analytics SDK, no crash reporter, no advertising ID, no account system, no network calls in the
25+
normal running of the app. Everything it reads — battery level, temperature, voltage, current,
26+
charge cycles, charger state, the charge-protection setting — is read from the system and stays on
27+
the device. The opt-in charging history is stored in Amply's private app database and is deleted by
28+
its own retention window; it is never uploaded.
29+
30+
Three features move data off the device, and all three are exempt because they happen **only at the
31+
user's explicit direction, to a destination the user picks**:
32+
33+
1. **Device support report** — built only when the user asks for it, shown in full before it leaves
34+
the app, with common identifiers redacted. The user then chooses to open a prefilled GitHub
35+
issue page or to hand the text to an email app.
36+
2. **Debug log recording** — off by default, starts only after an explicit confirmation, stays in
37+
private app storage, and leaves only through Android's share sheet if the user shares it.
38+
3. **Charge-history export / share** — the user's own data, shared by the user, to a target they
39+
choose.
40+
41+
None of these is a developer-operated collection endpoint. Nothing goes to the developer's servers,
42+
because there are none.
43+
44+
## The judgement call, stated plainly
45+
46+
If Google reads the GitHub-issue path as developer-directed sharing rather than user-directed, the
47+
honest correction is to declare **"App info and performance → Other app performance data"** as
48+
*shared, optional, for app functionality (support)*. The user-initiated exemption is the better fit
49+
and is what this form answers, but the fallback exists if review pushes back — do not argue the
50+
point, just amend the form.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Play Console — declarations
2+
3+
Copy-paste source for the Play Console forms that gate review, plus the tooling that regenerates
4+
the demo video they require. Package name: `eu.darken.amply`.
5+
6+
| File | Console location |
7+
|------|------------------|
8+
| [`foreground-service-special-use/DECLARATION.md`](./foreground-service-special-use/DECLARATION.md) | App content → **Foreground service permissions** |
9+
| [`APP_ACCESS.md`](./APP_ACCESS.md) | App content → **App access** |
10+
| [`DATA_SAFETY.md`](./DATA_SAFETY.md) | App content → **Data safety** |
11+
12+
## Order of operations
13+
14+
1. Upload a build containing the permission to a track (internal testing is enough).
15+
2. Record and host the demo video (below), then fill in the foreground-service declaration.
16+
3. Fill in app access and data safety.
17+
4. Submit. Publication stays gated on the `specialUse` use case being approved — see the `release`
18+
skill.
19+
20+
## Demo video
21+
22+
Play requires, per declared foreground service type, a description, a statement of user impact if
23+
the task is deferred or interrupted, and **a link to a video demonstrating the feature**. No length
24+
or hosting spec is published for it; keep it under two minutes at 720p or better, and host it as an
25+
unlisted YouTube video or a link-shared Google Drive file.
26+
27+
The video is regenerated on a device with no manual screen recording: the recorder drives Amply
28+
with label-based taps (robust to layout changes) while `screenrecord` captures, then post-processing
29+
adds a title card, burned-in captions, and an end card. **The scripts are committed; the `.mp4`
30+
output is not** — it is written to `/tmp/amply-demo/` and regenerating it is one command.
31+
32+
| File | Role |
33+
|------|------|
34+
| `_common.sh` | UI helpers (label-based tap/scroll), battery simulation, shade control, privacy hygiene, `screenrecord` start/stop |
35+
| `find_node.py` | Locates a UI node by text/content-desc in a `uiautomator` dump |
36+
| `postprocess.sh [OUTDIR]` | ffmpeg: title card + timed captions + end card → `<OUTDIR>/declaration.mp4` |
37+
| `<declaration>/record.sh` | The recorded flow, its pre-state checks, and its title/end-card text |
38+
39+
```bash
40+
# Requires a PHYSICAL, capability-gated device (an emulator fails the gate and
41+
# would record the diagnostics-only UI). Default serial is the Pixel 7a.
42+
./foreground-service-special-use/record.sh 31071JEHN17531
43+
./postprocess.sh /tmp/amply-demo/foreground-service-special-use
44+
45+
# NOREC=1 ./foreground-service-special-use/record.sh # validate the tap chain, no recording
46+
```
47+
48+
### What the recorder guarantees
49+
50+
- **It refuses a bad take.** It aborts if the device does not start at the 80% limit (the restore
51+
would have nothing to restore), and fails at the end unless the underlying system setting was
52+
observed going protected → lifted → protected again. A take that did not actually demonstrate the
53+
feature is never silently produced.
54+
- **It keeps the user's data out of frame.** The finished video goes to Google, and the notification
55+
shade and quick-settings strip would otherwise show other apps' notifications and the Wi-Fi SSID.
56+
The recorder clears the shade, enables Do Not Disturb, and turns Wi-Fi off for the take, then
57+
restores all three on exit (including on failure).
58+
- **It restores the device.** Battery override, Do Not Disturb and Wi-Fi are reset via an exit trap.
59+
60+
### Keep consistent
61+
62+
The declared functionality must stay documented in the store listing
63+
(`fastlane/metadata/android/en-US/full_description.txt`) and must match the manifest's
64+
`PROPERTY_SPECIAL_USE_FGS_SUBTYPE` value — Google cross-checks both. Re-record and re-submit
65+
whenever the service's responsibilities change.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# shellcheck shell=bash
2+
# Shared helpers for the Play declaration screencast recorders.
3+
# Sourced by each declaration folder's record.sh. The sourcing script must set
4+
# OUTDIR (and may set SERIAL/PKG/SIZE/NOREC) BEFORE sourcing this file.
5+
set -uo pipefail
6+
7+
SERIAL="${SERIAL:-31071JEHN17531}" # Pixel 7a (lynx)
8+
PKG="${PKG:-eu.darken.amply}"
9+
SIZE="${SIZE:-720x1600}" # 1080x2400 scaled 1:1 in aspect
10+
NOREC="${NOREC:-0}"
11+
ADB=(adb -s "$SERIAL")
12+
LIBDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13+
UIX="$OUTDIR/ui.xml"; RAW="$OUTDIR/raw.mp4"; CAPS="$OUTDIR/captions.txt"
14+
mkdir -p "$OUTDIR"; : > "$CAPS"
15+
16+
# ---- ui helpers -------------------------------------------------------------
17+
# uiautomator intermittently fails with "could not get idle state" during
18+
# animations (worse under screenrecord load) and then writes nothing, leaving a
19+
# stale dump. Retry, and only overwrite $UIX with a dump that has nodes.
20+
dump() {
21+
local out
22+
for _ in 1 2 3 4; do
23+
if "${ADB[@]}" shell uiautomator dump /sdcard/__ui.xml 2>&1 | grep -q "dumped to"; then
24+
out="$("${ADB[@]}" exec-out cat /sdcard/__ui.xml)"
25+
if [ -n "$out" ] && printf '%s' "$out" | grep -q "<node"; then
26+
printf '%s' "$out" > "$UIX"; return 0
27+
fi
28+
fi
29+
sleep 0.4
30+
done
31+
[ -s "$UIX" ] && return 1
32+
"${ADB[@]}" exec-out cat /sdcard/__ui.xml > "$UIX" 2>/dev/null || true
33+
return 1
34+
}
35+
_find() { python3 "$LIBDIR/find_node.py" "$@" 2>/dev/null; }
36+
37+
tap() { # tap <needle> [idx] [-c] (-c = substring match)
38+
local needle="$1" idx="${2:-0}" flag="${3:-}" xy
39+
for _ in $(seq 1 12); do
40+
dump
41+
if [ "$flag" = "-c" ]; then xy=$(_find -c "$UIX" "$needle" "$idx"); else xy=$(_find "$UIX" "$needle" "$idx"); fi
42+
if [ -n "$xy" ]; then "${ADB[@]}" shell input tap $xy; return 0; fi
43+
sleep 0.5
44+
done
45+
echo " ! tap not found: '$needle'" >&2; return 1
46+
}
47+
have() { # have <needle> [-c] -> 0 if present on screen
48+
local needle="$1" flag="${2:-}"
49+
dump
50+
if [ "$flag" = "-c" ]; then _find -c "$UIX" "$needle" >/dev/null; else _find "$UIX" "$needle" >/dev/null; fi
51+
}
52+
swipe_up() { "${ADB[@]}" shell input swipe 540 1900 540 900 500; } # scroll down a list
53+
swipe_down() { "${ADB[@]}" shell input swipe 540 900 540 1900 500; }
54+
back() { "${ADB[@]}" shell input keyevent BACK; }
55+
pause() { sleep "${1:-1.6}"; }
56+
shade_open() { "${ADB[@]}" shell cmd statusbar expand-notifications; }
57+
shade_close() { "${ADB[@]}" shell cmd statusbar collapse; }
58+
# Captions may contain a literal "\n" to force a line break; postprocess.sh expands it.
59+
# Keep each line under ~34 characters or it overflows the 720px frame.
60+
# NOTE: the caption text goes through the environment, not `awk -v`. awk expands
61+
# escape sequences in -v assignments, which would turn the literal "\n" into a real
62+
# newline and break the one-caption-per-line format postprocess.sh parses.
63+
cap() {
64+
local now; now=$(date +%s.%N)
65+
CAP_TEXT="$1" awk -v a="$now" -v b="$REC_T0" 'BEGIN{printf "%.2f|%s\n", a-b, ENVIRON["CAP_TEXT"]}' >> "$CAPS"
66+
}
67+
68+
# ---- privacy hygiene for the recording --------------------------------------
69+
# The finished video is uploaded to Google. Anything in the notification shade or
70+
# the quick-settings strip is in frame: other apps' notifications, the Wi-Fi SSID,
71+
# the user's location and language. Clear and mute it for the take, restore after.
72+
notif_clear() {
73+
shade_open; pause 1.2
74+
tap "Clear all" >/dev/null 2>&1 || true
75+
pause 1
76+
shade_close; pause 1
77+
}
78+
dnd() { "${ADB[@]}" shell cmd notification set_dnd "$1" >/dev/null 2>&1; } # priority|off
79+
wifi() { "${ADB[@]}" shell svc wifi "$1" >/dev/null 2>&1; } # disable|enable
80+
81+
# ---- battery simulation -----------------------------------------------------
82+
# Only the battery *readings* are driven here — the service, the restore decision
83+
# and the settings write are the real app on real hardware. The current recording
84+
# uses unplug/reset only (pulling the cable of the device running the screen
85+
# capture is not an option); the level/status helpers exist for takes that need to
86+
# demonstrate the 100% stop condition instead.
87+
# BATTERY_STATUS_CHARGING=2, BATTERY_STATUS_FULL=5.
88+
bat_level() { "${ADB[@]}" shell dumpsys battery set level "$1" >/dev/null; }
89+
bat_status() { "${ADB[@]}" shell dumpsys battery set status "$1" >/dev/null; }
90+
bat_plug() { "${ADB[@]}" shell dumpsys battery set usb 1 >/dev/null; }
91+
bat_unplug() { "${ADB[@]}" shell dumpsys battery unplug >/dev/null; }
92+
bat_reset() { "${ADB[@]}" shell dumpsys battery reset >/dev/null; }
93+
94+
# ---- charge policy readback (Pixel) -----------------------------------------
95+
# charge_optimization_mode: 1 = the 80% limit, 0 = unrestricted/off.
96+
policy_mode() { "${ADB[@]}" shell settings get secure charge_optimization_mode | tr -d '\r'; }
97+
98+
# ---- app helpers ------------------------------------------------------------
99+
app_launch() { "${ADB[@]}" shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1; }
100+
app_stop() { "${ADB[@]}" shell am force-stop "$PKG"; }
101+
screen_wake() {
102+
"${ADB[@]}" shell input keyevent KEYCODE_WAKEUP
103+
"${ADB[@]}" shell wm dismiss-keyguard >/dev/null 2>&1
104+
}
105+
106+
# ---- recording control ------------------------------------------------------
107+
rec_start() {
108+
if [ "$NOREC" = "0" ]; then
109+
echo "Recording → $RAW"
110+
"${ADB[@]}" shell rm -f /sdcard/__demo.mp4
111+
"${ADB[@]}" shell screenrecord --size "$SIZE" --bit-rate 8000000 --time-limit 180 /sdcard/__demo.mp4 &
112+
REC_CLIENT=$!
113+
sleep 1.2
114+
else
115+
echo "NOREC=1 → validating taps without recording"
116+
fi
117+
REC_T0=$(date +%s.%N)
118+
}
119+
rec_stop() {
120+
if [ "$NOREC" = "0" ]; then
121+
"${ADB[@]}" shell pkill -INT screenrecord 2>/dev/null || true
122+
wait "$REC_CLIENT" 2>/dev/null || true
123+
sleep 1
124+
"${ADB[@]}" pull /sdcard/__demo.mp4 "$RAW" >/dev/null && echo "Saved $RAW"
125+
fi
126+
echo "Captions:"; cat "$CAPS"
127+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env python3
2+
"""Find a UI node by text or content-desc in a uiautomator XML dump and print
3+
the tap center as "x y". Exit 1 if not found.
4+
5+
Usage: find_node.py [-c] ui.xml "Needle" [match_index]
6+
-c substring (contains) match instead of exact
7+
"""
8+
import sys, re
9+
import xml.etree.ElementTree as ET
10+
11+
args = sys.argv[1:]
12+
contains = False
13+
if args and args[0] == "-c":
14+
contains = True
15+
args = args[1:]
16+
if len(args) < 2:
17+
sys.exit("usage: find_node.py [-c] ui.xml needle [index]")
18+
path, needle = args[0], args[1]
19+
idx = int(args[2]) if len(args) > 2 else 0
20+
21+
22+
def hit(v):
23+
return v is not None and (needle in v if contains else v == needle)
24+
25+
26+
root = ET.parse(path).getroot()
27+
matches = [n for n in root.iter("node") if hit(n.get("text")) or hit(n.get("content-desc"))]
28+
if idx < len(matches):
29+
m = re.findall(r"\d+", matches[idx].get("bounds"))
30+
print((int(m[0]) + int(m[2])) // 2, (int(m[1]) + int(m[3])) // 2)
31+
sys.exit(0)
32+
sys.exit(1)

0 commit comments

Comments
 (0)