Skip to content

Commit 971fdbc

Browse files
1.2.0: experimental lock screen pill
Value and coloured arrow at the bottom of the lock screen, drawn by an accessibility overlay since no other way exists to draw there. Off by default; it reads no window content, hides on the PIN pad, on unlock and when another app opens over the lock screen, and keeps the notification off the lock screen while it runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6973037 commit 971fdbc

15 files changed

Lines changed: 257 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ A tiny Android app that shows your glucose from **Nightscout** as a glass-style
1515
- **Readable graph.** Faint dashed lines mark your target range (you can hide them), and the scale either fits your data or stays fixed at a range you choose.
1616
- **Persistent notification** with value, trend arrow, 5-minute delta and age.
1717
- **Status bar number**, so you see your glucose without pulling the shade down.
18+
- **Lock screen pill** (experimental, off by default): value and coloured arrow at the bottom of the lock screen, between the torch and camera shortcuts. It hides when you open the PIN pad, unlock, or open the camera. Android lets no app draw there, so it uses an accessibility service: see Settings → Widget and graph in the app.
1819
- **mg/dL or mmol/L**, and your own urgent low / low / high / urgent high ranges.
1920
- **Light and dark**: follows the system, or pick one for the widget. On Android 12+ the glass picks up your wallpaper colours (Material You).
2021
- **Adjustable glow**, from off to vivid.
@@ -58,6 +59,7 @@ Sugarglass talks to your Nightscout site and nothing else. Your address and toke
5859
- If your site redirects somewhere else, Sugarglass stops and tells you the new address instead of following it with your credentials.
5960
- The settings are left out of Android's cloud backups and phone-to-phone transfers, so the token never leaves the phone; on a new phone you enter it again.
6061
- Prefer a token with only the `readable` role over the API secret: it can read your data but not change anything.
62+
- The lock screen pill is an accessibility service only because that is the one way to draw on the lock screen. It can't read what's on screen (no window content), never presses anything, and of other apps it only sees which one is in front, to step aside when the camera opens over the lock screen. It is off unless you turn it on.
6163

6264
Permissions: internet (Nightscout), notifications, foreground service (to stay alive), start at boot, and the request to be excluded from battery optimisation.
6365

‎app/build.gradle‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId 'io.github.federicomameli1.sugarglass'
1515
minSdk 26
1616
targetSdk 34
17-
versionCode 5
18-
versionName '1.1.2'
17+
versionCode 6
18+
versionName '1.2.0'
1919
}
2020

2121
signingConfigs {

‎app/src/main/AndroidManifest.xml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,18 @@
5353
android:name="android.appwidget.provider"
5454
android:resource="@xml/widget_info" />
5555
</receiver>
56+
<!-- Experimental lock screen pill: an accessibility service only so it may draw there -->
57+
<service
58+
android:name=".LockPill"
59+
android:exported="false"
60+
android:label="@string/lock_pill"
61+
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
62+
<intent-filter>
63+
<action android:name="android.accessibilityservice.AccessibilityService" />
64+
</intent-filter>
65+
<meta-data
66+
android:name="android.accessibilityservice"
67+
android:resource="@xml/lock_pill" />
68+
</service>
5669
</application>
5770
</manifest>

‎app/src/main/java/io/github/federicomameli1/sugarglass/GlucoseService.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public class GlucoseService extends Service {
6767
static volatile String status = "";
6868
/** Whether the last fetch failed, so the notification can say so without quoting the error. */
6969
static volatile boolean failing;
70+
/** Set by the lock screen pill while it runs: then the notification stays off the lock screen. */
71+
static volatile boolean pill;
7072
/** What the notification last showed that matters, so a dark screen isn't woken for an unchanged value. */
7173
private volatile String shown = "";
7274

@@ -355,7 +357,7 @@ private Notification notification() {
355357
.setOngoing(true)
356358
.setOnlyAlertOnce(true)
357359
.setShowWhen(false)
358-
.setVisibility(Notification.VISIBILITY_PUBLIC)
360+
.setVisibility(pill ? Notification.VISIBILITY_SECRET : Notification.VISIBILITY_PUBLIC)
359361
.setContentIntent(PendingIntent.getActivity(this, 0,
360362
new Intent(this, MainActivity.class), PendingIntent.FLAG_IMMUTABLE));
361363
List<Glucose.Reading> r = readings;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package io.github.federicomameli1.sugarglass;
2+
3+
import android.accessibilityservice.AccessibilityService;
4+
import android.app.KeyguardManager;
5+
import android.content.BroadcastReceiver;
6+
import android.content.Context;
7+
import android.content.Intent;
8+
import android.content.IntentFilter;
9+
import android.graphics.Bitmap;
10+
import android.graphics.Canvas;
11+
import android.graphics.Paint;
12+
import android.graphics.PixelFormat;
13+
import android.graphics.Typeface;
14+
import android.os.Handler;
15+
import android.os.Looper;
16+
import android.os.PowerManager;
17+
import android.view.Gravity;
18+
import android.view.WindowManager;
19+
import android.view.accessibility.AccessibilityEvent;
20+
import android.widget.ImageView;
21+
22+
import java.util.List;
23+
24+
/**
25+
* Experimental, off unless turned on in Accessibility: a small pill with the value and arrow at the bottom of the lock
26+
* screen. Apps cannot draw there; an accessibility overlay can. It reads nothing on screen
27+
* (no window content in its config), it only draws, and it never takes touches.
28+
*/
29+
public class LockPill extends AccessibilityService {
30+
31+
// ponytail: polls the lock state while shown, since HyperOS tells apps about a fingerprint unlock ~0.7 s
32+
// late. Only while the pill is up (screen on, locked), so it costs nothing the rest of the time
33+
private static final long TICK_MS = 100;
34+
35+
private final Handler handler = new Handler(Looper.getMainLooper());
36+
private ImageView view;
37+
private String drawn = "";
38+
private boolean pin; // the PIN pad is up: the pill would sit over it
39+
private boolean covered; // another app is shown over the lock screen
40+
41+
private final Runnable tick = new Runnable() {
42+
@Override
43+
public void run() {
44+
check();
45+
if (view != null) handler.postDelayed(this, TICK_MS);
46+
}
47+
};
48+
49+
private final BroadcastReceiver screen = new BroadcastReceiver() {
50+
@Override
51+
public void onReceive(Context context, Intent intent) {
52+
pin = false;
53+
covered = false;
54+
check();
55+
}
56+
};
57+
58+
@Override
59+
protected void onServiceConnected() {
60+
IntentFilter f = new IntentFilter(Intent.ACTION_SCREEN_ON);
61+
f.addAction(Intent.ACTION_SCREEN_OFF);
62+
f.addAction(Intent.ACTION_USER_PRESENT);
63+
registerReceiver(screen, f);
64+
GlucoseService.pill = true;
65+
check();
66+
}
67+
68+
@Override
69+
public void onAccessibilityEvent(AccessibilityEvent event) {
70+
CharSequence from = event.getPackageName();
71+
if (from == null || getPackageName().contentEquals(from)) return; // its own window showing up
72+
boolean system = "com.android.systemui".contentEquals(from);
73+
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
74+
// Something else over the lock screen (camera, a side panel app): out of its way.
75+
// Only the package name is read, never what the window says
76+
covered = !system;
77+
pin = false;
78+
} else if (system) {
79+
// On HyperOS the PIN pad shows as focus moving to its text field
80+
pin = "android.widget.EditText".contentEquals(event.getClassName());
81+
}
82+
check();
83+
}
84+
85+
/**
86+
* On the lock screen and not yet unlocked. A fingerprint or PIN makes the device unlocked before the
87+
* lock screen finishes going away, so with a secure lock that is the earlier, better signal.
88+
*/
89+
private boolean locked() {
90+
KeyguardManager k = getSystemService(KeyguardManager.class);
91+
return k.isKeyguardLocked() && (!k.isDeviceSecure() || k.isDeviceLocked())
92+
&& getSystemService(PowerManager.class).isInteractive();
93+
}
94+
95+
@Override
96+
public void onInterrupt() {
97+
}
98+
99+
@Override
100+
public void onDestroy() {
101+
unregisterReceiver(screen);
102+
GlucoseService.pill = false;
103+
hide();
104+
super.onDestroy();
105+
}
106+
107+
/** Shown only while the screen is on and locked, and there is something to show. */
108+
private void check() {
109+
boolean locked = locked();
110+
List<Glucose.Reading> r = GlucoseService.readings;
111+
if (!locked || pin || covered || r.isEmpty()) {
112+
hide();
113+
return;
114+
}
115+
long now = System.currentTimeMillis();
116+
Glucose.Reading last = r.get(0);
117+
boolean hidden = Glucose.hidden(last, now); // same rule as the widget: an old number never looks current
118+
Glucose.Config c = GlucoseService.config(this);
119+
String value = hidden ? "---" : Glucose.value(last.sgv, c);
120+
if (GlucoseService.demo(this)) value = "DEMO " + value; // made-up readings must never pass for real ones
121+
String direction = hidden || Glucose.stale(last, now) ? "" : last.direction;
122+
int hue = GlucoseService.color(this, last, now, c);
123+
String key = value + direction + hue;
124+
if (view == null) {
125+
view = new ImageView(this);
126+
WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
127+
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
128+
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
129+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
130+
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
131+
PixelFormat.TRANSLUCENT);
132+
lp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
133+
lp.y = Math.round(34 * getResources().getDisplayMetrics().density); // level with torch and camera
134+
getSystemService(WindowManager.class).addView(view, lp);
135+
drawn = "";
136+
handler.postDelayed(tick, TICK_MS);
137+
}
138+
if (!key.equals(drawn)) {
139+
view.setImageBitmap(pill(value, direction, hidden ? getColor(R.color.stale) : hue));
140+
view.setContentDescription(value + " " + Glucose.arrow(direction));
141+
drawn = key;
142+
}
143+
}
144+
145+
private void hide() {
146+
handler.removeCallbacks(tick);
147+
if (view == null) return;
148+
getSystemService(WindowManager.class).removeView(view);
149+
view = null;
150+
}
151+
152+
/** Dark glass pill, white value, arrow in the range colour. */
153+
private Bitmap pill(String value, String direction, int hue) {
154+
float d = getResources().getDisplayMetrics().density, h = 40 * d, pad = 16 * d, gap = 6 * d;
155+
Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
156+
text.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
157+
text.setTextSize(22 * d);
158+
text.setColor(0xFFFFFFFF);
159+
float arrow = 20 * d, arrowW = Art.arrowWidth(direction, arrow);
160+
float valueW = text.measureText(value), w = pad * 2 + valueW + (arrowW > 0 ? gap + arrowW : 0);
161+
Bitmap b = Bitmap.createBitmap(Math.round(w), Math.round(h), Bitmap.Config.ARGB_8888);
162+
Canvas canvas = new Canvas(b);
163+
Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
164+
fill.setColor(0x73000000);
165+
canvas.drawRoundRect(0, 0, w, h, h / 2, h / 2, fill);
166+
Paint edge = new Paint(Paint.ANTI_ALIAS_FLAG);
167+
edge.setStyle(Paint.Style.STROKE);
168+
edge.setStrokeWidth(1 * d);
169+
edge.setColor(0x47FFFFFF);
170+
canvas.drawRoundRect(d / 2, d / 2, w - d / 2, h - d / 2, h / 2, h / 2, edge);
171+
Paint.FontMetrics m = text.getFontMetrics();
172+
canvas.drawText(value, pad, h / 2 - (m.ascent + m.descent) / 2, text);
173+
Art.arrow(canvas, direction, pad + valueW + gap + arrowW / 2, h / 2, arrow, hue);
174+
return b;
175+
}
176+
}

‎app/src/main/java/io/github/federicomameli1/sugarglass/SettingsActivity.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,19 @@ public void afterTextChanged(android.text.Editable e) {
325325
applied();
326326
drawPreview();
327327
});
328+
// The lock screen pill is switched on in Android's accessibility settings, not here
329+
page.addView(label(R.string.lock_pill_title));
330+
TextView pillNote = text(13, secondary);
331+
pillNote.setText(R.string.lock_pill_note);
332+
page.addView(pillNote);
333+
button(R.string.lock_pill_open, () -> {
334+
try {
335+
startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS));
336+
} catch (RuntimeException e) {
337+
// nothing to open on this phone
338+
}
339+
});
340+
328341
// Look, glow and switches already show while you change them; Save stores the typed scale and closes
329342
button(R.string.save_short, () -> {
330343
if (fixed.isChecked()) {

‎app/src/main/res/values-de/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@
4646
<string name="notify_error">Nightscout nicht erreichbar. Details in der App</string>
4747
<string name="secret_kept">Gespeichert. Leer lassen, um es zu behalten</string>
4848
<string name="example">Beispiel</string>
49+
<string name="lock_pill">Sugarglass-Pille auf dem Sperrbildschirm</string>
50+
<string name="lock_pill_description">Zeigt den letzten Wert und den Pfeil unten auf dem Sperrbildschirm. Sie zeichnet nur: Sie liest den Bildschirm nicht und drückt nichts.</string>
51+
<string name="lock_pill_title">Pille auf dem Sperrbildschirm (experimentell)</string>
52+
<string name="lock_pill_note">Zeigt Wert und Pfeil unten auf dem Sperrbildschirm und nimmt die Benachrichtigung von dort weg. Aktiviere \"Sugarglass-Pille auf dem Sperrbildschirm\" unter Bedienungshilfen (manchmal unter Heruntergeladene Apps). Ist sie ausgegraut, erlaube zuerst eingeschränkte Einstellungen im Menü der App-Info. Sie zeichnet nur: Sie liest den Bildschirm nicht.</string>
53+
<string name="lock_pill_open">Bedienungshilfen öffnen</string>
4954
</resources>

‎app/src/main/res/values-es/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@
4646
<string name="notify_error">No se puede conectar con Nightscout. Abre la app para ver detalles</string>
4747
<string name="secret_kept">Guardado. Déjalo vacío para conservarlo</string>
4848
<string name="example">Ejemplo</string>
49+
<string name="lock_pill">Píldora de Sugarglass en la pantalla de bloqueo</string>
50+
<string name="lock_pill_description">Muestra el último valor y la flecha en la parte inferior de la pantalla de bloqueo. Solo dibuja: no lee la pantalla ni pulsa nada.</string>
51+
<string name="lock_pill_title">Píldora en la pantalla de bloqueo (experimental)</string>
52+
<string name="lock_pill_note">Muestra el valor y la flecha abajo en la pantalla de bloqueo y quita de ahí la notificación. Activa \"Píldora de Sugarglass en la pantalla de bloqueo\" en Accesibilidad (en algunos móviles en Aplicaciones descargadas). Si aparece en gris, primero permite los ajustes restringidos desde el menú de Información de la app. Solo dibuja: no lee la pantalla.</string>
53+
<string name="lock_pill_open">Abrir ajustes de accesibilidad</string>
4954
</resources>

‎app/src/main/res/values-fr/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@
4646
<string name="notify_error">Nightscout injoignable. Ouvrez l\'app pour les détails</string>
4747
<string name="secret_kept">Enregistré. Laissez vide pour le garder</string>
4848
<string name="example">Exemple</string>
49+
<string name="lock_pill">Pastille Sugarglass sur l\'écran de verrouillage</string>
50+
<string name="lock_pill_description">Affiche la dernière valeur et la flèche en bas de l\'écran de verrouillage. Elle ne fait que dessiner : elle ne lit pas l\'écran et n\'appuie sur rien.</string>
51+
<string name="lock_pill_title">Pastille sur l\'écran de verrouillage (expérimental)</string>
52+
<string name="lock_pill_note">Affiche la valeur et la flèche en bas de l\'écran de verrouillage, et en retire la notification. Activez « Pastille Sugarglass sur l\'écran de verrouillage » dans Accessibilité (parfois sous Applications téléchargées). Si c\'est grisé, autorisez d\'abord les paramètres restreints depuis le menu Infos sur l\'appli. Elle ne fait que dessiner : elle ne lit pas l\'écran.</string>
53+
<string name="lock_pill_open">Ouvrir les paramètres d\'accessibilité</string>
4954
</resources>

‎app/src/main/res/values-it/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@
4646
<string name="notify_error">Nightscout non raggiungibile. Apri l\'app per i dettagli</string>
4747
<string name="secret_kept">Salvato. Lascia vuoto per mantenerlo</string>
4848
<string name="example">Esempio</string>
49+
<string name="lock_pill">Pillola Sugarglass sulla schermata di blocco</string>
50+
<string name="lock_pill_description">Mostra l\'ultimo valore e la freccia in basso sulla schermata di blocco. Disegna e basta: non legge cosa c\'è sullo schermo e non preme nulla.</string>
51+
<string name="lock_pill_title">Pillola sulla schermata di blocco (sperimentale)</string>
52+
<string name="lock_pill_note">Mostra valore e freccia in basso sulla schermata di blocco, e toglie da lì la notifica. Attiva \"Pillola Sugarglass sulla schermata di blocco\" in Accessibilità (su alcuni telefoni sotto App scaricate). Se è grigia, prima consenti le impostazioni con restrizioni dal menu di Info app. Disegna e basta: non legge lo schermo.</string>
53+
<string name="lock_pill_open">Apri impostazioni di accessibilità</string>
4954
</resources>

0 commit comments

Comments
 (0)