row) {
- return !row.isEmpty() && row.get(0).isNavigation;
- }
-
- private void notifyService() {
- DistractionControlService service = DistractionControlService.getInstance();
- if (service != null) {
- service.updateRules();
- }
- }
-
- /**
- * Collapses rules that share a comment into a single row.
- *
- * Some things a user thinks of as one switch need more than one rule to
- * hide -- Instagram's feed, for instance, is two sibling containers with
- * different classes. Giving those rules the same comment is what marks
- * them as parts of one whole; the row then acts on all of its parts at
- * once, so a half-hidden feed is not a state the UI can produce.
- *
- *
Merging deliberately ignores category, so parts filed under different
- * categories still merge rather than silently appearing as two half-rows.
- * Rules without a comment never merge: they fall back to a shared
- * placeholder name, which would otherwise collapse every unlabelled custom
- * rule into one row.
- *
- *
A custom rule never merges with a built-in one even if they share a
- * comment: a mixed row can't be deleted (the delete path requires every
- * part to be custom) and toggling it would silently flip the user's own
- * rule along with the built-in one.
- *
- *
Navigation rules never merge with blocking ones for the same reason
- * squared: the two are stored under different preference keys, so a mixed
- * row's switch would have to write to both stores at once, and deleting it
- * would have to remove from both.
- */
- static List> mergeRules(List rules) {
- List> rows = new ArrayList<>();
- Map> byComment = new java.util.LinkedHashMap<>();
- for (FilterRule rule : rules) {
- String comment = rule.description == null ? "" : rule.description.trim();
- // Navigation rules never merge. Merging exists so that one switch can back the
- // several rules it takes to hide one thing; opening a screen is a single click, and
- // only one navigation rule per app runs anyway, so a merged row would promise to
- // enable rules that could not all be on at once.
- if (comment.isEmpty() || rule.isNavigation) {
- List row = new ArrayList<>();
- row.add(rule);
- rows.add(row);
- continue;
- }
- String key = rule.isCustom + " " + comment;
- List row = byComment.get(key);
- if (row == null) {
- row = new ArrayList<>();
- byComment.put(key, row);
- rows.add(row);
- }
- row.add(rule);
- }
- return rows;
- }
-
- private Map>> groupRows(List> rows) {
- Map>> groups = new java.util.LinkedHashMap<>();
- for (List row : rows) {
- groups.computeIfAbsent(getRuleGroup(row.get(0)), k -> new ArrayList<>()).add(row);
- }
- return groups;
- }
-
- static boolean isRowEnabled(List row) {
- for (FilterRule rule : row) {
- if (!rule.enabled) {
- return false;
- }
- }
- return true;
- }
-
- private String getRuleGroup(FilterRule rule) {
- // Forced rather than taken from the rule text: picker-built navigation rules carry no
- // category, and scattering them under "Custom rules" alongside things that hide would
- // lose the one distinction that matters here -- these act on the app, they do not
- // merely hide part of it.
- if (rule.isNavigation) {
- return context.getString(R.string.auto_navigation_title);
- }
- if (rule.category != null && !rule.category.trim().isEmpty()) {
- return rule.category.trim();
- }
- if (rule.isCustom) {
- return context.getString(R.string.rule_group_custom);
- }
- return context.getString(R.string.rule_group_other);
- }
-
- private String getRuleDisplayName(FilterRule rule) {
- if (rule.description != null && !rule.description.trim().isEmpty()) {
- return rule.description;
- }
- if (rule.isCustom) {
- return context.getString(R.string.rule_custom_fallback);
- }
- return context.getString(R.string.rule_builtin_fallback);
- }
-
- /** Counts merged rows rather than rules, to match what the section shows. */
- private String getRuleGroupSummary(List> rows) {
- int enabledCount = 0;
- for (List row : rows) {
- if (isRowEnabled(row)) {
- enabledCount++;
- }
- }
- if (enabledCount == 0) {
- return context.getResources().getQuantityString(R.plurals.disabled_rule_count,
- rows.size(), rows.size());
- }
- if (enabledCount == rows.size()) {
- return context.getResources().getQuantityString(R.plurals.active_rule_count,
- enabledCount, enabledCount);
- }
- return context.getString(R.string.active_rule_fraction, enabledCount, rows.size());
- }
-
- private boolean isRuleGroupExpanded(String packageName, String title) {
- return collapsePrefs.getBoolean(getRuleGroupExpandedKey(packageName, title), false);
- }
-
- private String getRuleGroupExpandedKey(String packageName, String title) {
- return "rule_group_expanded_" + packageName + "_" + title;
- }
-
- @Override
- public int getItemCount() {
- return items.size();
- }
-
- // Item classes for different view types
- private static class AppHeaderItem {
- final String packageName;
- /** Enabled rows that hide something. Navigation rows are counted separately. */
- final int ruleCount;
- final int totalRuleCount;
- final int navigationCount;
-
- AppHeaderItem(String packageName, int ruleCount, int totalRuleCount,
- int navigationCount) {
- this.packageName = packageName;
- this.ruleCount = ruleCount;
- this.totalRuleCount = totalRuleCount;
- this.navigationCount = navigationCount;
- }
- }
-
- /**
- * One row of the rules list. Usually a single rule, but rules sharing a
- * comment are presented -- and acted on -- as one; see {@link #mergeRules}.
- */
- private static class RuleItem {
- final List parts;
-
- RuleItem(List parts) {
- this.parts = parts;
- }
-
- FilterRule primary() {
- return parts.get(0);
- }
- }
-
- private static class FooterItem {
- }
-
- private static class RuleSectionItem {
- final String packageName;
- final String title;
- final List> rows;
- final boolean expanded;
-
- RuleSectionItem(String packageName, String title, List> rows,
- boolean expanded) {
- this.packageName = packageName;
- this.title = title;
- this.rows = rows;
- this.expanded = expanded;
- }
- }
-
- static class AppHeaderViewHolder extends RecyclerView.ViewHolder {
- TextView appName;
- TextView packageName;
- ImageView appIcon;
- MaterialSwitch packageSwitch;
-
- AppHeaderViewHolder(View itemView) {
- super(itemView);
- appName = itemView.findViewById(R.id.app_name);
- packageName = itemView.findViewById(R.id.package_name);
- appIcon = itemView.findViewById(R.id.app_icon);
- packageSwitch = itemView.findViewById(R.id.package_switch);
- }
- }
-
- static class FooterViewHolder extends RecyclerView.ViewHolder {
- final TextView footerText;
- final TextView recruitmentText;
-
- FooterViewHolder(View itemView) {
- super(itemView);
- footerText = itemView.findViewById(R.id.footer_text);
- recruitmentText = itemView.findViewById(R.id.recruitment_text);
- }
- }
-
- static class RuleSectionViewHolder extends RecyclerView.ViewHolder {
- final TextView sectionTitle;
- final TextView sectionCount;
- final TextView sectionIndicator;
-
- RuleSectionViewHolder(View itemView) {
- super(itemView);
- sectionTitle = itemView.findViewById(R.id.rule_section_title);
- sectionCount = itemView.findViewById(R.id.rule_section_count);
- sectionIndicator = itemView.findViewById(R.id.rule_section_indicator);
- }
- }
-
- public static class RuleViewHolder extends RecyclerView.ViewHolder {
- final TextView ruleDescription;
- final TextView ruleDetails;
- final MaterialSwitch ruleSwitch;
- // Removed position field
-
- RuleViewHolder(View itemView) {
- super(itemView);
- ruleDescription = itemView.findViewById(R.id.rule_description);
- ruleDetails = itemView.findViewById(R.id.rule_details);
- ruleSwitch = itemView.findViewById(R.id.rule_switch);
- }
- }
-}
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/ServiceConfig.java b/app/src/main/java/net/kollnig/greasemilkyway/ServiceConfig.java
index fb71d54..07f8a9e 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/ServiceConfig.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/ServiceConfig.java
@@ -14,7 +14,10 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
/**
* Manages configuration for the LayoutDumpAccessibilityService.
@@ -380,6 +383,39 @@ public void removeCustomNavigationRule(String ruleString) {
}
}
+ /** Renames all blocking-rule parts behind one merged custom row atomically. */
+ public void renameCustomRules(String[] ruleStrings, String newComment) {
+ renameCustomRules(KEY_CUSTOM_RULES, ruleStrings, newComment);
+ }
+
+ /** Renames all navigation-rule parts behind one merged custom row atomically. */
+ public void renameCustomNavigationRules(String[] ruleStrings, String newComment) {
+ renameCustomRules(KEY_CUSTOM_NAVIGATION_RULES, ruleStrings, newComment);
+ }
+
+ private void renameCustomRules(String preferenceKey, String[] ruleStrings, String newComment) {
+ if (ruleStrings == null || ruleStrings.length == 0 || newComment == null) {
+ throw new IllegalArgumentException("Rules and comment are required");
+ }
+ Set oldRules = new HashSet<>(Arrays.asList(ruleStrings));
+ String stored = prefs.getString(preferenceKey, "");
+ if (stored.isEmpty()) {
+ return;
+ }
+
+ String[] lines = stored.split("\\n");
+ boolean changed = false;
+ for (int i = 0; i < lines.length; i++) {
+ if (oldRules.contains(lines[i])) {
+ lines[i] = RuleText.withComment(lines[i], newComment);
+ changed = true;
+ }
+ }
+ if (changed) {
+ prefs.edit().putString(preferenceKey, String.join("\n", lines)).apply();
+ }
+ }
+
public boolean isNavigationRuleEnabled(FilterRule rule) {
// Opt-in, like blocking rules: nothing starts moving the user around unasked.
return prefs.getBoolean(KEY_NAVIGATION_RULE_ENABLED + ruleKeySuffix(rule), false);
@@ -425,6 +461,41 @@ public boolean setNavigationRuleEnabled(FilterRule rule, boolean enabled) {
return displaced;
}
+ /** Clears every saved navigation selection for one app in a single preference update. */
+ public void disableAllNavigationRules(String packageName) {
+ if (packageName == null) {
+ throw new IllegalArgumentException("Package name is required");
+ }
+
+ SharedPreferences.Editor editor = prefs.edit();
+ for (FilterRule rule : getNavigationRules()) {
+ if (packageName.equals(rule.packageName)) {
+ editor.putBoolean(KEY_NAVIGATION_RULE_ENABLED + ruleKeySuffix(rule), false);
+ }
+ }
+ editor.apply();
+ }
+
+ /**
+ * Pauses the supplied packages as one preference update. A package pause is represented by
+ * both its disabled master switch and its expiry timestamp, so neither can be written alone.
+ */
+ public void pausePackagesUntil(Iterable packageNames, long untilMillis) {
+ if (untilMillis <= System.currentTimeMillis()) {
+ throw new IllegalArgumentException("Pause expiry must be in the future");
+ }
+
+ SharedPreferences.Editor editor = prefs.edit();
+ for (String packageName : packageNames) {
+ if (packageName == null || packageName.isEmpty()) {
+ throw new IllegalArgumentException("Package name is required");
+ }
+ editor.putBoolean(KEY_PACKAGE_DISABLED + packageName, true);
+ editor.putLong(KEY_PAUSE_UNTIL_PACKAGE_ + packageName, untilMillis);
+ }
+ editor.apply();
+ }
+
/**
* Enables a package and restores the per-rule state shown underneath it.
*
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/SettingsActivity.java b/app/src/main/java/net/kollnig/greasemilkyway/SettingsActivity.java
index 232514b..ab7e426 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/SettingsActivity.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/SettingsActivity.java
@@ -18,6 +18,7 @@
import com.google.android.material.appbar.MaterialToolbar;
import net.kollnig.distractionlib.FrictionGateActivity;
+import net.kollnig.distractionlib.FilterRuleParser;
public class SettingsActivity extends AppCompatActivity {
@@ -25,6 +26,7 @@ public class SettingsActivity extends AppCompatActivity {
private TextView tvFrictionGateSubtitle;
private TextView tvPauseDurationSubtitle;
private TextView tvNotificationTimeoutSubtitle;
+ private TextView tvCustomRulesSubtitle;
private Runnable pendingFrictionAction;
private final ActivityResultLauncher frictionGateLauncher =
@@ -67,6 +69,7 @@ private void initViews() {
tvFrictionGateSubtitle = findViewById(R.id.tv_friction_gate_subtitle);
tvPauseDurationSubtitle = findViewById(R.id.tv_pause_duration_subtitle);
tvNotificationTimeoutSubtitle = findViewById(R.id.tv_notification_timeout_subtitle);
+ tvCustomRulesSubtitle = findViewById(R.id.tv_custom_rules_subtitle);
updateSubtitles();
@@ -74,20 +77,24 @@ private void initViews() {
findViewById(R.id.btn_friction_gate).setOnClickListener(v -> runWithFrictionGate(
getString(R.string.unlock_friction_settings),
- () -> showNumberPickerDialog("Friction Gate Words", "Choose number of words (0-15)", 0, 15, config.getFrictionWordCount(), newValue -> {
+ () -> showNumberPickerDialog(getString(R.string.friction_gate_dialog_title),
+ getString(R.string.friction_gate_dialog_message), 0, 15,
+ config.getFrictionWordCount(), newValue -> {
config.setFrictionWordCount(newValue);
updateSubtitles();
})));
findViewById(R.id.btn_pause_duration).setOnClickListener(v -> runWithFrictionGate(
getString(R.string.unlock_friction_settings),
- () -> showNumberPickerDialog("Pause Duration", "Choose default pause in minutes (1-120)", 1, 120, config.getPauseDurationMins(), newValue -> {
+ () -> showNumberPickerDialog(getString(R.string.pause_duration_dialog_title),
+ getString(R.string.pause_duration_dialog_message), 1, 120,
+ config.getPauseDurationMins(), newValue -> {
config.setPauseDurationMins(newValue);
updateSubtitles();
})));
findViewById(R.id.btn_notification_timeout).setOnClickListener(v -> {
- final String[] labels = {"Immediate response", "Default (recommended)", "Battery saver"};
+ final String[] labels = getResources().getStringArray(R.array.response_speed_choices);
final long[] values = {0, 100, 300};
long current = config.getNotificationTimeoutMs();
int checkedItem = 1;
@@ -98,7 +105,7 @@ private void initViews() {
}
}
new AlertDialog.Builder(this)
- .setTitle("Response Speed")
+ .setTitle(R.string.response_speed_dialog_title)
.setSingleChoiceItems(labels, checkedItem, (dialog, which) -> {
config.setNotificationTimeoutMs(values[which]);
updateSubtitles();
@@ -129,23 +136,45 @@ private void runWithFrictionGate(String contextTitle, Runnable action) {
private void updateSubtitles() {
if (tvFrictionGateSubtitle != null) {
- tvFrictionGateSubtitle.setText(getString(R.string.friction_gate_words, config.getFrictionWordCount()));
+ int words = config.getFrictionWordCount();
+ tvFrictionGateSubtitle.setText(getResources().getQuantityString(
+ R.plurals.friction_gate_word_count, words, words));
}
if (tvPauseDurationSubtitle != null) {
- tvPauseDurationSubtitle.setText(getString(R.string.pause_duration_minutes, config.getPauseDurationMins()));
+ int minutes = config.getPauseDurationMins();
+ tvPauseDurationSubtitle.setText(getResources().getQuantityString(
+ R.plurals.pause_duration_minute_count, minutes, minutes));
}
if (tvNotificationTimeoutSubtitle != null) {
long ms = config.getNotificationTimeoutMs();
String label;
if (ms <= 0) {
- label = getString(R.string.response_speed_immediate);
+ label = getString(R.string.response_speed_immediate_consequence);
} else if (ms >= 300) {
- label = getString(R.string.response_speed_battery_saver);
+ label = getString(R.string.response_speed_battery_consequence);
} else {
- label = getString(R.string.response_speed_default);
+ label = getString(R.string.response_speed_default_consequence);
}
tvNotificationTimeoutSubtitle.setText(label);
}
+ if (tvCustomRulesSubtitle != null) {
+ int count = 0;
+ FilterRuleParser parser = new FilterRuleParser();
+ String[] blocking = config.getCustomRules();
+ String[] navigation = config.getCustomNavigationRules();
+ if (blocking != null) count += parser.parseRules(blocking).size();
+ if (navigation != null) count += parser.parseRules(navigation).size();
+ String ruleCount = getResources().getQuantityString(
+ R.plurals.rule_count, count, count);
+ tvCustomRulesSubtitle.setText(
+ getString(R.string.custom_rules_count_summary, ruleCount));
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (config != null) updateSubtitles();
}
private void showNumberPickerDialog(String title, String message, int min, int max, int currentValue, final NumberPickerCallback callback) {
diff --git a/app/src/main/res/layout/activity_app_detail.xml b/app/src/main/res/layout/activity_app_detail.xml
new file mode 100644
index 0000000..28dc87a
--- /dev/null
+++ b/app/src/main/res/layout/activity_app_detail.xml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_custom_rules.xml b/app/src/main/res/layout/activity_custom_rules.xml
index 90f9105..f64c987 100644
--- a/app/src/main/res/layout/activity_custom_rules.xml
+++ b/app/src/main/res/layout/activity_custom_rules.xml
@@ -4,7 +4,7 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:fitsSystemWindows="true">
+ android:fitsSystemWindows="false">
-
-
-
-
+
+
+
+
-
-
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:minHeight="48dp"
+ android:text="@string/custom_rules_add" />
-
+
+
+
+
+
+
+
+ android:orientation="vertical">
+
+
+
+
-
+
+
-
-
+
+
+
+
-
+ android:layout_marginBottom="8dp"
+ android:minHeight="48dp"
+ android:text="@string/custom_rules_back_to_list" />
-
-
-
-
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index 6e81d4c..4a9af5f 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -4,24 +4,20 @@
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:fitsSystemWindows="false"
- android:background="@color/background_main">
+ android:fitsSystemWindows="false">
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
+
-
-
+ android:minHeight="64dp"
+ android:background="?attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+
+
+
-
diff --git a/app/src/main/res/layout/dialog_custom_rule_edit.xml b/app/src/main/res/layout/dialog_custom_rule_edit.xml
new file mode 100644
index 0000000..41fb93d
--- /dev/null
+++ b/app/src/main/res/layout/dialog_custom_rule_edit.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_app_group.xml b/app/src/main/res/layout/item_app_group.xml
index ff4d2a1..99ead5d 100644
--- a/app/src/main/res/layout/item_app_group.xml
+++ b/app/src/main/res/layout/item_app_group.xml
@@ -21,7 +21,7 @@
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp"
android:textStyle="bold"
- app:layout_constraintEnd_toStartOf="@id/package_switch"
+ app:layout_constraintEnd_toStartOf="@id/app_chevron"
app:layout_constraintStart_toEndOf="@id/app_icon"
app:layout_constraintTop_toTopOf="parent" />
@@ -33,7 +33,7 @@
android:layout_marginEnd="16dp"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp"
- app:layout_constraintEnd_toStartOf="@id/package_switch"
+ app:layout_constraintEnd_toStartOf="@id/app_chevron"
app:layout_constraintStart_toEndOf="@id/app_icon"
app:layout_constraintTop_toBottomOf="@id/app_name" />
@@ -51,4 +51,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
-
\ No newline at end of file
+
+
+
diff --git a/app/src/main/res/layout/item_custom_rule.xml b/app/src/main/res/layout/item_custom_rule.xml
new file mode 100644
index 0000000..7d97184
--- /dev/null
+++ b/app/src/main/res/layout/item_custom_rule.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_custom_rule_group.xml b/app/src/main/res/layout/item_custom_rule_group.xml
new file mode 100644
index 0000000..dbaf686
--- /dev/null
+++ b/app/src/main/res/layout/item_custom_rule_group.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/src/main/res/layout/item_navigation_option.xml b/app/src/main/res/layout/item_navigation_option.xml
new file mode 100644
index 0000000..766d408
--- /dev/null
+++ b/app/src/main/res/layout/item_navigation_option.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/app/src/main/res/layout/item_rule_section.xml b/app/src/main/res/layout/item_rule_section.xml
index 3339929..8ec299f 100644
--- a/app/src/main/res/layout/item_rule_section.xml
+++ b/app/src/main/res/layout/item_rule_section.xml
@@ -32,15 +32,24 @@
app:layout_constraintBaseline_toBaselineOf="@id/rule_section_title"
app:layout_constraintEnd_toStartOf="@id/rule_section_indicator" />
+
+
+ app:layout_constraintEnd_toStartOf="@id/rule_section_switch" />
diff --git a/app/src/main/res/layout/item_status_card.xml b/app/src/main/res/layout/item_status_card.xml
new file mode 100644
index 0000000..f05eeb4
--- /dev/null
+++ b/app/src/main/res/layout/item_status_card.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index b9ac2b6..206fabd 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -22,6 +22,7 @@
#34D399
+ #FBBF24
#7F1D1D
@@ -31,4 +32,3 @@
#FF000000
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 112157f..ff9d138 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -22,6 +22,7 @@
#34C759
+ #B45309
#FEE2E2
@@ -37,4 +38,4 @@
#FF03DAC5
#FF018786
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index def7e29..9f9d40b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -45,8 +45,8 @@
Tap "Allow"
- - %d UI element hidden
- - %d UI elements hidden
+ - %d hidden
+ - %d hidden
- %d rule
@@ -60,7 +60,27 @@
- %d disabled
- %d disabled
- Click to hide elements
+ Off — nothing hidden
+ Enable all rules for this app
+ Focus is on
+ Focus is not running
+ Tap to enable accessibility access
+ %1$d active rules in %2$d apps · %3$d paused
+ Paused · resumes %1$s
+ %1$d hidden · opens in %2$s
+ Not installed · %1$d apps
+ Pause %1$s
+ Made with ❤️ by reddfocus.org
+ Blocking is active
+ Paused
+ Pause 15 min
+ 1 hour
+ Today
+ Pause app
+ Open normally
+ Turn this category on or off
+ Disable %1$s
+ Disable rule
Not installed
No rules active
Opens on launch
@@ -84,6 +104,8 @@
Element Picker
Allows you to select and hide elements in other apps
+ Element picker active
+ Shown while you are selecting an element
ReDD Focus is active
Tap inspect to pick an element to hide
Inspect element
diff --git a/app/src/main/res/values/strings_custom_ui.xml b/app/src/main/res/values/strings_custom_ui.xml
new file mode 100644
index 0000000..c7d3ff1
--- /dev/null
+++ b/app/src/main/res/values/strings_custom_ui.xml
@@ -0,0 +1,71 @@
+
+
+ Custom rules
+ No custom rules yet
+ Hide something new
+ Edit as text (expert)
+ Back to rule list
+ One rule per line
+ %1$s · edit as text
+
+ - %d rule
+ - %d rules working together
+
+ App is off
+ Edit custom rule
+ Name
+ Rule text
+ Rename
+ Delete rule
+ Turn off %1$s
+ Delete %1$s
+ Enter a name
+ Names cannot contain ##
+ Rule renamed
+
+ Blocking
+ Friction gate
+ Advanced
+ Before turning a block off
+
+ - Type %d word
+ - Type %d words
+
+ Default pause length
+ Choose a default pause from 1 to 120 minutes
+
+ - %d minute
+ - %d minutes
+
+ Friction gate words
+ Choose the number of words required, from 0 to 15
+ Response speed
+ Immediate — uses more battery
+ Default — balanced
+ Battery saver — reacts a moment later
+ When %1$s opens
+ Hide distractions
+ Blocking is off
+ Paused · resumes %1$s
+ Hide something new
+ Open a place on launch instead
+ Hide something new
+ We’ll open %1$s with the picker on. Tap the thing that distracts you; you can adjust the selection before saving.
+ Enable accessibility access before starting the picker.
+ This app cannot be opened automatically.
+ Pause or turn off?
+ Pause all
+ Pause all active apps
+ Not installed · tap to show
+ Not installed · tap to hide
+ Pause for the default duration, or keep blocking off until you turn it back on.
+ Pause
+ Turn off
+ ›
+ ⌄
+
+ - @string/response_speed_immediate_consequence
+ - @string/response_speed_default_consequence
+ - @string/response_speed_battery_consequence
+
+
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java b/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java
new file mode 100644
index 0000000..7e4f8dd
--- /dev/null
+++ b/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java
@@ -0,0 +1,68 @@
+package net.kollnig.greasemilkyway;
+
+import android.content.Context;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Arrays;
+import java.util.Calendar;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricTestRunner.class)
+public class PauseManagerTest {
+
+ @Test
+ public void absolutePauseWritesBothPackageStateValues() {
+ Context context = RuntimeEnvironment.getApplication();
+ long until = System.currentTimeMillis() + 60_000L;
+
+ PauseManager.applyPackagePauseUntil(context, "com.example.app", until);
+
+ ServiceConfig config = new ServiceConfig(context);
+ assertTrue(config.isPackageDisabled("com.example.app"));
+ assertEquals(until, config.getPackagePausedUntil("com.example.app"));
+ }
+
+ @Test
+ public void durationPauseUsesTheRequestedDuration() {
+ Context context = RuntimeEnvironment.getApplication();
+ long before = System.currentTimeMillis();
+
+ long until = PauseManager.applyPackagePause(context, "com.example.duration", 15);
+
+ assertTrue(until >= before + 15 * 60_000L);
+ assertTrue(until <= System.currentTimeMillis() + 15 * 60_000L);
+ }
+
+ @Test
+ public void nextLocalMidnightIsTheFollowingLocalDayAtMidnight() {
+ Calendar now = Calendar.getInstance();
+ now.set(2026, Calendar.MARCH, 29, 13, 30, 0);
+ now.set(Calendar.MILLISECOND, 0);
+
+ Calendar midnight = Calendar.getInstance();
+ midnight.setTimeInMillis(PauseManager.nextLocalMidnightMillis(now.getTimeInMillis()));
+
+ assertEquals(0, midnight.get(Calendar.HOUR_OF_DAY));
+ assertEquals(0, midnight.get(Calendar.MINUTE));
+ assertEquals(30, midnight.get(Calendar.DAY_OF_MONTH));
+ }
+
+ @Test
+ public void batchPauseWritesEveryPackage() {
+ Context context = RuntimeEnvironment.getApplication();
+ long until = System.currentTimeMillis() + 60_000L;
+
+ PauseManager.applyPackagePauses(context, Arrays.asList("com.example.one", "com.example.two"),
+ until);
+
+ ServiceConfig config = new ServiceConfig(context);
+ assertEquals(until, config.getPackagePausedUntil("com.example.one"));
+ assertEquals(until, config.getPackagePausedUntil("com.example.two"));
+ }
+}
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/RuleRowsTest.java b/app/src/test/java/net/kollnig/greasemilkyway/RuleRowsTest.java
new file mode 100644
index 0000000..8128875
--- /dev/null
+++ b/app/src/test/java/net/kollnig/greasemilkyway/RuleRowsTest.java
@@ -0,0 +1,37 @@
+package net.kollnig.greasemilkyway;
+
+import net.kollnig.distractionlib.FilterRule;
+import net.kollnig.distractionlib.FilterRuleParser;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricTestRunner.class)
+public class RuleRowsTest {
+ @Test public void mergesBlockingPartsButNeverNavigationRows() {
+ List blocking = new FilterRuleParser().parseRules(new String[] {
+ "com.example##path=A[*]##comment=Hide feed", "com.example##path=B[*]##comment=Hide feed" });
+ assertEquals(1, RuleRows.mergeRules(blocking).size());
+ blocking.get(0).isNavigation = true;
+ assertEquals(2, RuleRows.mergeRules(blocking).size());
+ }
+ @Test public void compactDestinationDoesNotRepeatNavigationVerb() {
+ FilterRule rule = new FilterRuleParser().parseRules(new String[] {
+ "com.example##viewId=com.example:id/a##comment=Open the Favourites list instead of all chats" }).get(0);
+ assertEquals("Favourites list", RuleRows.compactNavigationDestination(rule));
+ }
+ @Test public void aRowIsActiveOnlyWhenAllPartsAreActive() {
+ List rules = new FilterRuleParser().parseRules(new String[] {
+ "com.example##path=A[*]##comment=Hide feed", "com.example##path=B[*]##comment=Hide feed" });
+ rules.get(0).enabled = rules.get(1).enabled = true;
+ assertTrue(RuleRows.isRowEnabled(rules)); rules.get(1).enabled = false;
+ assertFalse(RuleRows.isRowEnabled(rules));
+ }
+}
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/RuleTextTest.java b/app/src/test/java/net/kollnig/greasemilkyway/RuleTextTest.java
new file mode 100644
index 0000000..20d4537
--- /dev/null
+++ b/app/src/test/java/net/kollnig/greasemilkyway/RuleTextTest.java
@@ -0,0 +1,41 @@
+package net.kollnig.greasemilkyway;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class RuleTextTest {
+
+ @Test
+ public void withCommentAppendsCommentWhenMissing() {
+ assertEquals("com.example.app##viewId=id##comment=New name",
+ RuleText.withComment("com.example.app##viewId=id", "New name"));
+ }
+
+ @Test
+ public void withCommentReplacesOnlyTheCommentFragment() {
+ assertEquals("com.example.app##viewId=id##comment=New name##category=Feed",
+ RuleText.withComment("com.example.app##viewId=id##comment=Old##category=Feed",
+ "New name"));
+ }
+
+ @Test
+ public void withCommentPreservesUnknownFragments() {
+ assertEquals("com.example.app##viewId=id##unknown=left##bare-fragment##comment=New",
+ RuleText.withComment(
+ "com.example.app##viewId=id##unknown=left##bare-fragment", "New"));
+ }
+
+ @Test
+ public void withCommentTreatsNavigationAndBlockingLinesTheSame() {
+ String line = "com.example.app##desc=Inbox##comment=Old";
+ assertEquals("com.example.app##desc=Inbox##comment=New",
+ RuleText.withComment(line, "New"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void withCommentRejectsRuleDelimiterInjection() {
+ RuleText.withComment("com.example.app##viewId=id##comment=Old",
+ "New##viewId=other");
+ }
+}
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/RulesAdapterTest.java b/app/src/test/java/net/kollnig/greasemilkyway/RulesAdapterTest.java
index d8d5cba..7a99e4a 100644
--- a/app/src/test/java/net/kollnig/greasemilkyway/RulesAdapterTest.java
+++ b/app/src/test/java/net/kollnig/greasemilkyway/RulesAdapterTest.java
@@ -24,7 +24,7 @@ private List parse(String... ruleStrings) {
@Test
public void rulesSharingACommentBecomeOneRow() {
- List> rows = RulesAdapter.mergeRules(parse(
+ List> rows = RuleRows.mergeRules(parse(
"com.example.app##path=A[*]##comment=Hide feed",
"com.example.app##path=B[*]##comment=Hide feed"));
@@ -34,7 +34,7 @@ public void rulesSharingACommentBecomeOneRow() {
@Test
public void rulesWithDifferentCommentsStaySeparate() {
- List> rows = RulesAdapter.mergeRules(parse(
+ List> rows = RuleRows.mergeRules(parse(
"com.example.app##path=A[*]##comment=Hide feed",
"com.example.app##path=B[*]##comment=Hide stories"));
@@ -48,7 +48,7 @@ public void rulesWithDifferentCommentsStaySeparate() {
*/
@Test
public void differingCategoriesDoNotPreventMerging() {
- List> rows = RulesAdapter.mergeRules(parse(
+ List> rows = RuleRows.mergeRules(parse(
"com.example.app##category=Feed##path=A[*]##comment=Hide feed",
"com.example.app##category=Main screen##path=B[*]##comment=Hide feed"));
@@ -61,7 +61,7 @@ public void differingCategoriesDoNotPreventMerging() {
*/
@Test
public void rulesWithoutCommentsNeverMerge() {
- List> rows = RulesAdapter.mergeRules(parse(
+ List> rows = RuleRows.mergeRules(parse(
"com.example.app##path=A[*]",
"com.example.app##path=B[*]"));
@@ -70,7 +70,7 @@ public void rulesWithoutCommentsNeverMerge() {
@Test
public void mergedRowKeepsThePositionOfItsFirstPart() {
- List> rows = RulesAdapter.mergeRules(parse(
+ List> rows = RuleRows.mergeRules(parse(
"com.example.app##path=A[*]##comment=Hide ads",
"com.example.app##path=B[*]##comment=Hide feed",
"com.example.app##path=C[*]##comment=Hide ads"));
@@ -89,10 +89,10 @@ public void rowIsOnlyEnabledWhenEveryPartIs() {
row.get(0).enabled = true;
row.get(1).enabled = false;
- assertFalse(RulesAdapter.isRowEnabled(row));
+ assertFalse(RuleRows.isRowEnabled(row));
row.get(1).enabled = true;
- assertTrue(RulesAdapter.isRowEnabled(row));
+ assertTrue(RuleRows.isRowEnabled(row));
}
/**
@@ -105,7 +105,7 @@ public void bundledFeedRulesMergeIntoOneRow() {
"com.instagram.android##category=Feed##path=androidx.viewpager.widget.ViewPager[0]>android.widget.FrameLayout[0]>androidx.recyclerview.widget.RecyclerView[0]>android.view.ViewGroup[*]##comment=Hide feed",
"com.instagram.android##category=Feed##path=androidx.viewpager.widget.ViewPager[0]>android.widget.FrameLayout[0]>androidx.recyclerview.widget.RecyclerView[0]>android.widget.FrameLayout[*]##comment=Hide feed");
- List> rows = RulesAdapter.mergeRules(feedRules);
+ List> rows = RuleRows.mergeRules(feedRules);
assertEquals(1, rows.size());
assertEquals(2, rows.get(0).size());
@@ -124,7 +124,7 @@ public void customRuleNeverMergesWithABuiltInRuleSharingItsComment() {
"com.example.app##path=B[*]##comment=Hide feed");
rules.get(1).isCustom = true;
- List> rows = RulesAdapter.mergeRules(rules);
+ List> rows = RuleRows.mergeRules(rules);
assertEquals(2, rows.size());
}
@@ -142,11 +142,11 @@ public void navigationRuleNeverMergesWithABlockingRuleSharingItsComment() {
"com.example.app##viewId=com.example.app:id/inbox##comment=Inbox");
rules.get(1).isNavigation = true;
- List> rows = RulesAdapter.mergeRules(rules);
+ List> rows = RuleRows.mergeRules(rules);
assertEquals(2, rows.size());
- assertFalse(RulesAdapter.isNavigationRow(rows.get(0)));
- assertTrue(RulesAdapter.isNavigationRow(rows.get(1)));
+ assertFalse(RuleRows.isNavigationRow(rows.get(0)));
+ assertTrue(RuleRows.isNavigationRow(rows.get(1)));
}
/**
@@ -164,7 +164,7 @@ public void navigationRulesNeverMergeEvenWhenTheyShareAComment() {
rule.isNavigation = true;
}
- assertEquals(2, RulesAdapter.mergeRules(rules).size());
+ assertEquals(2, RuleRows.mergeRules(rules).size());
}
/**
@@ -183,7 +183,7 @@ public void displacedNavigationRuleIsMarkedDisabledInMemory() {
rule.enabled = true;
}
- RulesAdapter.markOtherNavigationRulesDisabled(
+ RuleRows.markOtherNavigationRulesDisabled(
rules, Collections.singletonList(rules.get(1)));
assertFalse(rules.get(0).enabled);
@@ -202,7 +202,7 @@ public void displacingLeavesOtherAppsAndBlockingRulesAlone() {
rule.enabled = true;
}
- RulesAdapter.markOtherNavigationRulesDisabled(
+ RuleRows.markOtherNavigationRulesDisabled(
rules, Collections.singletonList(rules.get(2)));
assertTrue("a different app keeps its navigation rule", rules.get(0).enabled);
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/ServiceConfigTest.java b/app/src/test/java/net/kollnig/greasemilkyway/ServiceConfigTest.java
index e612904..19b2f8f 100644
--- a/app/src/test/java/net/kollnig/greasemilkyway/ServiceConfigTest.java
+++ b/app/src/test/java/net/kollnig/greasemilkyway/ServiceConfigTest.java
@@ -723,6 +723,61 @@ public void bundledNavigationRulesSurviveAddingACustomOne() {
assertEquals(bundled + 1, config.getNavigationRules().size());
}
+ @Test
+ public void disableAllNavigationRulesOnlyClearsTheRequestedPackage() {
+ FilterRule first = createRule("com.example.one##viewId=one");
+ FilterRule second = createRule("com.example.one##viewId=two");
+ FilterRule otherPackage = createRule("com.example.two##viewId=one");
+ config.addCustomNavigationRule(first.ruleString);
+ config.addCustomNavigationRule(second.ruleString);
+ config.addCustomNavigationRule(otherPackage.ruleString);
+ config.setNavigationRuleEnabled(first, true);
+ config.setNavigationRuleEnabled(otherPackage, true);
+
+ config.disableAllNavigationRules("com.example.one");
+
+ assertFalse(config.isNavigationRuleEnabled(first));
+ assertFalse(config.isNavigationRuleEnabled(second));
+ assertTrue(config.isNavigationRuleEnabled(otherPackage));
+ }
+
+ @Test
+ public void renameCustomRulesRenamesEveryPartOfAMergedRowWithoutChangingState() {
+ String first = "com.example.app##viewId=one##comment=Old name";
+ String second = "com.example.app##viewId=two##comment=Old name";
+ FilterRule firstRule = createRule(first);
+ FilterRule secondRule = createRule(second);
+ config.addCustomRule(first);
+ config.addCustomRule(second);
+ config.setRuleEnabled(firstRule, true);
+ config.setRuleEnabled(secondRule, true);
+
+ config.renameCustomRules(new String[]{first, second}, "New name");
+
+ assertArrayEquals(new String[]{
+ "com.example.app##viewId=one##comment=New name",
+ "com.example.app##viewId=two##comment=New name"
+ }, config.getCustomRules());
+ assertTrue(config.isRuleEnabled(createRule(
+ "com.example.app##viewId=one##comment=New name")));
+ assertTrue(config.isRuleEnabled(createRule(
+ "com.example.app##viewId=two##comment=New name")));
+ }
+
+ @Test
+ public void renameCustomNavigationRulesDoesNotChangeTheBlockingStore() {
+ String navigation = "com.example.app##viewId=nav##comment=Old";
+ String blocking = "com.example.app##viewId=block##comment=Old";
+ config.addCustomNavigationRule(navigation);
+ config.addCustomRule(blocking);
+
+ config.renameCustomNavigationRules(new String[]{navigation}, "New");
+
+ assertArrayEquals(new String[]{"com.example.app##viewId=nav##comment=New"},
+ config.getCustomNavigationRules());
+ assertArrayEquals(new String[]{blocking}, config.getCustomRules());
+ }
+
// --- One navigation rule per app ---
@Test
diff --git a/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java b/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java
index 48eaad5..b832505 100644
--- a/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java
+++ b/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java
@@ -24,6 +24,7 @@
import android.widget.Toast;
import java.util.ArrayList;
+import java.util.EnumSet;
import java.util.List;
/**
@@ -55,6 +56,9 @@ public interface Listener {
void onNavigationRuleUndone(String ruleString);
void onPickerDismissed();
+
+ /** Completes a successful pick; unlike cancellation this may return to app UI. */
+ void onPickerDone(String packageName);
}
private final AccessibilityService service;
@@ -66,6 +70,8 @@ public interface Listener {
private View highlightView;
private LinearLayout controlBar;
private TextView infoText;
+ private TextView technicalInfoText;
+ private Button blockAllButton;
private final List nodesAtPoint = new ArrayList<>();
private int currentNodeIndex = 0;
@@ -75,6 +81,8 @@ public interface Listener {
private boolean isAtBottom = true;
private String lastAppliedRule = null;
private Mode lastAppliedMode = Mode.BLOCK;
+ private String targetPackageName;
+ private EnumSet allowedModes = EnumSet.allOf(Mode.class);
private LinearLayout undoBar = null;
private Runnable undoAutoHideRunnable = null;
private static final long UNDO_TIMEOUT_MS = 8000;
@@ -91,7 +99,18 @@ public boolean isActive() {
}
public void show() {
+ show(null, EnumSet.allOf(Mode.class));
+ }
+
+ /**
+ * Arms the picker for one app and one kind of action. A null package preserves the
+ * notification entry point, which intentionally works in any foreground app.
+ */
+ public void show(String forPackage, EnumSet actions) {
if (isActive) return;
+ targetPackageName = forPackage;
+ allowedModes = actions == null || actions.isEmpty()
+ ? EnumSet.noneOf(Mode.class) : EnumSet.copyOf(actions);
isActive = true;
ui.post(() -> {
@@ -117,6 +136,8 @@ public void hide() {
highlightView = null;
controlBar = null;
infoText = null;
+ technicalInfoText = null;
+ blockAllButton = null;
lastAppliedRule = null;
recycleNodes();
});
@@ -240,7 +261,7 @@ private void createControlBar() {
infoText.setTextColor(textColor);
infoText.setTextSize(13f);
infoText.setText(service.getString(R.string.picker_hint));
- infoText.setMaxLines(2);
+ infoText.setMaxLines(1);
infoText.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
selectionRow.addView(infoText, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
@@ -258,25 +279,38 @@ private void createControlBar() {
controlBar.addView(selectionRow);
+ technicalInfoText = new TextView(service);
+ technicalInfoText.setTextColor(textColor);
+ technicalInfoText.setTextSize(11f);
+ technicalInfoText.setMaxLines(1);
+ technicalInfoText.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE);
+ technicalInfoText.setText(service.getString(R.string.picker_hint));
+ controlBar.addView(technicalInfoText);
+
LinearLayout buttonRow = new LinearLayout(service);
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
buttonRow.setGravity(Gravity.CENTER);
buttonRow.setPadding(0, dpToPx(8), 0, 0);
- Button blockBtn = createButton(service.getString(R.string.picker_block),
- Color.argb(200, 200, 40, 40), btnTextColor);
- blockBtn.setOnClickListener(v -> confirmBlock());
- buttonRow.addView(blockBtn, createButtonParams());
-
- Button blockAllBtn = createButton(service.getString(R.string.picker_block_all),
- Color.argb(200, 200, 80, 40), btnTextColor);
- blockAllBtn.setOnClickListener(v -> confirmBlockAll());
- buttonRow.addView(blockAllBtn, createButtonParams());
-
- Button openBtn = createButton(service.getString(R.string.picker_open),
- Color.argb(200, 40, 120, 200), btnTextColor);
- openBtn.setOnClickListener(v -> confirmNavigate());
- buttonRow.addView(openBtn, createButtonParams());
+ if (allowedModes.contains(Mode.BLOCK)) {
+ Button blockBtn = createButton(service.getString(R.string.picker_block),
+ Color.argb(200, 200, 40, 40), btnTextColor);
+ blockBtn.setOnClickListener(v -> confirmBlock());
+ buttonRow.addView(blockBtn, createButtonParams());
+ }
+ if (allowedModes.contains(Mode.BLOCK_ALL)) {
+ blockAllButton = createButton(service.getString(R.string.picker_block_all),
+ Color.argb(200, 200, 80, 40), btnTextColor);
+ blockAllButton.setEnabled(false);
+ blockAllButton.setOnClickListener(v -> confirmBlockAll());
+ buttonRow.addView(blockAllButton, createButtonParams());
+ }
+ if (allowedModes.contains(Mode.NAVIGATE)) {
+ Button openBtn = createButton(service.getString(R.string.picker_open),
+ Color.argb(200, 40, 120, 200), btnTextColor);
+ openBtn.setOnClickListener(v -> confirmNavigate());
+ buttonRow.addView(openBtn, createButtonParams());
+ }
controlBar.addView(buttonRow);
@@ -323,6 +357,8 @@ private Button createIconButton(String glyph, int textColor, View.OnClickListene
btn.setPadding(pad, 0, pad, 0);
btn.setMinWidth(0);
btn.setMinimumWidth(0);
+ btn.setMinHeight(dpToPx(48));
+ btn.setMinimumHeight(dpToPx(48));
btn.setOnClickListener(onClick);
return btn;
}
@@ -337,6 +373,8 @@ private Button createButton(String text, int bgColor, int textColor) {
int hPad = dpToPx(12);
int vPad = dpToPx(6);
btn.setPadding(hPad, vPad, hPad, vPad);
+ btn.setMinHeight(dpToPx(48));
+ btn.setMinimumHeight(dpToPx(48));
return btn;
}
@@ -356,6 +394,12 @@ private void handleTap(float x, float y) {
try {
currentPackageName = root.getPackageName() != null ? root.getPackageName().toString() : "";
+ if (targetPackageName != null && !targetPackageName.equals(currentPackageName)) {
+ recycleNodes();
+ updateInfo(service.getString(R.string.picker_wrong_app));
+ hideHighlight();
+ return;
+ }
recycleNodes();
currentRootNode = AccessibilityNodeInfo.obtain(root);
collectNodesAtPoint(root, (int) x, (int) y, nodesAtPoint);
@@ -445,9 +489,11 @@ private void highlightCurrentNode() {
}
}
- String description = ElementPickerRuleGenerator.describeNode(node);
+ String description = ElementPickerRuleGenerator.plainLanguageDescription(node);
String depth = "(" + (currentNodeIndex + 1) + "/" + nodesAtPoint.size() + ")";
updateInfo(depth + " " + description);
+ updateTechnicalInfo(ElementPickerRuleGenerator.describeNode(node));
+ updateBlockAllButton(node);
}
private void hideHighlight() {
@@ -462,6 +508,24 @@ private void updateInfo(String text) {
}
}
+ private void updateTechnicalInfo(String text) {
+ if (technicalInfoText != null) {
+ technicalInfoText.setText(text);
+ }
+ }
+
+ private void updateBlockAllButton(AccessibilityNodeInfo node) {
+ if (blockAllButton == null || currentRootNode == null) return;
+ int matches = ElementPickerRuleGenerator.countGeneralizedSiblingMatches(node);
+ int visible = ElementPickerRuleGenerator.countVisibleSiblings(node);
+ boolean tooBroad = ElementPickerRuleGenerator.refusesBroadMatch(matches, visible);
+ blockAllButton.setText(service.getString(R.string.picker_block_all_count, matches));
+ blockAllButton.setEnabled(matches > 0 && !tooBroad);
+ blockAllButton.setContentDescription(tooBroad
+ ? service.getString(R.string.picker_block_all_too_broad)
+ : service.getString(R.string.picker_block_all_count, matches));
+ }
+
private void confirmBlock() {
if (nodesAtPoint.isEmpty() || currentNodeIndex >= nodesAtPoint.size()) {
Toast.makeText(service, R.string.picker_no_element, Toast.LENGTH_SHORT).show();
@@ -484,6 +548,10 @@ private void confirmBlockAll() {
}
AccessibilityNodeInfo node = nodesAtPoint.get(currentNodeIndex);
+ if (blockAllButton != null && !blockAllButton.isEnabled()) {
+ Toast.makeText(service, R.string.picker_block_all_too_broad, Toast.LENGTH_LONG).show();
+ return;
+ }
String selectorDesc = "All similar elements";
String generatedRule = ElementPickerRuleGenerator.generateRuleForAll(node, currentRootNode,
currentPackageName, null);
@@ -756,6 +824,21 @@ private void showUndoBar(String ruleDescription) {
undoBar.addView(undoBtn, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ Button doneBtn = new Button(service);
+ doneBtn.setText(R.string.picker_done);
+ doneBtn.setTextSize(13f);
+ doneBtn.setAllCaps(true);
+ doneBtn.setBackgroundColor(Color.TRANSPARENT);
+ doneBtn.setTextColor(Color.WHITE);
+ doneBtn.setPadding(dpToPx(12), 0, dpToPx(4), 0);
+ doneBtn.setOnClickListener(v -> {
+ String packageName = currentPackageName;
+ hide();
+ listener.onPickerDone(packageName);
+ });
+ undoBar.addView(doneBtn, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT,
diff --git a/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerRuleGenerator.java b/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerRuleGenerator.java
index d7b7d71..23f9c86 100644
--- a/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerRuleGenerator.java
+++ b/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerRuleGenerator.java
@@ -310,6 +310,73 @@ public static String describeNode(AccessibilityNodeInfo node) {
return sb.toString().trim();
}
+ /** A short label that explains the selected thing without requiring Android vocabulary. */
+ public static String plainLanguageDescription(AccessibilityNodeInfo node) {
+ if (node == null) return "element";
+ CharSequence label = node.getContentDescription();
+ if (label == null || label.length() == 0) label = node.getText();
+ if (label != null && label.length() > 0) return label.toString();
+ CharSequence className = node.getClassName();
+ String name = className == null ? "" : className.toString();
+ if (name.endsWith("ImageView")) return "picture";
+ if (name.endsWith("Button")) return "button";
+ if (name.endsWith("RecyclerView") || name.endsWith("ListView")) return "list";
+ return "element";
+ }
+
+ /** Counts visible siblings selected by the wildcard leaf used by generateRuleForAll. */
+ public static int countGeneralizedSiblingMatches(AccessibilityNodeInfo node) {
+ if (node == null) return 0;
+ AccessibilityNodeInfo parent = node.getParent();
+ if (parent == null) return node.isVisibleToUser() ? 1 : 0;
+ try {
+ CharSequence selectedClass = node.getClassName();
+ int count = 0;
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ AccessibilityNodeInfo sibling = parent.getChild(i);
+ if (sibling == null) continue;
+ try {
+ CharSequence siblingClass = sibling.getClassName();
+ if (sibling.isVisibleToUser() && selectedClass != null
+ && siblingClass != null
+ && selectedClass.toString().contentEquals(siblingClass)) count++;
+ } finally {
+ sibling.recycle();
+ }
+ }
+ return count;
+ } finally {
+ parent.recycle();
+ }
+ }
+
+ /** Counts the visible siblings against which the wildcard leaf is evaluated. */
+ public static int countVisibleSiblings(AccessibilityNodeInfo node) {
+ if (node == null) return 0;
+ AccessibilityNodeInfo parent = node.getParent();
+ if (parent == null) return node.isVisibleToUser() ? 1 : 0;
+ try {
+ int count = 0;
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ AccessibilityNodeInfo sibling = parent.getChild(i);
+ if (sibling == null) continue;
+ try {
+ if (sibling.isVisibleToUser()) count++;
+ } finally {
+ sibling.recycle();
+ }
+ }
+ return count;
+ } finally {
+ parent.recycle();
+ }
+ }
+
+ /** Refuses a wildcard that would select every visible candidate in its sibling scope. */
+ public static boolean refusesBroadMatch(int matchCount, int visibleNodeCount) {
+ return visibleNodeCount > 0 && matchCount >= visibleNodeCount;
+ }
+
public static String getSelectorDescription(AccessibilityNodeInfo node,
AccessibilityNodeInfo rootNode) {
String viewId = node.getViewIdResourceName();
diff --git a/distractionlib/src/main/res/values/strings.xml b/distractionlib/src/main/res/values/strings.xml
index d9f5adc..e2f2dce 100644
--- a/distractionlib/src/main/res/values/strings.xml
+++ b/distractionlib/src/main/res/values/strings.xml
@@ -1,10 +1,13 @@
Tap on an element to select it
- ▼
- ▲
+ Select more
+ Select less
Hide
Hide all
+ Hide all %d
+ Hide all is unavailable because it would hide everything visible.
+ Open the app you chose before selecting an element.
Hide all elements like this?
Open on launch
Open this when the app starts?
@@ -28,6 +31,7 @@
Hide all
Open on launch
Undo
+ Done
Blocked: %s
Opens on launch: %s
diff --git a/distractionlib/src/test/java/net/kollnig/distractionlib/ElementPickerRuleGeneratorTest.java b/distractionlib/src/test/java/net/kollnig/distractionlib/ElementPickerRuleGeneratorTest.java
index 4d488b6..2f3de93 100644
--- a/distractionlib/src/test/java/net/kollnig/distractionlib/ElementPickerRuleGeneratorTest.java
+++ b/distractionlib/src/test/java/net/kollnig/distractionlib/ElementPickerRuleGeneratorTest.java
@@ -114,6 +114,51 @@ public void describeNodeWithClassNameOnly() {
assertEquals("Button", desc);
}
+ @Test
+ public void plainLanguageDescriptionUsesLabelBeforeRole() {
+ AccessibilityNodeInfo node = mock(AccessibilityNodeInfo.class);
+ when(node.getContentDescription()).thenReturn("Search");
+ when(node.getText()).thenReturn("Ignored");
+ when(node.getClassName()).thenReturn("android.widget.Button");
+
+ assertEquals("Search", ElementPickerRuleGenerator.plainLanguageDescription(node));
+ }
+
+ @Test
+ public void plainLanguageDescriptionMapsCommonRoles() {
+ AccessibilityNodeInfo image = mock(AccessibilityNodeInfo.class);
+ when(image.getClassName()).thenReturn("android.widget.ImageView");
+ AccessibilityNodeInfo list = mock(AccessibilityNodeInfo.class);
+ when(list.getClassName()).thenReturn("androidx.recyclerview.widget.RecyclerView");
+
+ assertEquals("picture", ElementPickerRuleGenerator.plainLanguageDescription(image));
+ assertEquals("list", ElementPickerRuleGenerator.plainLanguageDescription(list));
+ }
+
+ @Test
+ public void broadMatchRefusalOnlyRejectsEveryVisibleNode() {
+ assertTrue(ElementPickerRuleGenerator.refusesBroadMatch(4, 4));
+ assertTrue(ElementPickerRuleGenerator.refusesBroadMatch(5, 4));
+ assertFalse(ElementPickerRuleGenerator.refusesBroadMatch(3, 4));
+ assertFalse(ElementPickerRuleGenerator.refusesBroadMatch(0, 0));
+ }
+
+ @Test
+ public void generalizedMatchSafetyUsesTheWildcardSiblingScope() {
+ AccessibilityNodeInfo node = mock(AccessibilityNodeInfo.class);
+ AccessibilityNodeInfo parent = mock(AccessibilityNodeInfo.class);
+ AccessibilityNodeInfo first = mock(AccessibilityNodeInfo.class);
+ AccessibilityNodeInfo second = mock(AccessibilityNodeInfo.class);
+ when(node.getParent()).thenReturn(parent);
+ when(parent.getChildCount()).thenReturn(2);
+ when(parent.getChild(0)).thenReturn(first);
+ when(parent.getChild(1)).thenReturn(second);
+ when(first.isVisibleToUser()).thenReturn(true);
+ when(second.isVisibleToUser()).thenReturn(true);
+
+ assertEquals(2, ElementPickerRuleGenerator.countVisibleSiblings(node));
+ }
+
@Test
public void describeNodeWithViewId() {
AccessibilityNodeInfo node = mock(AccessibilityNodeInfo.class);
From 9263bb2a3086c4e373c466feb9fe12071448278e Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 1 Aug 2026 12:21:21 +0200
Subject: [PATCH 2/5] Restore pause-first disable flows
Recommend the configured temporary pause whenever blocking is turned off, while preserving permanent disable as an explicit secondary action. Reflect the configured duration in the detail chip and reserve enough width for Material switches on narrow screens.
---
.../greasemilkyway/AppDetailActivity.java | 20 +++++-----
.../greasemilkyway/AppDetailAdapter.java | 19 +++++++++-
.../greasemilkyway/CustomRulesActivity.java | 38 ++++++++++++-------
.../greasemilkyway/CustomRulesAdapter.java | 11 ++++++
.../greasemilkyway/OverviewAdapter.java | 25 ++++--------
.../kollnig/greasemilkyway/PauseManager.java | 28 ++++++++++++++
.../greasemilkyway/PauseOrDisableDialog.java | 30 +++++++++++++++
.../main/res/layout/activity_app_detail.xml | 10 +++--
app/src/main/res/layout/item_app_group.xml | 11 +++---
app/src/main/res/layout/item_custom_rule.xml | 6 +--
app/src/main/res/layout/item_rule.xml | 12 +++---
app/src/main/res/layout/item_rule_section.xml | 7 ++--
app/src/main/res/values/strings.xml | 1 -
app/src/main/res/values/strings_custom_ui.xml | 17 +++++++--
.../greasemilkyway/PauseManagerTest.java | 23 +++++++++++
15 files changed, 190 insertions(+), 68 deletions(-)
create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/PauseOrDisableDialog.java
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java
index 9b50129..05eeb18 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java
@@ -84,7 +84,8 @@ protected void onCreate(Bundle state) {
adapter = new AppDetailAdapter(this, config, packageName);
list.setAdapter(adapter);
- findViewById(R.id.pause_15).setOnClickListener(view -> pauseFor(15));
+ findViewById(R.id.pause_default).setOnClickListener(
+ view -> pauseFor(config.getPauseDurationMins()));
findViewById(R.id.pause_hour).setOnClickListener(view -> pauseFor(60));
findViewById(R.id.pause_today).setOnClickListener(view -> runWithFrictionGate(
getString(R.string.pause_app_title), () -> {
@@ -149,25 +150,24 @@ private void load() {
} else {
state.setText(R.string.app_detail_state);
}
+ int defaultMinutes = config.getPauseDurationMins();
+ ((MaterialButton) findViewById(R.id.pause_default)).setText(
+ getResources().getQuantityString(
+ R.plurals.pause_chip_minutes, defaultMinutes, defaultMinutes));
adapter.setRules(appRules);
}
private void showPauseOrDisableDialog() {
- new AlertDialog.Builder(this)
- .setTitle(R.string.pause_or_disable_title)
- .setMessage(R.string.pause_or_disable_message)
- .setPositiveButton(R.string.pause_default_action, (dialog, which) -> {
+ PauseOrDisableDialog.show(this, config,
+ () -> {
PauseManager.applyPackagePause(this, packageName);
load();
- })
- .setNegativeButton(R.string.disable_permanently_action, (dialog, which) -> {
+ }, () -> {
config.setPackageDisabled(packageName, true);
config.setPackagePausedUntil(packageName, 0);
notifyService();
load();
- })
- .setOnCancelListener(dialog -> load())
- .show();
+ }, this::load);
}
private void showPickerChoice() {
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java
index ad63a9d..93690b1 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java
@@ -159,7 +159,7 @@ private void bindGroup(GroupHolder holder, GroupItem group) {
holder.switchView.setChecked(true);
((FrictionGateHost) context).runWithFrictionGate(
context.getString(R.string.disable_group_title, group.title),
- () -> setRowsEnabled(group.rows, false));
+ () -> showPauseOrDisable(group.rows));
} else if (enabled) {
setRowsEnabled(group.rows, true);
}
@@ -197,7 +197,8 @@ private void bindRule(RuleHolder holder, RuleItem item) {
holder.switchView.setChecked(true);
((FrictionGateHost) context).runWithFrictionGate(
context.getString(R.string.disable_rule_title),
- () -> setRowsEnabled(java.util.Collections.singletonList(item.parts), false));
+ () -> showPauseOrDisable(
+ java.util.Collections.singletonList(item.parts)));
} else if (enabled) {
setRowsEnabled(java.util.Collections.singletonList(item.parts), true);
}
@@ -218,6 +219,20 @@ private void setRowsEnabled(List> rows, boolean enabled) {
rebuildItems();
}
+ private void showPauseOrDisable(List> rows) {
+ PauseOrDisableDialog.show(context, config,
+ () -> pauseRows(rows),
+ () -> setRowsEnabled(rows, false),
+ this::rebuildItems);
+ }
+
+ private void pauseRows(List> rows) {
+ List rules = new ArrayList<>();
+ for (List row : rows) rules.addAll(row);
+ PauseManager.applyRulePauses(context, rules);
+ rebuildItems();
+ }
+
private void reloadNavigationState() {
for (FilterRule stored : config.getNavigationRules()) {
if (!packageName.equals(stored.packageName)) continue;
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java
index ee0d3bb..9a1c6d7 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java
@@ -176,24 +176,34 @@ private void reloadRuleList() {
private void setRowEnabled(List row, boolean enabled) {
if (row.isEmpty()) return;
- Runnable change = () -> {
- for (FilterRule rule : row) {
- if (rule.isNavigation) {
- config.setNavigationRuleEnabled(rule, enabled);
- } else {
- config.setRuleEnabled(rule, enabled);
- config.setRulePausedUntil(rule, 0);
- }
- }
- notifyService();
- reloadRuleList();
- };
if (!enabled && !RuleRows.isNavigationRow(row)) {
String name = displayName(row);
- runWithFrictionGate(getString(R.string.custom_rule_disable_gate, name), change);
+ runWithFrictionGate(getString(R.string.custom_rule_disable_gate, name),
+ () -> showPauseOrDisable(row));
} else {
- change.run();
+ applyRowEnabled(row, enabled);
+ }
+ }
+
+ private void showPauseOrDisable(List row) {
+ PauseOrDisableDialog.show(this, config,
+ () -> {
+ PauseManager.applyRulePauses(this, row);
+ reloadRuleList();
+ }, () -> applyRowEnabled(row, false), this::reloadRuleList);
+ }
+
+ private void applyRowEnabled(List row, boolean enabled) {
+ for (FilterRule rule : row) {
+ if (rule.isNavigation) {
+ config.setNavigationRuleEnabled(rule, enabled);
+ } else {
+ config.setRuleEnabled(rule, enabled);
+ config.setRulePausedUntil(rule, 0);
+ }
}
+ notifyService();
+ reloadRuleList();
}
private void showEditDialog(List row) {
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java
index 9954cd8..e41ffd5 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java
@@ -13,7 +13,9 @@
import net.kollnig.distractionlib.FilterRule;
+import java.text.DateFormat;
import java.util.ArrayList;
+import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -80,11 +82,20 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi
? context.getString(R.string.rule_custom_fallback)
: primary.description.trim();
boolean appOff = config.isPackageDisabled(primary.packageName);
+ long pausedUntil = 0;
+ for (FilterRule rule : row) {
+ if (rule.isPaused) pausedUntil = Math.max(pausedUntil, rule.pausedUntil);
+ }
ruleHolder.name.setText(name);
if (appOff) {
ruleHolder.subtitle.setText(R.string.custom_rule_app_off);
ruleHolder.subtitle.setVisibility(View.VISIBLE);
+ } else if (pausedUntil > System.currentTimeMillis()) {
+ ruleHolder.subtitle.setText(context.getString(R.string.app_paused_resumes,
+ DateFormat.getTimeInstance(DateFormat.SHORT)
+ .format(new Date(pausedUntil))));
+ ruleHolder.subtitle.setVisibility(View.VISIBLE);
} else if (row.size() > 1) {
ruleHolder.subtitle.setText(context.getResources().getQuantityString(
R.plurals.custom_rule_multiple_parts, row.size(), row.size()));
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java
index f1ac966..5987c63 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java
@@ -9,7 +9,6 @@
import android.widget.TextView;
import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
@@ -63,7 +62,8 @@ void setRules(List rules, boolean serviceEnabled) {
List installed = new ArrayList<>();
List missing = new ArrayList<>();
for (Map.Entry> entry : byPackage.entrySet()) {
- AppItem item = new AppItem(entry.getKey(), entry.getValue());
+ AppItem item = new AppItem(entry.getKey(), entry.getValue(),
+ config.getPackagePausedUntil(entry.getKey()));
boolean isInstalled = AppCatalog.isInstalled(context, item.packageName);
if (isInstalled && item.pausedUntil > System.currentTimeMillis()) {
pausedApps++;
@@ -231,21 +231,16 @@ private void bindApp(AppHolder holder, AppItem item, boolean missing) {
}
private void showPauseOrDisable(AppItem item) {
- new AlertDialog.Builder(context)
- .setTitle(R.string.pause_or_disable_title)
- .setMessage(R.string.pause_or_disable_message)
- .setPositiveButton(R.string.pause_default_action, (dialog, which) -> {
+ PauseOrDisableDialog.show(context, config,
+ () -> {
PauseManager.applyPackagePause(context, item.packageName);
reload();
- })
- .setNegativeButton(R.string.disable_permanently_action, (dialog, which) -> {
+ }, () -> {
config.setPackageDisabled(item.packageName, true);
config.setPackagePausedUntil(item.packageName, 0);
notifyService();
reload();
- })
- .setOnCancelListener(dialog -> reload())
- .show();
+ }, this::reload);
}
private void reload() {
@@ -289,9 +284,10 @@ private static final class AppItem {
final long pausedUntil;
final String destination;
- AppItem(String packageName, List rules) {
+ AppItem(String packageName, List rules, long pausedUntil) {
this.packageName = packageName;
this.rules = rules;
+ this.pausedUntil = pausedUntil;
int active = 0;
String navigationDestination = "";
for (List row : RuleRows.mergeRules(rules)) {
@@ -305,11 +301,6 @@ private static final class AppItem {
}
activeRows = active;
destination = navigationDestination;
- long pause = 0;
- for (FilterRule rule : rules) {
- pause = Math.max(pause, rule.pausedUntil);
- }
- pausedUntil = pause;
}
}
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java b/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java
index b0ffe6a..566c15b 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java
@@ -2,6 +2,8 @@
import android.content.Context;
+import net.kollnig.distractionlib.FilterRule;
+
import java.util.Calendar;
import java.util.Collections;
@@ -60,6 +62,32 @@ public static long applyPackagePauses(Context context, Iterable packageN
return applyPackagePauses(context, packageNames, durationUntil(minutes));
}
+ /** Temporarily switches off blocking rules while preserving their automatic return. */
+ public static long applyRulePauses(Context context, Iterable rules) {
+ ServiceConfig config = new ServiceConfig(context);
+ return applyRulePausesUntil(context, rules,
+ durationUntil(config.getPauseDurationMins()));
+ }
+
+ static long applyRulePausesUntil(Context context, Iterable rules, long until) {
+ if (until <= System.currentTimeMillis()) {
+ throw new IllegalArgumentException("Pause expiry must be in the future");
+ }
+ ServiceConfig config = new ServiceConfig(context);
+ for (FilterRule rule : rules) {
+ if (rule == null || rule.isNavigation) {
+ throw new IllegalArgumentException("A blocking rule is required");
+ }
+ config.setRuleEnabled(rule, false);
+ config.setRulePausedUntil(rule, until);
+ rule.enabled = false;
+ rule.isPaused = true;
+ rule.pausedUntil = until;
+ }
+ notifyService();
+ return until;
+ }
+
private static long durationUntil(int minutes) {
try {
return Math.addExact(System.currentTimeMillis(), Math.multiplyExact(minutes, 60_000L));
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/PauseOrDisableDialog.java b/app/src/main/java/net/kollnig/greasemilkyway/PauseOrDisableDialog.java
new file mode 100644
index 0000000..bd18585
--- /dev/null
+++ b/app/src/main/java/net/kollnig/greasemilkyway/PauseOrDisableDialog.java
@@ -0,0 +1,30 @@
+package net.kollnig.greasemilkyway;
+
+import android.content.Context;
+
+import androidx.appcompat.app.AlertDialog;
+
+/** Keeps temporary pause visually and semantically ahead of permanent disable. */
+final class PauseOrDisableDialog {
+ private PauseOrDisableDialog() {
+ }
+
+ static void show(Context context, ServiceConfig config, Runnable pause,
+ Runnable disablePermanently, Runnable cancel) {
+ int minutes = config.getPauseDurationMins();
+ String message = context.getResources().getQuantityString(
+ R.plurals.pause_recommended_message, minutes, minutes);
+ String pauseLabel = context.getResources().getQuantityString(
+ R.plurals.pause_for_minutes, minutes, minutes);
+
+ new AlertDialog.Builder(context)
+ .setTitle(R.string.pause_or_disable_title)
+ .setMessage(message)
+ .setPositiveButton(pauseLabel, (dialog, which) -> pause.run())
+ .setNeutralButton(R.string.disable_permanently_action,
+ (dialog, which) -> disablePermanently.run())
+ .setNegativeButton(android.R.string.cancel, (dialog, which) -> cancel.run())
+ .setOnCancelListener(dialog -> cancel.run())
+ .show();
+ }
+}
diff --git a/app/src/main/res/layout/activity_app_detail.xml b/app/src/main/res/layout/activity_app_detail.xml
index 28dc87a..98d68f4 100644
--- a/app/src/main/res/layout/activity_app_detail.xml
+++ b/app/src/main/res/layout/activity_app_detail.xml
@@ -52,6 +52,7 @@
android:id="@+id/app_detail_switch"
android:layout_width="wrap_content"
android:layout_height="48dp"
+ android:minWidth="64dp"
android:contentDescription="@string/disable_all_rules_for_app" />
@@ -67,11 +68,11 @@
android:paddingEnd="16dp">
+ android:text="@string/pause_default_placeholder" />
+ android:paddingStart="16dp"
+ android:paddingTop="16dp"
+ android:paddingEnd="8dp"
+ android:paddingBottom="16dp" />
+ android:paddingStart="16dp"
+ android:paddingTop="16dp"
+ android:paddingEnd="8dp"
+ android:paddingBottom="16dp">
-
+
diff --git a/app/src/main/res/layout/item_rule_section.xml b/app/src/main/res/layout/item_rule_section.xml
index 8ec299f..df69a62 100644
--- a/app/src/main/res/layout/item_rule_section.xml
+++ b/app/src/main/res/layout/item_rule_section.xml
@@ -9,7 +9,7 @@
android:minHeight="56dp"
android:paddingStart="16dp"
android:paddingTop="12dp"
- android:paddingEnd="16dp"
+ android:paddingEnd="8dp"
android:paddingBottom="12dp">
Made with ❤️ by reddfocus.org
Blocking is active
Paused
- Pause 15 min
1 hour
Today
Pause app
diff --git a/app/src/main/res/values/strings_custom_ui.xml b/app/src/main/res/values/strings_custom_ui.xml
index c7d3ff1..86812f7 100644
--- a/app/src/main/res/values/strings_custom_ui.xml
+++ b/app/src/main/res/values/strings_custom_ui.xml
@@ -48,6 +48,7 @@
Blocking is off
Paused · resumes %1$s
Hide something new
+ Pause
Open a place on launch instead
Hide something new
We’ll open %1$s with the picker on. Tap the thing that distracts you; you can adjust the selection before saving.
@@ -58,9 +59,19 @@
Pause all active apps
Not installed · tap to show
Not installed · tap to hide
- Pause for the default duration, or keep blocking off until you turn it back on.
- Pause
- Turn off
+
+ - Pause for %d minute
+ - Pause for %d minutes
+
+
+ - Pause %d min
+ - Pause %d min
+
+
+ - We recommend pausing for %d minute. Blocking will turn back on automatically.
+ - We recommend pausing for %d minutes. Blocking will turn back on automatically.
+
+ Turn off permanently
›
⌄
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java b/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java
index 7e4f8dd..9cdfbbc 100644
--- a/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java
+++ b/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java
@@ -2,6 +2,9 @@
import android.content.Context;
+import net.kollnig.distractionlib.FilterRule;
+import net.kollnig.distractionlib.FilterRuleParser;
+
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
@@ -9,8 +12,10 @@
import java.util.Arrays;
import java.util.Calendar;
+import java.util.Collections;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@@ -65,4 +70,22 @@ public void batchPauseWritesEveryPackage() {
assertEquals(until, config.getPackagePausedUntil("com.example.one"));
assertEquals(until, config.getPackagePausedUntil("com.example.two"));
}
+
+ @Test
+ public void rulePauseTurnsOffTheRuleUntilTheRequestedDeadline() {
+ Context context = RuntimeEnvironment.getApplication();
+ FilterRule rule = new FilterRuleParser().parseRules(new String[]{
+ "com.example.app##viewId=com.example.app:id/feed"
+ }).get(0);
+ ServiceConfig config = new ServiceConfig(context);
+ config.setRuleEnabled(rule, true);
+ long until = System.currentTimeMillis() + 60_000L;
+
+ PauseManager.applyRulePausesUntil(context, Collections.singletonList(rule), until);
+
+ assertEquals(until, config.getRulePausedUntil(rule));
+ assertFalse(rule.enabled);
+ assertTrue(rule.isPaused);
+ assertEquals(until, rule.pausedUntil);
+ }
}
From b2a00716a8f7f5cf0dd80e23b106093469708d68 Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sun, 2 Aug 2026 12:29:59 +0200
Subject: [PATCH 3/5] Revamp the element picker overlay
Replace the crowded glyph toolbar with an intent-scoped sheet that explains the current selection and keeps an explicit top/bottom move action available. Track every picker window for teardown and keep undo feedback opposite the movable controls.
---
.../DistractionControlService.java | 3 +-
app/src/main/res/values/strings.xml | 30 --
.../distractionlib/ElementPickerOverlay.java | 306 +++++++++++++-----
.../src/main/res/values/strings.xml | 27 +-
.../ElementPickerOverlayTest.java | 18 ++
5 files changed, 257 insertions(+), 127 deletions(-)
create mode 100644 distractionlib/src/test/java/net/kollnig/distractionlib/ElementPickerOverlayTest.java
diff --git a/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java b/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java
index ebb1b6a..2485bb9 100644
--- a/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java
+++ b/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java
@@ -182,7 +182,8 @@ protected void onPauseNotificationShouldCancel() {
}
public void startPickerMode() {
- startPickerMode(null, EnumSet.allOf(ElementPickerOverlay.Mode.class));
+ startPickerMode(null, EnumSet.of(
+ ElementPickerOverlay.Mode.BLOCK, ElementPickerOverlay.Mode.BLOCK_ALL));
}
/** Starts the picker only for the requested app; notification entry remains unscoped. */
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4dadeb7..519acfa 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -111,37 +111,7 @@
Stop inspecting
Picker mode active
Tap on an element to select it
- Tap on an element to select it
- ▼
- ▲
- Hide
- Hide all
- Hide all elements like this?
- Open on launch
- Open this when the app starts?
- This label matches more than one element on this screen, so tapping it could hit the wrong one. Use ▲ or ▼ to pick a more specific element.
- This element has no ID or label of its own, so it cannot be found again reliably. Use ▲ to pick the button around it.
- ✕
- ↕
- No element found at this position
- Already at deepest element
- Already at shallowest element
- Hide this element?
- %s
- Rule text (for Custom Rules): %s
- Name it (optional)
- e.g. AI button
- Cancel
- Hide
- This element will be covered whenever it appears.
- Every element with the same shape will be covered.
- Each time you open this app, ReDD Focus will tap this for you once. You can still go elsewhere afterwards.
- Hide all
- Open on launch
Rule saved! Element will now be hidden.
- Undo
- Blocked: %s
- Opens on launch: %s
Pause blocking
diff --git a/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java b/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java
index b832505..88c8b30 100644
--- a/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java
+++ b/distractionlib/src/main/java/net/kollnig/distractionlib/ElementPickerOverlay.java
@@ -7,6 +7,8 @@
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.GradientDrawable;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
@@ -71,7 +73,12 @@ public interface Listener {
private LinearLayout controlBar;
private TextView infoText;
private TextView technicalInfoText;
+ private TextView broadMatchText;
private Button blockAllButton;
+ private Button moveButton;
+ // This full-screen window must be owned so service teardown cannot strand it over the UI.
+ private View confirmationOverlay;
+ private final List