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..41e33bc
--- /dev/null
+++ b/app/src/main/res/layout/activity_app_detail.xml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..0fa55dc 100644
--- a/app/src/main/res/layout/item_app_group.xml
+++ b/app/src/main/res/layout/item_app_group.xml
@@ -3,7 +3,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:padding="16dp">
+ android:paddingStart="16dp"
+ android:paddingTop="16dp"
+ android:paddingEnd="8dp"
+ android:paddingBottom="16dp">
@@ -33,7 +36,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" />
@@ -41,14 +44,24 @@
android:id="@+id/package_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:minWidth="48dp"
+ android:minWidth="64dp"
android:minHeight="48dp"
- android:scaleX="0.75"
- android:scaleY="0.75"
android:contentDescription="@string/disable_all_rules_for_app"
app:useMaterialThemeColors="true"
app:layout_constraintBottom_toBottomOf="parent"
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..b3aab82
--- /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.xml b/app/src/main/res/layout/item_rule.xml
index e05a464..f9f9df1 100644
--- a/app/src/main/res/layout/item_rule.xml
+++ b/app/src/main/res/layout/item_rule.xml
@@ -5,7 +5,7 @@
android:layout_height="wrap_content"
android:paddingStart="32dp"
android:paddingTop="12dp"
- android:paddingEnd="16dp"
+ android:paddingEnd="8dp"
android:paddingBottom="12dp">
-
+
diff --git a/app/src/main/res/layout/item_rule_section.xml b/app/src/main/res/layout/item_rule_section.xml
index 3339929..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">
+
+
+ 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..f3efc17 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,24 @@
- %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 app
+ Open normally
+ Turn this category on or off
+ Disable %1$s
+ Disable rule
Not installed
No rules active
Opens on launch
@@ -84,43 +101,15 @@
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
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/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..0817025
--- /dev/null
+++ b/app/src/main/res/values/strings_custom_ui.xml
@@ -0,0 +1,91 @@
+
+
+ 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
+ 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.
+ 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 %d minute
+ - Pause for %d minutes
+
+
+ - %d sec
+ - %d sec
+
+
+ - %d min
+ - %d min
+
+
+ - %d hour
+ - %d hours
+
+ %1$s · default
+
+ - 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
+ ›
+ ⌄
+
+ - @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/AppDetailActivityTest.java b/app/src/test/java/net/kollnig/greasemilkyway/AppDetailActivityTest.java
new file mode 100644
index 0000000..c988139
--- /dev/null
+++ b/app/src/test/java/net/kollnig/greasemilkyway/AppDetailActivityTest.java
@@ -0,0 +1,36 @@
+package net.kollnig.greasemilkyway;
+
+import static org.junit.Assert.assertEquals;
+
+import android.content.Context;
+import android.content.Intent;
+
+import com.google.android.material.button.MaterialButton;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(RobolectricTestRunner.class)
+public class AppDetailActivityTest {
+
+ @Test
+ public void configuredPauseDurationIsRenderedAsTheMiddleChip() {
+ Context context = RuntimeEnvironment.getApplication();
+ new ServiceConfig(context).setPauseDurationMins(5);
+ Intent intent = new Intent(context, AppDetailActivity.class)
+ .putExtra(AppDetailActivity.EXTRA_PACKAGE_NAME, "com.example.app");
+
+ AppDetailActivity activity = Robolectric.buildActivity(AppDetailActivity.class, intent)
+ .create().start().resume().visible().get();
+
+ assertEquals("2 min", ((MaterialButton) activity.findViewById(R.id.pause_shorter))
+ .getText().toString());
+ assertEquals("5 min · default",
+ ((MaterialButton) activity.findViewById(R.id.pause_default)).getText().toString());
+ assertEquals("10 min", ((MaterialButton) activity.findViewById(R.id.pause_longer))
+ .getText().toString());
+ }
+}
diff --git a/app/src/test/java/net/kollnig/greasemilkyway/PauseChipDurationsTest.java b/app/src/test/java/net/kollnig/greasemilkyway/PauseChipDurationsTest.java
new file mode 100644
index 0000000..207348b
--- /dev/null
+++ b/app/src/test/java/net/kollnig/greasemilkyway/PauseChipDurationsTest.java
@@ -0,0 +1,22 @@
+package net.kollnig.greasemilkyway;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class PauseChipDurationsTest {
+
+ @Test
+ public void configuredDefaultIsAlwaysTheMiddleOfIncreasingChoices() {
+ for (int defaultMinutes = 1; defaultMinutes <= 120; defaultMinutes++) {
+ long[] durations = PauseChipDurations.aroundDefault(defaultMinutes);
+
+ assertEquals(3, durations.length);
+ assertTrue(durations[0] > 0);
+ assertTrue(durations[0] < durations[1]);
+ assertTrue(durations[1] < durations[2]);
+ assertEquals(defaultMinutes * 60_000L, durations[1]);
+ }
+ }
+}
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..c939729
--- /dev/null
+++ b/app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java
@@ -0,0 +1,92 @@
+package net.kollnig.greasemilkyway;
+
+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;
+import org.robolectric.RuntimeEnvironment;
+
+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)
+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"));
+ }
+
+ @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);
+ assertFalse(RuleRows.isRowEnabled(Collections.singletonList(rule)));
+ assertTrue(rule.isPaused);
+ assertEquals(until, rule.pausedUntil);
+ }
+}
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..bdc7e2d 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;
@@ -24,6 +26,7 @@
import android.widget.Toast;
import java.util.ArrayList;
+import java.util.EnumSet;
import java.util.List;
/**
@@ -55,6 +58,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 +72,13 @@ public interface Listener {
private View highlightView;
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