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:
parent
64aa7adc99
commit
5d7d84eaca
38 changed files with 2419 additions and 1 deletions
39
app/build.gradle
Normal file
39
app/build.gradle
Normal 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"
|
||||
}
|
||||
86
app/src/main/AndroidManifest.xml
Normal file
86
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
44
app/src/main/java/com/lpw/graypause/AppWatchService.java
Normal file
44
app/src/main/java/com/lpw/graypause/AppWatchService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/com/lpw/graypause/BootReceiver.java
Normal file
17
app/src/main/java/com/lpw/graypause/BootReceiver.java
Normal 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);
|
||||
}
|
||||
}
|
||||
101
app/src/main/java/com/lpw/graypause/ColorApps.java
Normal file
101
app/src/main/java/com/lpw/graypause/ColorApps.java
Normal 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;
|
||||
}
|
||||
}
|
||||
177
app/src/main/java/com/lpw/graypause/Gray.java
Normal file
177
app/src/main/java/com/lpw/graypause/Gray.java
Normal 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));
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/lpw/graypause/GrayTileService.java
Normal file
35
app/src/main/java/com/lpw/graypause/GrayTileService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
448
app/src/main/java/com/lpw/graypause/MainActivity.java
Normal file
448
app/src/main/java/com/lpw/graypause/MainActivity.java
Normal 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);
|
||||
}
|
||||
}
|
||||
16
app/src/main/java/com/lpw/graypause/RestoreReceiver.java
Normal file
16
app/src/main/java/com/lpw/graypause/RestoreReceiver.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/src/main/java/com/lpw/graypause/ShizukuGrant.java
Normal file
81
app/src/main/java/com/lpw/graypause/ShizukuGrant.java
Normal 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();
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/com/lpw/graypause/ShortcutActivity.java
Normal file
23
app/src/main/java/com/lpw/graypause/ShortcutActivity.java
Normal 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();
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/lpw/graypause/Shortcuts.java
Normal file
36
app/src/main/java/com/lpw/graypause/Shortcuts.java
Normal 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);
|
||||
}
|
||||
}
|
||||
7
app/src/main/res/drawable/ic_launcher.xml
Normal file
7
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/ic_shortcut.xml
Normal file
6
app/src/main/res/drawable/ic_shortcut.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/ic_tile.xml
Normal file
6
app/src/main/res/drawable/ic_tile.xml
Normal 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>
|
||||
6
app/src/main/res/values/strings.xml
Normal file
6
app/src/main/res/values/strings.xml
Normal 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>
|
||||
7
app/src/main/res/values/styles.xml
Normal file
7
app/src/main/res/values/styles.xml
Normal 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>
|
||||
7
app/src/main/res/xml/accessibility_config.xml
Normal file
7
app/src/main/res/xml/accessibility_config.xml
Normal 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" />
|
||||
Loading…
Reference in a new issue