GrayPause 1.4: grayscale color breaks for Android

Timers that switch grayscale off and restore it on their own: presets and
custom h/m/s timers, Quick Settings tile, launcher shortcuts, countdown
notification, reboot recovery, and optional always-in-color apps via an
accessibility service. WRITE_SECURE_SETTINGS is granted once via Shizuku
or adb.

Gradle project for F-Droid with fastlane metadata and a draft fdroiddata
recipe; build.sh keeps the Gradle-free on-phone Termux build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-10-04 20:07:49 +02:00
commit 5d7d84eaca
38 changed files with 2419 additions and 1 deletions

39
app/build.gradle Normal file
View file

@ -0,0 +1,39 @@
plugins {
id "com.android.application"
}
android {
namespace "com.lpw.graypause"
compileSdk 36
defaultConfig {
applicationId "com.lpw.graypause"
minSdk 33
targetSdk 36
versionCode 5
versionName "1.4"
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
// F-Droid: no Google-encrypted dependency metadata blob in the APK.
dependenciesInfo {
includeInApk false
includeInBundle false
}
}
dependencies {
def shizuku = "13.1.5"
implementation "dev.rikka.shizuku:api:$shizuku"
implementation "dev.rikka.shizuku:provider:$shizuku"
}

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Package, versions and SDK levels live in app/build.gradle (build.sh reads them from there). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<queries>
<package android:name="moe.shizuku.privileged.api" />
<!-- The color-apps picker lists launchable apps. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Target of the long-press launcher shortcuts: starts a break and closes, no UI. -->
<activity
android:name=".ShortcutActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.NoDisplay" />
<receiver android:name=".RestoreReceiver" android:exported="false" />
<receiver android:name=".BootReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".GrayTileService"
android:exported="true"
android:icon="@drawable/ic_tile"
android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE" android:value="false" />
</service>
<service
android:name=".AppWatchService"
android:exported="false"
android:label="GrayPause color apps"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
<!-- Shizuku hands its binder to the app through this provider. -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="com.lpw.graypause.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<meta-data android:name="moe.shizuku.client.V3_SUPPORT" android:value="true" />
</application>
</manifest>

View file

@ -0,0 +1,44 @@
package com.lpw.graypause;
import android.accessibilityservice.AccessibilityService;
import android.content.ComponentName;
import android.content.pm.PackageManager;
import android.view.accessibility.AccessibilityEvent;
/** Tracks the foreground app and hands each change to {@link ColorApps}. */
public class AppWatchService extends AccessibilityService {
/** Package of the activity in front, or null when the service is not running. */
static volatile String foreground;
@Override
public void onAccessibilityEvent(AccessibilityEvent e) {
if (e.getEventType() != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return;
if (e.getPackageName() == null || e.getClassName() == null) return;
String pkg = e.getPackageName().toString();
// Dialogs, keyboards, the notification shade and toasts also fire this event; only
// a real activity means the user switched apps.
if (!isActivity(pkg, e.getClassName().toString())) return;
if (pkg.equals(foreground)) return;
foreground = pkg;
ColorApps.onForeground(this, pkg);
}
private boolean isActivity(String pkg, String cls) {
try {
getPackageManager().getActivityInfo(new ComponentName(pkg, cls), 0);
return true;
} catch (PackageManager.NameNotFoundException ex) {
return false;
}
}
@Override
public void onInterrupt() {}
@Override
public boolean onUnbind(android.content.Intent intent) {
foreground = null;
ColorApps.onWatchStopped(this);
return super.onUnbind(intent);
}
}

View file

@ -0,0 +1,17 @@
package com.lpw.graypause;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/** Alarms do not survive a reboot, so a break that was running is re-armed or finished here. */
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context c, Intent intent) {
if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return;
long until = Gray.pausedUntil(c);
if (until == 0) return;
if (until <= System.currentTimeMillis()) Gray.resume(c);
else Gray.arm(c, until);
}
}

View file

@ -0,0 +1,101 @@
package com.lpw.graypause;
import android.content.ComponentName;
import android.content.Context;
import android.content.SharedPreferences;
import android.provider.Settings;
import java.util.HashSet;
import java.util.Set;
/**
* Apps that are always shown in color. Grayscale is switched off on entering one and back on
* on leaving, but only when this feature switched it off: a running timer, or grayscale the
* user turned off themselves, is left alone.
*/
final class ColorApps {
private ColorApps() {}
private static SharedPreferences prefs(Context c) {
return c.getSharedPreferences("state", Context.MODE_PRIVATE);
}
static Set<String> apps(Context c) {
return new HashSet<>(prefs(c).getStringSet("colorApps", new HashSet<>()));
}
static void setApps(Context c, Set<String> apps) {
prefs(c).edit().putStringSet("colorApps", apps).apply();
}
/** True while grayscale is off only because a color app is in front. */
private static boolean holding(Context c) {
return prefs(c).getBoolean("colorHold", false);
}
private static void setHolding(Context c, boolean on) {
prefs(c).edit().putBoolean("colorHold", on).commit();
}
static boolean inColorApp(Context c) {
String fg = AppWatchService.foreground;
return fg != null && apps(c).contains(fg);
}
static void onForeground(Context c, String pkg) {
if (!Gray.canWrite(c)) return;
if (apps(c).contains(pkg)) {
if (!holding(c) && !Gray.isPaused(c) && Gray.isGray(c)) {
setHolding(c, true);
Gray.setGray(c, false);
}
} else {
release(c);
}
}
/** A timer ended: stay in color if a color app is in front, and take over from the timer. */
static boolean keepColorAfterTimer(Context c) {
if (!inColorApp(c)) return false;
setHolding(c, true);
return true;
}
static void onWatchStopped(Context c) {
release(c);
}
private static void release(Context c) {
if (!holding(c)) return;
setHolding(c, false);
if (!Gray.isPaused(c)) Gray.setGray(c, true);
}
static ComponentName component(Context c) {
return new ComponentName(c, AppWatchService.class);
}
static boolean watchEnabled(Context c) {
String list = Settings.Secure.getString(c.getContentResolver(),
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
if (list == null) return false;
String me = component(c).flattenToString();
String shortMe = component(c).flattenToShortString();
for (String s : list.split(":")) {
if (s.equalsIgnoreCase(me) || s.equalsIgnoreCase(shortMe)) return true;
}
return false;
}
/** Switches the watcher on directly; works because the app may write secure settings. */
static boolean enableWatch(Context c) {
if (!Gray.canWrite(c)) return false;
String list = Settings.Secure.getString(c.getContentResolver(),
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
String me = component(c).flattenToString();
String updated = list == null || list.isEmpty() ? me : list + ":" + me;
Settings.Secure.putString(c.getContentResolver(),
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, updated);
Settings.Secure.putInt(c.getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED, 1);
return true;
}
}

View file

@ -0,0 +1,177 @@
package com.lpw.graypause;
import android.Manifest;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.provider.Settings;
import android.service.quicksettings.TileService;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
/** Grayscale state, the pause deadline, and everything that has to follow it. */
final class Gray {
// Same switches as Settings > Accessibility > Color correction > Grayscale.
private static final String KEY_ENABLED = "accessibility_display_daltonizer_enabled";
private static final String KEY_MODE = "accessibility_display_daltonizer";
private static final int MODE_MONOCHROMACY = 0;
static final String ACTION_RESTORE = "com.lpw.graypause.RESTORE";
static final String ACTION_EXTEND = "com.lpw.graypause.EXTEND";
private static final String CHANNEL = "pause";
private static final int NOTIFICATION_ID = 1;
private Gray() {}
static boolean canWrite(Context c) {
return c.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
== PackageManager.PERMISSION_GRANTED;
}
static boolean isGray(Context c) {
ContentResolver cr = c.getContentResolver();
return Settings.Secure.getInt(cr, KEY_ENABLED, 0) == 1
&& Settings.Secure.getInt(cr, KEY_MODE, -1) == MODE_MONOCHROMACY;
}
static void setGray(Context c, boolean on) {
ContentResolver cr = c.getContentResolver();
if (on) Settings.Secure.putInt(cr, KEY_MODE, MODE_MONOCHROMACY);
Settings.Secure.putInt(cr, KEY_ENABLED, on ? 1 : 0);
}
private static SharedPreferences prefs(Context c) {
return c.getSharedPreferences("state", Context.MODE_PRIVATE);
}
/** Wall-clock millis when grayscale comes back, or 0 if no break is running. */
static long pausedUntil(Context c) {
return prefs(c).getLong("until", 0);
}
static boolean isPaused(Context c) {
return pausedUntil(c) > 0;
}
/** Seconds the Quick Settings tile pauses for: the last duration picked anywhere. */
static int tileSeconds(Context c) {
return prefs(c).getInt("tileSeconds", 300);
}
static void pause(Context c, int seconds) {
prefs(c).edit().putInt("tileSeconds", seconds).apply();
startBreak(c, System.currentTimeMillis() + seconds * 1000L);
}
static void extend(Context c, int seconds) {
long base = Math.max(pausedUntil(c), System.currentTimeMillis());
startBreak(c, base + seconds * 1000L);
}
/** Starting list until the user edits it; every timer, these included, can be deleted. */
private static final String DEFAULT_TIMERS = "60,120,300,600,900,1800";
/** The user's timers, in seconds, ascending, without duplicates. */
static List<Integer> timers(Context c) {
SharedPreferences p = prefs(c);
String raw = p.getString("timers", null);
if (raw == null) raw = DEFAULT_TIMERS + "," + p.getString("custom", ""); // 1.1 kept customs apart
TreeSet<Integer> set = new TreeSet<>();
for (String s : raw.split(",")) {
if (!s.isEmpty()) set.add(Integer.parseInt(s));
}
return new ArrayList<>(set);
}
static void setTimers(Context c, List<Integer> timers) {
StringBuilder sb = new StringBuilder();
for (int t : timers) sb.append(sb.length() == 0 ? "" : ",").append(t);
prefs(c).edit().putString("timers", sb.toString()).remove("custom").apply();
Shortcuts.publish(c);
}
/** "45s", "5 min", "1h 30m", "2m 10s". */
static String label(int seconds) {
int h = seconds / 3600, m = seconds / 60 % 60, s = seconds % 60;
if (h == 0 && s == 0) return m + " min";
StringBuilder sb = new StringBuilder();
if (h > 0) sb.append(h).append("h ");
if (m > 0) sb.append(m).append("m ");
if (s > 0) sb.append(s).append("s ");
return sb.toString().trim();
}
private static void startBreak(Context c, long until) {
prefs(c).edit().putLong("until", until).commit();
setGray(c, false);
arm(c, until);
refreshTile(c);
}
/** Ends the break: grayscale back on, alarm and notification gone. */
static void resume(Context c) {
prefs(c).edit().remove("until").commit();
if (!ColorApps.keepColorAfterTimer(c)) setGray(c, true);
c.getSystemService(AlarmManager.class).cancel(restoreIntent(c));
c.getSystemService(NotificationManager.class).cancel(NOTIFICATION_ID);
refreshTile(c);
}
/** Schedules the restore alarm and posts the countdown. Also used after reboot. */
static void arm(Context c, long until) {
c.getSystemService(AlarmManager.class)
.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, restoreIntent(c));
showNotification(c, until);
}
private static PendingIntent restoreIntent(Context c) {
return receiverIntent(c, ACTION_RESTORE, 0);
}
private static PendingIntent receiverIntent(Context c, String action, int code) {
Intent i = new Intent(c, RestoreReceiver.class).setAction(action);
return PendingIntent.getBroadcast(c, code, i,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
}
static PendingIntent openApp(Context c) {
Intent i = new Intent(c, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_IMMUTABLE);
}
private static void showNotification(Context c, long until) {
NotificationManager nm = c.getSystemService(NotificationManager.class);
nm.createNotificationChannel(new NotificationChannel(
CHANNEL, "Color break", NotificationManager.IMPORTANCE_LOW));
Notification n = new Notification.Builder(c, CHANNEL)
.setSmallIcon(R.drawable.ic_tile)
.setContentTitle("Color break — grayscale returns soon")
.setWhen(until)
.setShowWhen(true)
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setOngoing(true)
.setContentIntent(openApp(c))
.addAction(new Notification.Action.Builder(null, "Grayscale now",
restoreIntent(c)).build())
.addAction(new Notification.Action.Builder(null, "+5 min",
receiverIntent(c, ACTION_EXTEND, 1)).build())
.build();
nm.notify(NOTIFICATION_ID, n);
}
static void refreshTile(Context c) {
TileService.requestListeningState(c, new ComponentName(c, GrayTileService.class));
}
}

View file

@ -0,0 +1,35 @@
package com.lpw.graypause;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
import java.text.DateFormat;
import java.util.Date;
/** Tap: start a color break of the last-used length. Tap during a break: grayscale now. */
public class GrayTileService extends TileService {
@Override
public void onStartListening() {
Tile t = getQsTile();
if (t == null) return;
if (Gray.isPaused(this)) {
t.setState(Tile.STATE_ACTIVE);
t.setSubtitle("until " + DateFormat.getTimeInstance(DateFormat.SHORT)
.format(new Date(Gray.pausedUntil(this))));
} else {
t.setState(Tile.STATE_INACTIVE);
t.setSubtitle(Gray.label(Gray.tileSeconds(this)));
}
t.updateTile();
}
@Override
public void onClick() {
if (!Gray.canWrite(this)) {
startActivityAndCollapse(Gray.openApp(this));
return;
}
if (Gray.isPaused(this)) Gray.resume(this);
else Gray.pause(this, Gray.tileSeconds(this));
onStartListening();
}
}

View file

@ -0,0 +1,448 @@
package com.lpw.graypause;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.NotificationManager;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.graphics.Insets;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.text.format.DateUtils;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.GridLayout;
import android.widget.LinearLayout;
import android.widget.NumberPicker;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import android.content.pm.ResolveInfo;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
public class MainActivity extends Activity {
private static final String GRANT_CMD =
"adb shell pm grant com.lpw.graypause android.permission.WRITE_SECURE_SETTINGS";
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable ticker = new Runnable() {
@Override public void run() {
refresh();
handler.postDelayed(this, 1000);
}
};
private TextView status;
private View secureSection;
private View notifSection;
private Button shizukuButton;
private TextView colorInfo;
private Button watchButton;
private Button appsButton;
private GridLayout durations;
private Button resumeButton;
private Button toggleButton;
@Override
protected void onCreate(Bundle saved) {
super.onCreate(saved);
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 0);
Shortcuts.publish(this);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
int pad = dp(20);
root.setPadding(pad, pad, pad, pad);
status = new TextView(this);
status.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22);
status.setGravity(Gravity.CENTER);
status.setPadding(0, dp(16), 0, dp(24));
root.addView(status);
LinearLayout secure = section(root, "Grayscale permission missing",
"GrayPause needs \"Write secure settings\" to switch grayscale. Android has no "
+ "switch for it in Settings, but it only has to be granted once.\n\n"
+ "Easiest: with the free Shizuku app running, tap the button below.\n"
+ "Or, from adb:\n" + GRANT_CMD);
shizukuButton = action(secure, "", v -> onShizukuButton());
action(secure, "Copy adb command", v -> {
getSystemService(ClipboardManager.class)
.setPrimaryClip(ClipData.newPlainText("adb", GRANT_CMD));
Toast.makeText(this, "Command copied", Toast.LENGTH_SHORT).show();
});
secureSection = secure;
LinearLayout notif = section(root, "Notifications are off",
"Without them you won't see the countdown, or the \"Grayscale now\" and "
+ "\"+5 min\" buttons, during a color break. Timers still work.");
action(notif, "Open notification settings", v -> open(
new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName())));
notifSection = notif;
TextView label = new TextView(this);
label.setText("Show colors for… (long-press a timer to delete it)");
label.setPadding(dp(6), 0, dp(6), dp(8));
root.addView(label);
durations = new GridLayout(this);
durations.setColumnCount(3);
root.addView(durations);
resumeButton = new Button(this);
resumeButton.setText("Grayscale now");
resumeButton.setOnClickListener(v -> {
Gray.resume(this);
refresh();
});
root.addView(resumeButton, fullWidth());
toggleButton = new Button(this);
toggleButton.setOnClickListener(v -> {
Gray.setGray(this, !Gray.isGray(this));
Gray.refreshTile(this);
refresh();
});
root.addView(toggleButton, fullWidth());
TextView colorTitle = new TextView(this);
colorTitle.setText("Color apps");
colorTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
colorTitle.setTypeface(Typeface.DEFAULT_BOLD);
colorTitle.setPadding(dp(6), dp(36), dp(6), 0);
root.addView(colorTitle);
colorInfo = new TextView(this);
colorInfo.setPadding(dp(6), dp(6), dp(6), 0);
root.addView(colorInfo);
watchButton = new Button(this);
watchButton.setText("Turn on app watcher");
watchButton.setOnClickListener(v -> {
if (!ColorApps.enableWatch(this)) {
open(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS));
}
handler.postDelayed(this::refresh, 500);
});
root.addView(watchButton, fullWidth());
appsButton = new Button(this);
appsButton.setOnClickListener(v -> pickColorApps());
root.addView(appsButton, fullWidth());
TextView hint = new TextView(this);
hint.setText("Tips: long-press the app icon for quick timers. "
+ "The \"Color break\" Quick Settings tile starts a break of the last length "
+ "you used; tap it again to end the break early.");
hint.setAlpha(0.7f);
hint.setPadding(dp(6), dp(32), dp(6), 0);
root.addView(hint);
ScrollView scroll = new ScrollView(this);
scroll.addView(root);
// targetSdk 35+ is edge-to-edge: keep content clear of the status and navigation bars.
scroll.setOnApplyWindowInsetsListener((v, insets) -> {
Insets i = insets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
v.setPadding(i.left, i.top, i.right, i.bottom);
return WindowInsets.CONSUMED;
});
setContentView(scroll);
buildDurations();
}
private void buildDurations() {
durations.removeAllViews();
for (int secs : Gray.timers(this)) {
Button b = gridButton(Gray.label(secs));
b.setOnClickListener(v -> {
Gray.pause(this, secs);
refresh();
});
b.setOnLongClickListener(v -> {
confirmDelete(secs);
return true;
});
durations.addView(b);
}
Button add = gridButton("+ Add");
add.setOnClickListener(v -> showCustomDialog());
durations.addView(add);
refresh();
}
private void confirmDelete(int secs) {
new AlertDialog.Builder(this)
.setMessage("Delete the " + Gray.label(secs) + " timer?")
.setPositiveButton("Delete", (d, w) -> {
List<Integer> list = Gray.timers(this);
list.remove(Integer.valueOf(secs));
Gray.setTimers(this, list);
buildDurations();
})
.setNegativeButton("Cancel", null)
.show();
}
private void showCustomDialog() {
LinearLayout row = new LinearLayout(this);
row.setGravity(Gravity.CENTER);
row.setPadding(0, dp(16), 0, 0);
NumberPicker h = picker(row, 23, "h");
NumberPicker m = picker(row, 59, "min");
NumberPicker s = picker(row, 59, "s");
m.setValue(5);
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("Custom timer")
.setView(row)
.setPositiveButton("Save", null)
.setNeutralButton("Start once", null)
.setNegativeButton("Cancel", null)
.create();
dialog.setOnShowListener(d -> {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
int secs = total(h, m, s);
if (secs == 0) return;
List<Integer> list = Gray.timers(this);
if (!list.contains(secs)) list.add(secs);
Gray.setTimers(this, list);
buildDurations();
dialog.dismiss();
});
dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> {
int secs = total(h, m, s);
if (secs == 0) return;
if (!Gray.canWrite(this)) {
Toast.makeText(this, "Finish the one-time setup first", Toast.LENGTH_SHORT).show();
return;
}
Gray.pause(this, secs);
refresh();
dialog.dismiss();
});
});
dialog.show();
}
private void pickColorApps() {
PackageManager pm = getPackageManager();
Intent launcher = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER);
TreeMap<String, String> byLabel = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (ResolveInfo r : pm.queryIntentActivities(launcher, 0)) {
String pkg = r.activityInfo.packageName;
if (pkg.equals(getPackageName())) continue;
byLabel.put(r.loadLabel(pm) + "\u0000" + pkg, pkg);
}
Set<String> chosen = ColorApps.apps(this);
// Chosen apps first, then everything else alphabetically.
List<String> pkgs = new ArrayList<>();
List<String> labels = new ArrayList<>();
for (int pass = 0; pass < 2; pass++) {
for (Map.Entry<String, String> e : byLabel.entrySet()) {
if (chosen.contains(e.getValue()) == (pass == 0)) {
pkgs.add(e.getValue());
labels.add(e.getKey().substring(0, e.getKey().indexOf('\u0000')));
}
}
}
boolean[] checked = new boolean[pkgs.size()];
for (int i = 0; i < checked.length; i++) checked[i] = chosen.contains(pkgs.get(i));
new AlertDialog.Builder(this)
.setTitle("Always in color")
.setMultiChoiceItems(labels.toArray(new String[0]), checked,
(d, which, on) -> checked[which] = on)
.setPositiveButton("Save", (d, w) -> {
Set<String> out = new HashSet<>();
for (int i = 0; i < checked.length; i++) if (checked[i]) out.add(pkgs.get(i));
ColorApps.setApps(this, out);
refresh();
})
.setNegativeButton("Cancel", null)
.show();
}
private static int total(NumberPicker h, NumberPicker m, NumberPicker s) {
return h.getValue() * 3600 + m.getValue() * 60 + s.getValue();
}
private NumberPicker picker(LinearLayout row, int max, String unit) {
NumberPicker p = new NumberPicker(this);
p.setMinValue(0);
p.setMaxValue(max);
row.addView(p);
TextView t = new TextView(this);
t.setText(unit);
t.setPadding(dp(4), 0, dp(16), 0);
row.addView(t);
return p;
}
@Override
protected void onResume() {
super.onResume();
handler.post(ticker);
}
@Override
protected void onPause() {
super.onPause();
handler.removeCallbacks(ticker);
}
private void refresh() {
if (status == null) return;
boolean ok = Gray.canWrite(this);
boolean paused = Gray.isPaused(this);
secureSection.setVisibility(ok ? View.GONE : View.VISIBLE);
if (!ok) shizukuButton.setText(shizukuLabel(ShizukuGrant.state(this)));
boolean notifOk = checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
&& getSystemService(NotificationManager.class).areNotificationsEnabled();
notifSection.setVisibility(notifOk ? View.GONE : View.VISIBLE);
for (int i = 0; i < durations.getChildCount() - 1; i++) {
durations.getChildAt(i).setEnabled(ok);
}
resumeButton.setVisibility(paused ? View.VISIBLE : View.GONE);
toggleButton.setVisibility(ok && !paused ? View.VISIBLE : View.GONE);
boolean watching = ColorApps.watchEnabled(this);
int n = ColorApps.apps(this).size();
watchButton.setVisibility(watching ? View.GONE : View.VISIBLE);
appsButton.setText(n == 0 ? "Choose color apps" : "Color apps (" + n + ")");
colorInfo.setText(watching
? "Grayscale switches off while one of these apps is open, and back on "
+ "when you leave it."
: "Keep chosen apps (gallery, camera…) in color. Needs the GrayPause app "
+ "watcher, an accessibility service that only sees which app is open.");
if (paused) {
long left = Math.max(0, Gray.pausedUntil(this) - System.currentTimeMillis());
status.setText("Colors on\ngrayscale returns in "
+ DateUtils.formatElapsedTime((left + 999) / 1000));
} else {
boolean gray = Gray.isGray(this);
status.setText(gray ? "Grayscale is on" : "Grayscale is off");
toggleButton.setText(gray ? "Turn grayscale off (no timer)" : "Turn grayscale on");
}
}
/** A warning card with a title and explanation; add buttons with {@link #action}. */
private LinearLayout section(LinearLayout parent, String title, String body) {
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(16), dp(16), dp(16), dp(10));
GradientDrawable bg = new GradientDrawable();
bg.setCornerRadius(dp(12));
bg.setStroke(dp(2), 0xFFFF9800);
box.setBackground(bg);
TextView t = new TextView(this);
t.setText(title);
t.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
t.setTypeface(Typeface.DEFAULT_BOLD);
box.addView(t);
TextView b = new TextView(this);
b.setText(body);
b.setTextIsSelectable(true);
b.setPadding(0, dp(8), 0, dp(8));
box.addView(b);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
lp.setMargins(dp(6), 0, dp(6), dp(20));
parent.addView(box, lp);
return box;
}
private Button action(LinearLayout box, String text, View.OnClickListener l) {
Button btn = new Button(this);
btn.setText(text);
btn.setOnClickListener(l);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
lp.setMargins(0, dp(6), 0, dp(6));
box.addView(btn, lp);
return btn;
}
private void onShizukuButton() {
switch (ShizukuGrant.state(this)) {
case NOT_INSTALLED:
open(new Intent(Intent.ACTION_VIEW, Uri.parse(ShizukuGrant.DOWNLOAD_URL)));
break;
case NOT_RUNNING:
Intent launch = getPackageManager().getLaunchIntentForPackage(ShizukuGrant.PACKAGE);
if (launch != null) open(launch);
break;
default:
shizukuButton.setEnabled(false);
ShizukuGrant.grant(this, (ok, msg) -> {
shizukuButton.setEnabled(true);
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
refresh();
});
}
}
private static String shizukuLabel(ShizukuGrant.State s) {
switch (s) {
case NOT_INSTALLED: return "Get Shizuku";
case NOT_RUNNING: return "Open Shizuku and start it";
default: return "Grant with Shizuku";
}
}
private void open(Intent intent) {
try {
startActivity(intent);
} catch (ActivityNotFoundException e) {
startActivity(new Intent(Settings.ACTION_SETTINGS));
}
}
private Button gridButton(String text) {
Button b = new Button(this);
b.setText(text);
b.setMinHeight(dp(56));
b.setSingleLine(true);
GridLayout.LayoutParams lp = new GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, 1f),
GridLayout.spec(GridLayout.UNDEFINED, 1f));
lp.width = 0;
lp.setMargins(dp(6), dp(6), dp(6), dp(6));
b.setLayoutParams(lp);
return b;
}
private LinearLayout.LayoutParams fullWidth() {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
lp.setMargins(dp(6), dp(20), dp(6), 0);
return lp;
}
private int dp(int v) {
return Math.round(v * getResources().getDisplayMetrics().density);
}
}

View file

@ -0,0 +1,16 @@
package com.lpw.graypause;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public class RestoreReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context c, Intent intent) {
if (Gray.ACTION_EXTEND.equals(intent.getAction())) {
Gray.extend(c, 300);
} else {
Gray.resume(c);
}
}
}

View file

@ -0,0 +1,81 @@
package com.lpw.graypause;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Handler;
import android.os.Looper;
import java.lang.reflect.Method;
import rikka.shizuku.Shizuku;
/**
* Grants WRITE_SECURE_SETTINGS once through Shizuku, which runs as the adb shell user.
* The grant persists, so Shizuku is not needed afterwards.
*/
final class ShizukuGrant {
static final String PACKAGE = "moe.shizuku.privileged.api";
static final String DOWNLOAD_URL = "https://shizuku.rikka.app/download/";
private static final int REQUEST_CODE = 42;
enum State { NOT_INSTALLED, NOT_RUNNING, NEEDS_PERMISSION, READY }
interface Callback { void done(boolean ok, String message); }
private ShizukuGrant() {}
static State state(Context c) {
try {
c.getPackageManager().getPackageInfo(PACKAGE, 0);
} catch (PackageManager.NameNotFoundException e) {
return State.NOT_INSTALLED;
}
if (!Shizuku.pingBinder() || Shizuku.isPreV11()) return State.NOT_RUNNING;
return Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
? State.READY : State.NEEDS_PERMISSION;
}
/** Asks Shizuku for access if needed, then runs the grant. The callback runs on the main thread. */
static void grant(Context c, Callback cb) {
State s = state(c);
if (s == State.READY) {
runGrant(c, cb);
} else if (s == State.NEEDS_PERMISSION) {
Shizuku.addRequestPermissionResultListener(new Shizuku.OnRequestPermissionResultListener() {
@Override public void onRequestPermissionResult(int code, int result) {
if (code != REQUEST_CODE) return;
Shizuku.removeRequestPermissionResultListener(this);
if (result == PackageManager.PERMISSION_GRANTED) runGrant(c, cb);
else cb.done(false, "Shizuku access was denied");
}
});
Shizuku.requestPermission(REQUEST_CODE);
} else {
cb.done(false, "Shizuku is not running");
}
}
private static void runGrant(Context c, Callback cb) {
Handler main = new Handler(Looper.getMainLooper());
new Thread(() -> {
String error = null;
try {
// Shizuku 13 made newProcess private in favour of UserService; for a one-off
// shell command it is still by far the simplest route.
Method m = Shizuku.class.getDeclaredMethod(
"newProcess", String[].class, String[].class, String.class);
m.setAccessible(true);
Process p = (Process) m.invoke(null, new String[]{"pm", "grant",
c.getPackageName(), Manifest.permission.WRITE_SECURE_SETTINGS}, null, null);
String err = new String(p.getErrorStream().readAllBytes()).trim();
if (p.waitFor() != 0) error = err.isEmpty() ? "pm grant failed" : err;
} catch (Exception e) {
Throwable t = e.getCause() != null ? e.getCause() : e;
error = t.toString();
}
boolean ok = error == null && Gray.canWrite(c);
String msg = ok ? "Permission granted, Shizuku is no longer needed"
: (error != null ? error : "Grant did not take effect");
main.post(() -> cb.done(ok, msg));
}).start();
}
}

View file

@ -0,0 +1,23 @@
package com.lpw.graypause;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Toast;
public class ShortcutActivity extends Activity {
static final String EXTRA_SECONDS = "seconds";
@Override
protected void onCreate(Bundle saved) {
super.onCreate(saved);
int secs = getIntent().getIntExtra(EXTRA_SECONDS, 0);
if (!Gray.canWrite(this) || secs <= 0) {
startActivity(new Intent(this, MainActivity.class));
} else {
Gray.pause(this, secs);
Toast.makeText(this, "Colors for " + Gray.label(secs), Toast.LENGTH_SHORT).show();
}
finish();
}
}

View file

@ -0,0 +1,36 @@
package com.lpw.graypause;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.drawable.Icon;
import java.util.ArrayList;
import java.util.List;
/** Long-press launcher shortcuts: one per timer, shortest first. */
final class Shortcuts {
private Shortcuts() {}
static void publish(Context c) {
ShortcutManager sm = c.getSystemService(ShortcutManager.class);
List<Integer> timers = Gray.timers(c);
int max = Math.min(sm.getMaxShortcutCountPerActivity(), timers.size());
List<ShortcutInfo> list = new ArrayList<>();
for (int i = 0; i < max; i++) {
int secs = timers.get(i);
Intent intent = new Intent(c, ShortcutActivity.class)
.setAction(Intent.ACTION_VIEW)
.putExtra(ShortcutActivity.EXTRA_SECONDS, secs);
String label = "Colors " + Gray.label(secs);
list.add(new ShortcutInfo.Builder(c, "t" + secs)
.setShortLabel(label)
.setLongLabel(label)
.setIcon(Icon.createWithResource(c, R.drawable.ic_shortcut))
.setIntent(intent)
.setRank(i)
.build());
}
sm.setDynamicShortcuts(list);
}
}

View file

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#FF202020" android:pathData="M24,0A24,24 0 1,0 24,48A24,24 0 1,0 24,0Z" />
<path android:fillColor="#FFE0E0E0" android:pathData="M24,8A16,16 0 0,0 24,40Z" />
<path android:fillColor="#FFFF9800" android:pathData="M24,8A16,16 0 0,1 24,40Z" />
</vector>

View file

@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFF9800"
android:pathData="M12,2A10,10 0 1,0 12,22A10,10 0 1,0 12,2ZM12,4A8,8 0 1,1 12,20A8,8 0 1,1 12,4ZM11,7L11,13L16,16L16.8,14.7L12.5,12.2L12.5,7Z" />
</vector>

View file

@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF"
android:pathData="M12,2A10,10 0 1,0 12,22A10,10 0 1,0 12,2ZM12,4L12,20A8,8 0 0,1 12,4Z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">GrayPause</string>
<string name="tile_label">Color break</string>
<string name="a11y_description">Watches which app is in front so grayscale can be switched off while you use the apps you chose in GrayPause (for example your gallery or camera). It reads only the app name, never screen content.</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="200"
android:canRetrieveWindowContent="false"
android:description="@string/a11y_description" />