From 1ca5490b03367535534b8c0d25c5f4943a04ae50 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:34:07 +0200 Subject: [PATCH 1/5] Revamp the focus management UI Replace the flat rules list with an app overview and per-app detail flow, regroup settings and custom rules, and guide picker sessions into the selected app. Keep rule identity and on-device state semantics intact while adding batch pause, safe comment rename, and scoped picker helpers. --- app/src/main/AndroidManifest.xml | 5 + app/src/main/assets/navigation_rules.txt | 4 +- .../kollnig/greasemilkyway/AppCatalog.java | 40 + .../greasemilkyway/AppDetailActivity.java | 253 +++++ .../greasemilkyway/AppDetailAdapter.java | 335 +++++++ .../greasemilkyway/CustomRulesActivity.java | 408 ++++++-- .../greasemilkyway/CustomRulesAdapter.java | 140 +++ .../DistractionControlService.java | 25 +- .../ElementPickerNotification.java | 11 +- .../greasemilkyway/FrictionGateHost.java | 5 + .../kollnig/greasemilkyway/MainActivity.java | 29 +- .../greasemilkyway/OverviewAdapter.java | 367 +++++++ .../kollnig/greasemilkyway/PauseManager.java | 68 +- .../net/kollnig/greasemilkyway/RuleRows.java | 84 ++ .../net/kollnig/greasemilkyway/RuleText.java | 33 + .../kollnig/greasemilkyway/RulesAdapter.java | 904 ------------------ .../kollnig/greasemilkyway/ServiceConfig.java | 71 ++ .../greasemilkyway/SettingsActivity.java | 47 +- .../main/res/layout/activity_app_detail.xml | 111 +++ .../main/res/layout/activity_custom_rules.xml | 158 +-- app/src/main/res/layout/activity_settings.xml | 224 +++-- .../res/layout/dialog_custom_rule_edit.xml | 51 + app/src/main/res/layout/item_app_group.xml | 18 +- app/src/main/res/layout/item_custom_rule.xml | 61 ++ .../res/layout/item_custom_rule_group.xml | 12 + .../res/layout/item_navigation_option.xml | 2 + app/src/main/res/layout/item_rule_section.xml | 17 +- app/src/main/res/layout/item_status_card.xml | 44 + app/src/main/res/values-night/colors.xml | 2 +- app/src/main/res/values/colors.xml | 3 +- app/src/main/res/values/strings.xml | 28 +- app/src/main/res/values/strings_custom_ui.xml | 71 ++ .../greasemilkyway/PauseManagerTest.java | 68 ++ .../kollnig/greasemilkyway/RuleRowsTest.java | 37 + .../kollnig/greasemilkyway/RuleTextTest.java | 41 + .../greasemilkyway/RulesAdapterTest.java | 30 +- .../greasemilkyway/ServiceConfigTest.java | 55 ++ .../distractionlib/ElementPickerOverlay.java | 115 ++- .../ElementPickerRuleGenerator.java | 67 ++ .../src/main/res/values/strings.xml | 8 +- .../ElementPickerRuleGeneratorTest.java | 45 + 41 files changed, 2858 insertions(+), 1239 deletions(-) create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/AppCatalog.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/FrictionGateHost.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/RuleRows.java create mode 100644 app/src/main/java/net/kollnig/greasemilkyway/RuleText.java delete mode 100644 app/src/main/java/net/kollnig/greasemilkyway/RulesAdapter.java create mode 100644 app/src/main/res/layout/activity_app_detail.xml create mode 100644 app/src/main/res/layout/dialog_custom_rule_edit.xml create mode 100644 app/src/main/res/layout/item_custom_rule.xml create mode 100644 app/src/main/res/layout/item_custom_rule_group.xml create mode 100644 app/src/main/res/layout/item_navigation_option.xml create mode 100644 app/src/main/res/layout/item_status_card.xml create mode 100644 app/src/main/res/values/strings_custom_ui.xml create mode 100644 app/src/test/java/net/kollnig/greasemilkyway/PauseManagerTest.java create mode 100644 app/src/test/java/net/kollnig/greasemilkyway/RuleRowsTest.java create mode 100644 app/src/test/java/net/kollnig/greasemilkyway/RuleTextTest.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0cc0888..98c9e89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,6 +37,11 @@ + + NAMES = new HashMap<>(); + private static final Map ICONS = new HashMap<>(); + static { + NAMES.put("com.whatsapp", "WhatsApp"); ICONS.put("com.whatsapp", R.drawable.ic_whatsapp); + NAMES.put("com.google.android.youtube", "YouTube"); ICONS.put("com.google.android.youtube", R.drawable.ic_youtube); + NAMES.put("com.instagram.android", "Instagram"); ICONS.put("com.instagram.android", R.drawable.ic_instagram); + NAMES.put("com.linkedin.android", "LinkedIn"); ICONS.put("com.linkedin.android", R.drawable.ic_linkedin); + } + private AppCatalog() { } + static String getDisplayName(Context context, String packageName) { + String known = NAMES.get(packageName); + if (known != null) return known; + try { + PackageManager pm = context.getPackageManager(); + return pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString(); + } catch (PackageManager.NameNotFoundException ignored) { return packageName; } + } + static Drawable getIcon(Context context, String packageName) { + Integer known = ICONS.get(packageName); + if (known != null) return context.getDrawable(known); + try { return context.getPackageManager().getApplicationIcon(packageName); } + catch (PackageManager.NameNotFoundException ignored) { return context.getDrawable(android.R.drawable.sym_def_app_icon); } + } + static boolean isInstalled(Context context, String packageName) { + try { context.getPackageManager().getApplicationInfo(packageName, 0); return true; } + catch (PackageManager.NameNotFoundException ignored) { return false; } + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java new file mode 100644 index 0000000..9b50129 --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java @@ -0,0 +1,253 @@ +package net.kollnig.greasemilkyway; + +import android.content.Intent; +import android.provider.Settings; +import android.os.Bundle; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.materialswitch.MaterialSwitch; + +import net.kollnig.distractionlib.ElementPickerOverlay; +import net.kollnig.distractionlib.FilterRule; +import net.kollnig.distractionlib.FrictionGateActivity; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; + +public class AppDetailActivity extends AppCompatActivity implements FrictionGateHost { + public static final String EXTRA_PACKAGE_NAME = + "net.kollnig.greasemilkyway.extra.PACKAGE_NAME"; + + private ServiceConfig config; + private String packageName; + private List rules = new ArrayList<>(); + private AppDetailAdapter adapter; + private Runnable afterGate; + + private final ActivityResultLauncher frictionGateLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + Runnable action = afterGate; + afterGate = null; + if (result.getResultCode() == RESULT_OK && action != null) { + action.run(); + } + load(); + }); + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + setContentView(R.layout.activity_app_detail); + NavigationBarHelper.setup(this); + setupInsets(); + + packageName = getIntent().getStringExtra(EXTRA_PACKAGE_NAME); + if (packageName == null || packageName.isEmpty()) { + finish(); + return; + } + config = new ServiceConfig(this); + + MaterialToolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + String appName = AppCatalog.getDisplayName(this, packageName); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(appName); + } + ((TextView) findViewById(R.id.app_detail_name)).setText(appName); + ((ImageView) findViewById(R.id.app_detail_icon)) + .setImageDrawable(AppCatalog.getIcon(this, packageName)); + + RecyclerView list = findViewById(R.id.app_detail_list); + list.setLayoutManager(new LinearLayoutManager(this)); + adapter = new AppDetailAdapter(this, config, packageName); + list.setAdapter(adapter); + + findViewById(R.id.pause_15).setOnClickListener(view -> pauseFor(15)); + findViewById(R.id.pause_hour).setOnClickListener(view -> pauseFor(60)); + findViewById(R.id.pause_today).setOnClickListener(view -> runWithFrictionGate( + getString(R.string.pause_app_title), () -> { + PauseManager.applyPackagePauseUntilLocalMidnight(this, packageName); + load(); + })); + + MaterialButton picker = findViewById(R.id.guided_picker); + picker.setVisibility(getResources().getBoolean(R.bool.show_custom_rules_fab) + ? View.VISIBLE : View.GONE); + picker.setOnClickListener(view -> showPickerChoice()); + } + + @Override + protected void onResume() { + super.onResume(); + if (config != null) { + load(); + } + } + + private void load() { + rules = new ArrayList<>(config.getRules()); + rules.addAll(config.getNavigationRules()); + List appRules = new ArrayList<>(); + for (FilterRule rule : rules) { + if (packageName.equals(rule.packageName)) { + appRules.add(rule); + } + } + + long pausedUntil = config.getPackagePausedUntil(packageName); + boolean paused = pausedUntil > System.currentTimeMillis(); + boolean disabled = config.isPackageDisabled(packageName); + boolean active = !disabled && !paused; + + MaterialSwitch master = findViewById(R.id.app_detail_switch); + master.setOnCheckedChangeListener(null); + master.setChecked(active); + master.setContentDescription(getString(active + ? R.string.disable_all_rules_for_app : R.string.enable_all_rules_for_app)); + master.setOnCheckedChangeListener((button, checked) -> { + if (checked) { + config.enablePackageRules(packageName, rules); + notifyService(); + load(); + return; + } + master.setOnCheckedChangeListener(null); + master.setChecked(true); + runWithFrictionGate(getString(R.string.disable_app_title, + AppCatalog.getDisplayName(this, packageName)), this::showPauseOrDisableDialog); + }); + + TextView state = findViewById(R.id.app_detail_state); + if (paused) { + String time = DateFormat.getTimeInstance(DateFormat.SHORT) + .format(new Date(pausedUntil)); + state.setText(getString(R.string.app_detail_paused_until, time)); + } else if (disabled) { + state.setText(R.string.app_detail_off); + } else { + state.setText(R.string.app_detail_state); + } + 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) -> { + 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(); + } + + private void showPickerChoice() { + new AlertDialog.Builder(this) + .setTitle(R.string.picker_guided_title) + .setMessage(getString(R.string.picker_guided_message, + AppCatalog.getDisplayName(this, packageName))) + .setPositiveButton(R.string.hide_something_new, (dialog, which) -> + startGuidedPicker(EnumSet.of(ElementPickerOverlay.Mode.BLOCK, + ElementPickerOverlay.Mode.BLOCK_ALL))) + .setNegativeButton(R.string.open_place_on_launch, (dialog, which) -> + startGuidedPicker(EnumSet.of(ElementPickerOverlay.Mode.NAVIGATE))) + .show(); + } + + private void startGuidedPicker(EnumSet modes) { + DistractionControlService service = DistractionControlService.getInstance(); + if (service == null) { + new AlertDialog.Builder(this) + .setMessage(R.string.picker_service_unavailable) + .setPositiveButton(R.string.enable_service, (dialog, which) -> + startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + return; + } + + Intent launch = getPackageManager().getLaunchIntentForPackage(packageName); + if (launch == null) { + new AlertDialog.Builder(this) + .setMessage(R.string.picker_app_unavailable) + .setPositiveButton(android.R.string.ok, null) + .show(); + return; + } + service.startPickerMode(packageName, modes); + startActivity(launch); + } + + private void pauseFor(int minutes) { + runWithFrictionGate(getString(R.string.pause_app_title), () -> { + PauseManager.applyPackagePause(this, packageName, minutes); + load(); + }); + } + + @Override + public boolean onSupportNavigateUp() { + finish(); + return true; + } + + @Override + public void runWithFrictionGate(String title, Runnable action) { + if (config.getFrictionWordCount() <= 0) { + action.run(); + return; + } + afterGate = action; + Intent intent = new Intent(this, FrictionGateActivity.class); + intent.putExtra(FrictionGateActivity.EXTRA_WORD_COUNT, config.getFrictionWordCount()); + intent.putExtra(FrictionGateActivity.EXTRA_CONTEXT_TITLE, title); + frictionGateLauncher.launch(intent); + } + + private void notifyService() { + DistractionControlService service = DistractionControlService.getInstance(); + if (service != null) { + service.updateRules(); + } + } + + private void setupInsets() { + View root = findViewById(R.id.main); + ViewCompat.setOnApplyWindowInsetsListener(root, (view, insets) -> { + Insets system = insets.getInsets(WindowInsetsCompat.Type.statusBars() + | WindowInsetsCompat.Type.navigationBars()); + view.setPadding(view.getPaddingLeft(), system.top, + view.getPaddingRight(), system.bottom); + return insets; + }); + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java new file mode 100644 index 0000000..ad63a9d --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/AppDetailAdapter.java @@ -0,0 +1,335 @@ +package net.kollnig.greasemilkyway; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RadioButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.materialswitch.MaterialSwitch; + +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; + +/** Per-app rules, with navigation deliberately kept out of blocking categories. */ +final class AppDetailAdapter extends RecyclerView.Adapter { + private static final int TYPE_LABEL = 0; + private static final int TYPE_NAVIGATION = 1; + private static final int TYPE_GROUP = 2; + private static final int TYPE_RULE = 3; + private static final String COLLAPSE_PREFS = "AppCollapseStates"; + + private final Context context; + private final ServiceConfig config; + private final String packageName; + private final SharedPreferences collapsePrefs; + private final List items = new ArrayList<>(); + private List currentRules = new ArrayList<>(); + + AppDetailAdapter(Context context, ServiceConfig config, String packageName) { + this.context = context; + this.config = config; + this.packageName = packageName; + collapsePrefs = context.getSharedPreferences(COLLAPSE_PREFS, Context.MODE_PRIVATE); + } + + void setRules(List rules) { + currentRules = new ArrayList<>(rules); + rebuildItems(); + } + + private void rebuildItems() { + items.clear(); + List navigation = new ArrayList<>(); + List blocking = new ArrayList<>(); + for (FilterRule rule : currentRules) { + (rule.isNavigation ? navigation : blocking).add(rule); + } + + if (!navigation.isEmpty()) { + items.add(new LabelItem(context.getString(R.string.navigation_section_title, + AppCatalog.getDisplayName(context, packageName)))); + items.add(new NavigationItem(null)); + for (FilterRule rule : navigation) items.add(new NavigationItem(rule)); + } + + items.add(new LabelItem(context.getString(R.string.blocking_section_title))); + Map>> groups = RuleRows.groupRows( + RuleRows.mergeRules(blocking), "", + context.getString(R.string.rule_group_custom), + context.getString(R.string.rule_group_other)); + for (Map.Entry>> entry : groups.entrySet()) { + boolean expanded = collapsePrefs.getBoolean(expansionKey(entry.getKey()), false); + GroupItem group = new GroupItem(entry.getKey(), entry.getValue(), expanded); + items.add(group); + if (expanded) { + for (List row : entry.getValue()) items.add(new RuleItem(row)); + } + } + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + Object item = items.get(position); + if (item instanceof LabelItem) return TYPE_LABEL; + if (item instanceof NavigationItem) return TYPE_NAVIGATION; + if (item instanceof GroupItem) return TYPE_GROUP; + return TYPE_RULE; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (type == TYPE_LABEL) { + return new LabelHolder(inflater.inflate(R.layout.item_custom_rule_group, parent, false)); + } + if (type == TYPE_NAVIGATION) { + return new NavigationHolder( + inflater.inflate(R.layout.item_navigation_option, parent, false)); + } + if (type == TYPE_GROUP) { + return new GroupHolder(inflater.inflate(R.layout.item_rule_section, parent, false)); + } + return new RuleHolder(inflater.inflate(R.layout.item_rule, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + Object item = items.get(position); + if (holder instanceof LabelHolder) { + ((LabelHolder) holder).label.setText(((LabelItem) item).text); + } else if (holder instanceof NavigationHolder) { + bindNavigation((NavigationHolder) holder, (NavigationItem) item); + } else if (holder instanceof GroupHolder) { + bindGroup((GroupHolder) holder, (GroupItem) item); + } else { + bindRule((RuleHolder) holder, (RuleItem) item); + } + } + + private void bindNavigation(NavigationHolder holder, NavigationItem item) { + boolean packageEnabled = !config.isPackageDisabled(packageName); + FilterRule rule = item.rule; + holder.label.setText(rule == null ? context.getString(R.string.open_normally) + : displayName(rule, false)); + holder.radio.setChecked(rule == null ? !hasEnabledNavigationRule() : rule.enabled); + holder.radio.setEnabled(packageEnabled); + holder.itemView.setEnabled(packageEnabled); + holder.itemView.setOnClickListener(packageEnabled ? v -> { + if (rule == null) { + config.disableAllNavigationRules(packageName); + } else { + config.setNavigationRuleEnabled(rule, true); + } + reloadNavigationState(); + notifyService(); + } : null); + } + + private void bindGroup(GroupHolder holder, GroupItem group) { + int active = 0; + for (List row : group.rows) { + if (RuleRows.isRowEnabled(row)) active++; + } + boolean packageEnabled = !config.isPackageDisabled(packageName); + holder.title.setText(group.title); + holder.count.setText(context.getString(R.string.active_rule_fraction, + active, group.rows.size())); + holder.indicator.setVisibility(View.VISIBLE); + holder.indicator.setText(group.expanded ? "⌄" : "›"); + holder.switchView.setOnCheckedChangeListener(null); + holder.switchView.setChecked(active == group.rows.size() && !group.rows.isEmpty()); + holder.switchView.setEnabled(packageEnabled); + holder.switchView.setContentDescription(group.title); + holder.switchView.setOnCheckedChangeListener((button, enabled) -> { + if (!enabled && context instanceof FrictionGateHost) { + holder.switchView.setOnCheckedChangeListener(null); + holder.switchView.setChecked(true); + ((FrictionGateHost) context).runWithFrictionGate( + context.getString(R.string.disable_group_title, group.title), + () -> setRowsEnabled(group.rows, false)); + } else if (enabled) { + setRowsEnabled(group.rows, true); + } + }); + holder.itemView.setOnClickListener(v -> { + collapsePrefs.edit().putBoolean(expansionKey(group.title), !group.expanded).apply(); + rebuildItems(); + }); + } + + private void bindRule(RuleHolder holder, RuleItem item) { + FilterRule primary = item.parts.get(0); + holder.description.setText(displayName(primary, true)); + long pausedUntil = 0; + for (FilterRule rule : item.parts) { + if (rule.isPaused) pausedUntil = Math.max(pausedUntil, rule.pausedUntil); + } + if (pausedUntil > System.currentTimeMillis()) { + holder.details.setText(context.getString(R.string.app_paused_resumes, + DateFormat.getTimeInstance(DateFormat.SHORT) + .format(new Date(pausedUntil)))); + holder.details.setVisibility(View.VISIBLE); + } else { + holder.details.setVisibility(View.GONE); + } + + boolean packageEnabled = !config.isPackageDisabled(packageName); + holder.switchView.setOnCheckedChangeListener(null); + holder.switchView.setChecked(RuleRows.isRowEnabled(item.parts)); + holder.switchView.setEnabled(packageEnabled); + holder.switchView.setContentDescription(displayName(primary, true)); + holder.switchView.setOnCheckedChangeListener((button, enabled) -> { + if (!enabled && context instanceof FrictionGateHost) { + holder.switchView.setOnCheckedChangeListener(null); + holder.switchView.setChecked(true); + ((FrictionGateHost) context).runWithFrictionGate( + context.getString(R.string.disable_rule_title), + () -> setRowsEnabled(java.util.Collections.singletonList(item.parts), false)); + } else if (enabled) { + setRowsEnabled(java.util.Collections.singletonList(item.parts), true); + } + }); + } + + private void setRowsEnabled(List> rows, boolean enabled) { + for (List row : rows) { + for (FilterRule rule : row) { + rule.enabled = enabled; + rule.isPaused = false; + rule.pausedUntil = 0; + config.setRuleEnabled(rule, enabled); + config.setRulePausedUntil(rule, 0); + } + } + notifyService(); + rebuildItems(); + } + + private void reloadNavigationState() { + for (FilterRule stored : config.getNavigationRules()) { + if (!packageName.equals(stored.packageName)) continue; + for (FilterRule current : currentRules) { + if (current.isNavigation && current.identity().equals(stored.identity())) { + current.enabled = stored.enabled; + } + } + } + notifyDataSetChanged(); + } + + private boolean hasEnabledNavigationRule() { + for (FilterRule rule : currentRules) { + if (rule.isNavigation && rule.enabled) return true; + } + return false; + } + + private String displayName(FilterRule rule, boolean removeHidePrefix) { + if (rule.description != null && !rule.description.trim().isEmpty()) { + String description = rule.description.trim(); + if (removeHidePrefix && description.startsWith("Hide ")) { + return description.substring("Hide ".length()); + } + return description; + } + return context.getString(rule.isCustom + ? R.string.rule_custom_fallback : R.string.rule_builtin_fallback); + } + + private String expansionKey(String title) { + return "rule_group_expanded_" + packageName + "_" + title; + } + + private void notifyService() { + DistractionControlService service = DistractionControlService.getInstance(); + if (service != null) service.updateRules(); + } + + @Override + public int getItemCount() { + return items.size(); + } + + private static final class LabelItem { + final String text; + LabelItem(String text) { this.text = text; } + } + + private static final class NavigationItem { + final FilterRule rule; + NavigationItem(FilterRule rule) { this.rule = rule; } + } + + private static final class GroupItem { + final String title; + final List> rows; + final boolean expanded; + GroupItem(String title, List> rows, boolean expanded) { + this.title = title; + this.rows = rows; + this.expanded = expanded; + } + } + + private static final class RuleItem { + final List parts; + RuleItem(List parts) { this.parts = parts; } + } + + private static final class LabelHolder extends RecyclerView.ViewHolder { + final TextView label; + LabelHolder(View view) { + super(view); + label = view.findViewById(R.id.custom_rule_group_title); + } + } + + private static final class NavigationHolder extends RecyclerView.ViewHolder { + final RadioButton radio; + final TextView label; + NavigationHolder(View view) { + super(view); + radio = view.findViewById(R.id.navigation_radio); + label = view.findViewById(R.id.navigation_label); + } + } + + private static final class GroupHolder extends RecyclerView.ViewHolder { + final TextView title; + final TextView count; + final TextView indicator; + final MaterialSwitch switchView; + GroupHolder(View view) { + super(view); + title = view.findViewById(R.id.rule_section_title); + count = view.findViewById(R.id.rule_section_count); + indicator = view.findViewById(R.id.rule_section_indicator); + switchView = view.findViewById(R.id.rule_section_switch); + } + } + + private static final class RuleHolder extends RecyclerView.ViewHolder { + final TextView description; + final TextView details; + final MaterialSwitch switchView; + RuleHolder(View view) { + super(view); + description = view.findViewById(R.id.rule_description); + details = view.findViewById(R.id.rule_details); + switchView = view.findViewById(R.id.rule_switch); + } + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java index f1a7e19..ee0d3bb 100644 --- a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java +++ b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesActivity.java @@ -9,6 +9,7 @@ import android.text.SpannableString; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; +import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.widget.EditText; @@ -20,16 +21,49 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + import com.google.android.material.appbar.MaterialToolbar; +import net.kollnig.distractionlib.FilterRule; import net.kollnig.distractionlib.FilterRuleParser; +import net.kollnig.distractionlib.FrictionGateActivity; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; -public class CustomRulesActivity extends AppCompatActivity { +public class CustomRulesActivity extends AppCompatActivity implements FrictionGateHost { private static final String PREFS_NAME = "picker_prefs"; private static final String KEY_PICKER_INTRO_SHOWN = "picker_intro_shown"; private EditText rulesEditor; + private View listContainer; + private View editorContainer; + private View emptyView; + private RecyclerView rulesList; private ServiceConfig config; + private CustomRulesAdapter adapter; + private boolean expertMode; + private boolean editorHasInvalidText; + private Runnable pendingFrictionAction; + + private final ActivityResultLauncher frictionGateLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + Runnable action = pendingFrictionAction; + pendingFrictionAction = null; + if (result.getResultCode() == RESULT_OK && action != null) { + action.run(); + } else { + reloadRuleList(); + } + }); private final ActivityResultLauncher notificationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { @@ -44,101 +78,293 @@ public class CustomRulesActivity extends AppCompatActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_custom_rules); - - // Setup navigation bar color to match app background NavigationBarHelper.setup(this); + setupInsets(); - // Setup toolbar MaterialToolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setTitle(R.string.custom_rules_title); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.custom_rules_list_title); + } - // Initialize config config = new ServiceConfig(this); - - // Initialize views + listContainer = findViewById(R.id.custom_rules_list_container); + editorContainer = findViewById(R.id.custom_rules_editor_container); + emptyView = findViewById(R.id.custom_rules_empty); + rulesList = findViewById(R.id.custom_rules_list); rulesEditor = findViewById(R.id.rules_editor); - // Setup README link (after loading rules to avoid any interference) + rulesList.setLayoutManager(new LinearLayoutManager(this)); + adapter = new CustomRulesAdapter(this, config, new CustomRulesAdapter.Listener() { + @Override + public void onToggle(List row, boolean enabled) { + setRowEnabled(row, enabled); + } + + @Override + public void onEdit(List row) { + showEditDialog(row); + } + }); + rulesList.setAdapter(adapter); + TextView readmeLink = findViewById(R.id.readme_link); if (readmeLink != null) { setupReadmeLink(readmeLink); } - // Setup FAB to show element picker notification - View fab = findViewById(R.id.custom_rules_button); + View addButton = findViewById(R.id.custom_rules_button); if (getResources().getBoolean(R.bool.show_custom_rules_fab)) { - fab.setOnClickListener(v -> onFabClicked()); + addButton.setOnClickListener(v -> onAddClicked()); } else { - fab.setVisibility(View.GONE); + addButton.setVisibility(View.GONE); } + findViewById(R.id.edit_rules_as_text).setOnClickListener(v -> showExpertEditor()); + findViewById(R.id.back_to_rule_list).setOnClickListener(v -> leaveExpertEditor()); } @Override protected void onResume() { super.onResume(); - // Reload rules from SharedPreferences every time the activity becomes visible. - // This ensures that rules added externally (e.g. via the element picker) are - // reflected in the editor instead of being silently overwritten on the next onPause. - String[] customRules = config.getCustomRules(); - rulesEditor.setText(customRules != null ? String.join("\n", customRules) : ""); + if (expertMode) { + if (!editorHasInvalidText) { + loadEditor(); + } + } else { + reloadRuleList(); + } } @Override protected void onPause() { + if (expertMode) { + editorHasInvalidText = !saveRules(); + } super.onPause(); - saveRules(); } - private void saveRules() { - String rulesText = rulesEditor.getText().toString(); - String[] rawLines = rulesText.split("\n"); + private void reloadRuleList() { + if (config == null || adapter == null) return; + List customRules = new ArrayList<>(); + for (FilterRule rule : config.getRules()) { + if (rule.isCustom) customRules.add(rule); + } + for (FilterRule rule : config.getNavigationRules()) { + if (rule.isCustom) customRules.add(rule); + } + customRules.sort(Comparator + .comparing((FilterRule rule) -> + AppCatalog.getDisplayName(this, rule.packageName), + String.CASE_INSENSITIVE_ORDER) + .thenComparing(rule -> rule.description == null ? "" : rule.description, + String.CASE_INSENSITIVE_ORDER)); + + Map> byPackage = new LinkedHashMap<>(); + for (FilterRule rule : customRules) { + byPackage.computeIfAbsent(rule.packageName, ignored -> new ArrayList<>()).add(rule); + } + Map>> rows = new LinkedHashMap<>(); + for (Map.Entry> entry : byPackage.entrySet()) { + rows.put(entry.getKey(), RuleRows.mergeRules(entry.getValue())); + } + adapter.setRules(rows); + boolean empty = customRules.isEmpty(); + rulesList.setVisibility(empty ? View.GONE : View.VISIBLE); + emptyView.setVisibility(empty ? View.VISIBLE : View.GONE); + } + + 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); + } else { + change.run(); + } + } + + private void showEditDialog(List row) { + View content = LayoutInflater.from(this).inflate(R.layout.dialog_custom_rule_edit, null); + EditText nameInput = content.findViewById(R.id.custom_rule_name_input); + TextView rawText = content.findViewById(R.id.custom_rule_raw_text); + nameInput.setText(displayName(row)); + List raw = new ArrayList<>(); + for (FilterRule rule : row) raw.add(rule.ruleString); + rawText.setText(String.join("\n", raw)); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.custom_rule_edit_title) + .setView(content) + .setPositiveButton(R.string.custom_rule_rename, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.setOnShowListener(ignored -> { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String newName = nameInput.getText().toString().trim(); + if (newName.isEmpty()) { + nameInput.setError(getString(R.string.custom_rule_name_required)); + return; + } + if (newName.contains("##")) { + nameInput.setError(getString(R.string.custom_rule_name_invalid)); + return; + } + renameRow(row, newName); + dialog.dismiss(); + }); + content.findViewById(R.id.custom_rule_delete_button).setOnClickListener(v -> { + dialog.dismiss(); + confirmDelete(row); + }); + }); + dialog.show(); + } + + private void renameRow(List row, String newName) { + List blocking = new ArrayList<>(); + List navigation = new ArrayList<>(); + for (FilterRule rule : row) { + (rule.isNavigation ? navigation : blocking).add(rule.ruleString); + } + if (!blocking.isEmpty()) { + config.renameCustomRules(blocking.toArray(new String[0]), newName); + } + if (!navigation.isEmpty()) { + config.renameCustomNavigationRules(navigation.toArray(new String[0]), newName); + } + notifyService(); + reloadRuleList(); + Toast.makeText(this, R.string.custom_rule_renamed, Toast.LENGTH_SHORT).show(); + } + + private void confirmDelete(List row) { + new AlertDialog.Builder(this) + .setTitle(R.string.delete_rule_title) + .setMessage(R.string.delete_rule_message) + .setPositiveButton(R.string.delete_rule_confirm, (dialog, which) -> + runWithFrictionGate( + getString(R.string.custom_rule_delete_gate, displayName(row)), + () -> deleteRow(row))) + .setNegativeButton(R.string.delete_rule_cancel, null) + .show(); + } + + private void deleteRow(List row) { + for (FilterRule rule : row) { + if (rule.isNavigation) { + config.removeCustomNavigationRule(rule.ruleString); + } else { + config.removeCustomRule(rule.ruleString); + } + } + notifyService(); + reloadRuleList(); + Toast.makeText(this, R.string.rule_deleted, Toast.LENGTH_SHORT).show(); + } + + private String displayName(List row) { + FilterRule primary = row.get(0); + if (primary.description == null || primary.description.trim().isEmpty()) { + return getString(R.string.rule_custom_fallback); + } + return primary.description.trim(); + } + + private void showExpertEditor() { + expertMode = true; + editorHasInvalidText = false; + listContainer.setVisibility(View.GONE); + editorContainer.setVisibility(View.VISIBLE); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(R.string.custom_rules_title); + } + loadEditor(); + } - // Filter out empty lines to avoid accumulating blanks - java.util.List filtered = new java.util.ArrayList<>(); + private void leaveExpertEditor() { + if (!saveRules()) { + editorHasInvalidText = true; + return; + } + editorHasInvalidText = false; + expertMode = false; + editorContainer.setVisibility(View.GONE); + listContainer.setVisibility(View.VISIBLE); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(R.string.custom_rules_list_title); + } + reloadRuleList(); + } + + private void loadEditor() { + String[] customRules = config.getCustomRules(); + rulesEditor.setText(customRules == null ? "" : String.join("\n", customRules)); + } + + private boolean saveRules() { + String[] rawLines = rulesEditor.getText().toString().split("\n"); + List filtered = new ArrayList<>(); + int expectedRules = 0; for (String line : rawLines) { if (!line.trim().isEmpty()) { filtered.add(line); + if (!line.trim().startsWith("//")) expectedRules++; } } String[] rules = filtered.toArray(new String[0]); - - // Parse rules - FilterRuleParser parser = new FilterRuleParser(); - try { - parser.parseRules(rules); - config.saveCustomRules(rules); - - // Update service rules - DistractionControlService service = DistractionControlService.getInstance(); - if (service != null) { - service.updateRules(); - } - } catch (Exception e) { + if (new FilterRuleParser().parseRules(rules).size() != expectedRules) { Toast.makeText(this, R.string.invalid_rules, Toast.LENGTH_LONG).show(); + return false; + } + config.saveCustomRules(rules); + notifyService(); + return true; + } + + @Override + public void runWithFrictionGate(String contextTitle, Runnable action) { + int wordCount = config.getFrictionWordCount(); + if (wordCount <= 0) { + action.run(); + return; } + pendingFrictionAction = action; + Intent intent = new Intent(this, FrictionGateActivity.class); + intent.putExtra(FrictionGateActivity.EXTRA_WORD_COUNT, wordCount); + intent.putExtra(FrictionGateActivity.EXTRA_CONTEXT_TITLE, contextTitle); + frictionGateLauncher.launch(intent); } - private void onFabClicked() { + private void onAddClicked() { boolean introShown = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) .getBoolean(KEY_PICKER_INTRO_SHOWN, false); - if (!introShown) { new AlertDialog.Builder(this) .setTitle(R.string.picker_intro_title) .setMessage(R.string.picker_intro_message) - .setPositiveButton(R.string.picker_intro_enable, (dialog, which) -> requestNotificationPermissionAndShow()) + .setPositiveButton(R.string.picker_intro_enable, + (dialog, which) -> requestNotificationPermissionAndShow()) .setNegativeButton(R.string.picker_intro_cancel, null) .show(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestNotificationPermissionAndShow(); } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED) { - requestNotificationPermissionAndShow(); - } else { - showPickerNotification(); - } + showPickerNotification(); } } @@ -153,58 +379,54 @@ private void requestNotificationPermissionAndShow() { } private void showPickerNotification() { - ElementPickerNotification notification = new ElementPickerNotification(this); - notification.showNotification(); + new ElementPickerNotification(this).showNotification(); Toast.makeText(this, R.string.picker_notification_shown, Toast.LENGTH_SHORT).show(); } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == android.R.id.home) { - onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); + private void notifyService() { + DistractionControlService service = DistractionControlService.getInstance(); + if (service != null) service.updateRules(); } private void setupReadmeLink(TextView textView) { - if (textView == null) { - return; + String fullText = getString(R.string.custom_rules_readme_link); + String linkText = "Custom Rules README"; + SpannableString text = new SpannableString(fullText); + int start = fullText.indexOf(linkText); + if (start >= 0) { + text.setSpan(new ClickableSpan() { + @Override + public void onClick(View widget) { + startActivity(new Intent(Intent.ACTION_VIEW, + Uri.parse("https://github.com/kasnder/GreaseMilkyway/blob/main/docs/CUSTOM_RULES.md"))); + } + }, start, start + linkText.length(), SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE); } - - try { - String fullText = getString(R.string.custom_rules_readme_link); - SpannableString spannableString = new SpannableString(fullText); - - String linkText = "Custom Rules README"; - int start = fullText.indexOf(linkText); - if (start >= 0) { - int end = start + linkText.length(); - - // Make "README" clickable (no special styling) - ClickableSpan clickableSpan = new ClickableSpan() { - @Override - public void onClick(View widget) { - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/kasnder/GreaseMilkyway/blob/main/docs/CUSTOM_RULES.md")); - startActivity(browserIntent); - } - - @Override - public void updateDrawState(android.text.TextPaint ds) { - // Keep default text color, underline to show it's a link - ds.setUnderlineText(true); - ds.setColor(textView.getCurrentTextColor()); - } - }; - - spannableString.setSpan(clickableSpan, start, end, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE); + textView.setText(text); + textView.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void setupInsets() { + View root = findViewById(R.id.main); + ViewCompat.setOnApplyWindowInsetsListener(root, (view, insets) -> { + Insets system = insets.getInsets( + WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars()); + view.setPadding(view.getPaddingLeft(), system.top, + view.getPaddingRight(), system.bottom); + return insets; + }); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + if (expertMode) { + leaveExpertEditor(); + } else { + onBackPressed(); } - - textView.setText(spannableString); - textView.setMovementMethod(LinkMovementMethod.getInstance()); - } catch (Exception e) { - // If setup fails, just set the plain text - textView.setText(getString(R.string.custom_rules_readme_link)); + return true; } + return super.onOptionsItemSelected(item); } -} +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java new file mode 100644 index 0000000..9954cd8 --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/CustomRulesAdapter.java @@ -0,0 +1,140 @@ +package net.kollnig.greasemilkyway; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.materialswitch.MaterialSwitch; + +import net.kollnig.distractionlib.FilterRule; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class CustomRulesAdapter extends RecyclerView.Adapter { + interface Listener { + void onToggle(List row, boolean enabled); + void onEdit(List row); + } + + private static final int TYPE_GROUP = 0; + private static final int TYPE_RULE = 1; + + private final Context context; + private final ServiceConfig config; + private final Listener listener; + private final List items = new ArrayList<>(); + + CustomRulesAdapter(Context context, ServiceConfig config, Listener listener) { + this.context = context; + this.config = config; + this.listener = listener; + } + + void setRules(Map>> rowsByPackage) { + items.clear(); + for (Map.Entry>> entry : rowsByPackage.entrySet()) { + items.add(new GroupItem(entry.getKey())); + for (List row : entry.getValue()) { + items.add(new RuleItem(row)); + } + } + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + return items.get(position) instanceof GroupItem ? TYPE_GROUP : TYPE_RULE; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (viewType == TYPE_GROUP) { + return new GroupHolder(inflater.inflate(R.layout.item_custom_rule_group, parent, false)); + } + return new RuleHolder(inflater.inflate(R.layout.item_custom_rule, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + Object item = items.get(position); + if (holder instanceof GroupHolder && item instanceof GroupItem) { + GroupItem group = (GroupItem) item; + ((GroupHolder) holder).title.setText( + AppCatalog.getDisplayName(context, group.packageName)); + return; + } + + RuleHolder ruleHolder = (RuleHolder) holder; + List row = ((RuleItem) item).parts; + FilterRule primary = row.get(0); + String name = primary.description == null || primary.description.trim().isEmpty() + ? context.getString(R.string.rule_custom_fallback) + : primary.description.trim(); + boolean appOff = config.isPackageDisabled(primary.packageName); + + ruleHolder.name.setText(name); + if (appOff) { + ruleHolder.subtitle.setText(R.string.custom_rule_app_off); + 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())); + ruleHolder.subtitle.setVisibility(View.VISIBLE); + } else { + ruleHolder.subtitle.setVisibility(View.GONE); + } + + ruleHolder.toggle.setOnCheckedChangeListener(null); + ruleHolder.toggle.setChecked(RuleRows.isRowEnabled(row)); + ruleHolder.toggle.setEnabled(!appOff); + ruleHolder.toggle.setContentDescription(name); + ruleHolder.toggle.setOnCheckedChangeListener((button, checked) -> + listener.onToggle(row, checked)); + ruleHolder.itemView.setContentDescription(name); + ruleHolder.itemView.setOnClickListener(v -> listener.onEdit(row)); + } + + @Override + public int getItemCount() { + return items.size(); + } + + private static final class GroupItem { + final String packageName; + GroupItem(String packageName) { this.packageName = packageName; } + } + + private static final class RuleItem { + final List parts; + RuleItem(List parts) { this.parts = parts; } + } + + private static final class GroupHolder extends RecyclerView.ViewHolder { + final TextView title; + GroupHolder(View itemView) { + super(itemView); + title = itemView.findViewById(R.id.custom_rule_group_title); + } + } + + private static final class RuleHolder extends RecyclerView.ViewHolder { + final TextView name; + final TextView subtitle; + final MaterialSwitch toggle; + RuleHolder(View itemView) { + super(itemView); + name = itemView.findViewById(R.id.custom_rule_name); + subtitle = itemView.findViewById(R.id.custom_rule_subtitle); + toggle = itemView.findViewById(R.id.custom_rule_switch); + } + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java b/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java index cec3650..ebb1b6a 100644 --- a/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java +++ b/app/src/main/java/net/kollnig/greasemilkyway/DistractionControlService.java @@ -17,6 +17,7 @@ import net.kollnig.distractionlib.FilterRuleParser; import java.util.List; +import java.util.EnumSet; public class DistractionControlService extends BaseDistractionControlService { private static final String TAG = "DistractionControlService"; @@ -105,6 +106,20 @@ public void onNavigationRuleUndone(String ruleString) { public void onPickerDismissed() { stopPickerMode(); } + + @Override + public void onPickerDone(String packageName) { + stopPickerMode(); + if (packageName == null || packageName.isEmpty()) { + return; + } + Intent detailIntent = new Intent(DistractionControlService.this, + AppDetailActivity.class); + detailIntent.putExtra(AppDetailActivity.EXTRA_PACKAGE_NAME, packageName); + detailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(detailIntent); + } }); pickerReceiver = new BroadcastReceiver() { @@ -167,13 +182,21 @@ protected void onPauseNotificationShouldCancel() { } public void startPickerMode() { + startPickerMode(null, EnumSet.allOf(ElementPickerOverlay.Mode.class)); + } + + /** Starts the picker only for the requested app; notification entry remains unscoped. */ + public void startPickerMode(String forPackage, EnumSet allowedActions) { if (pickerOverlay == null || pickerNotification == null) { return; } Log.i(TAG, "Starting picker mode"); clearCurrentOverlays(); - pickerOverlay.show(); + if (pickerOverlay.isActive()) { + pickerOverlay.hide(); + } + pickerOverlay.show(forPackage, allowedActions); pickerNotification.showPickerActiveNotification(); } diff --git a/app/src/main/java/net/kollnig/greasemilkyway/ElementPickerNotification.java b/app/src/main/java/net/kollnig/greasemilkyway/ElementPickerNotification.java index f031741..9693fa6 100644 --- a/app/src/main/java/net/kollnig/greasemilkyway/ElementPickerNotification.java +++ b/app/src/main/java/net/kollnig/greasemilkyway/ElementPickerNotification.java @@ -17,6 +17,7 @@ public class ElementPickerNotification { private static final String TAG = "ElementPickerNotification"; public static final String CHANNEL_ID = "element_picker_channel"; + public static final String ACTIVE_CHANNEL_ID = "element_picker_active_channel"; private static final int NOTIFICATION_ID = 1001; public static final String ACTION_START_PICKER = "net.kollnig.greasemilkyway.ACTION_START_PICKER"; @@ -41,6 +42,14 @@ private void createNotificationChannel() { channel.setDescription(context.getString(R.string.picker_channel_description)); channel.setShowBadge(false); notificationManager.createNotificationChannel(channel); + NotificationChannel activeChannel = new NotificationChannel( + ACTIVE_CHANNEL_ID, + context.getString(R.string.picker_active_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ); + activeChannel.setDescription(context.getString(R.string.picker_active_channel_description)); + activeChannel.setShowBadge(false); + notificationManager.createNotificationChannel(activeChannel); } } @@ -76,7 +85,7 @@ public void showPickerActiveNotification() { context, 1, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID) + Notification notification = new NotificationCompat.Builder(context, ACTIVE_CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(context.getString(R.string.picker_active_title)) .setContentText(context.getString(R.string.picker_active_text)) diff --git a/app/src/main/java/net/kollnig/greasemilkyway/FrictionGateHost.java b/app/src/main/java/net/kollnig/greasemilkyway/FrictionGateHost.java new file mode 100644 index 0000000..fb0af88 --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/FrictionGateHost.java @@ -0,0 +1,5 @@ +package net.kollnig.greasemilkyway; + +interface FrictionGateHost { + void runWithFrictionGate(String contextTitle, Runnable action); +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/MainActivity.java b/app/src/main/java/net/kollnig/greasemilkyway/MainActivity.java index 8d2d55f..417afcd 100644 --- a/app/src/main/java/net/kollnig/greasemilkyway/MainActivity.java +++ b/app/src/main/java/net/kollnig/greasemilkyway/MainActivity.java @@ -6,7 +6,6 @@ import android.os.Build; import android.os.Bundle; import android.provider.Settings; -import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -29,14 +28,15 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; -public class MainActivity extends AppCompatActivity { +public class MainActivity extends AppCompatActivity implements FrictionGateHost { public static final String ACTION_PAUSE_PACKAGE = "net.kollnig.greasemilkyway.ACTION_PAUSE_PACKAGE"; public static final String EXTRA_PACKAGE_NAME = "net.kollnig.greasemilkyway.EXTRA_PACKAGE_NAME"; public static final String EXTRA_RETURN_TO_PACKAGE = "net.kollnig.greasemilkyway.EXTRA_RETURN_TO_PACKAGE"; private ServiceConfig config; - private RulesAdapter adapter; + private OverviewAdapter adapter; private AlertDialog accessibilityPromptDialog; @@ -79,7 +79,7 @@ protected void onCreate(Bundle savedInstanceState) { // Setup RecyclerView rulesList.setLayoutManager(new LinearLayoutManager(this)); - adapter = new RulesAdapter(this, config); + adapter = new OverviewAdapter(this, config, this::showAccessibilityPrompt); rulesList.setAdapter(adapter); // Load current settings @@ -183,7 +183,7 @@ private static void setupStepImage(AppCompatActivity activity, View parent, int } private static boolean detectTwoStepFlow() { - String manufacturer = Build.MANUFACTURER.toLowerCase(); + String manufacturer = Build.MANUFACTURER.toLowerCase(Locale.ROOT); return manufacturer.contains("samsung") || manufacturer.contains("xiaomi") || manufacturer.contains("oppo") || @@ -221,8 +221,8 @@ public void runWithFrictionGate(String contextTitle, Runnable action, Runnable o this.onFrictionGatePassed = action; this.onFrictionGateCancelled = onCancel; Intent intent = new Intent(this, FrictionGateActivity.class); - intent.putExtra("WORD_COUNT", wordCount); - intent.putExtra("CONTEXT_TITLE", contextTitle); + intent.putExtra(FrictionGateActivity.EXTRA_WORD_COUNT, wordCount); + intent.putExtra(FrictionGateActivity.EXTRA_CONTEXT_TITLE, contextTitle); frictionGateLauncher.launch(intent); } @@ -270,13 +270,8 @@ private void relaunchPackage(String packageName) { protected void onResume() { super.onResume(); - // Gate: if accessibility service is not enabled, prompt and block - if (!isAccessibilityServiceEnabled()) { - showAccessibilityPrompt(); - return; - } - - // Reload settings to pick up any new custom rules + // The overview keeps the service-off state actionable in its status card. A modal here + // made that state unreachable and prevented people from inspecting their saved rules. loadSettings(); } @@ -287,11 +282,7 @@ private void loadSettings() { // for display only. The adapter dispatches each row back to the store it came from. List rules = new ArrayList<>(config.getRules()); rules.addAll(config.getNavigationRules()); - Log.d("SettingsActivity", "Loading " + rules.size() + " rules"); - for (FilterRule rule : rules) { - Log.d("SettingsActivity", "Rule for " + rule.packageName + " with description: " + rule.description); - } - adapter.setRules(rules); + adapter.setRules(rules, isAccessibilityServiceEnabled()); } private void setupNavigationBarPadding() { diff --git a/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java new file mode 100644 index 0000000..f1ac966 --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/OverviewAdapter.java @@ -0,0 +1,367 @@ +package net.kollnig.greasemilkyway; + +import android.content.Context; +import android.content.Intent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.materialswitch.MaterialSwitch; + +import net.kollnig.distractionlib.FilterRule; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** The overview intentionally contains only app-level state; individual rules live in detail. */ +final class OverviewAdapter extends RecyclerView.Adapter { + private static final int STATUS = 1; + private static final int APP = 2; + private static final int FOOTER = 3; + private static final int NOT_INSTALLED = 4; + + private final Context context; + private final ServiceConfig config; + private final Runnable enableService; + private final List items = new ArrayList<>(); + private List allRules = new ArrayList<>(); + private boolean serviceEnabled; + private boolean missingExpanded; + + OverviewAdapter(Context context, ServiceConfig config, Runnable enableService) { + this.context = context; + this.config = config; + this.enableService = enableService; + } + + void setRules(List rules, boolean serviceEnabled) { + this.allRules = rules; + this.serviceEnabled = serviceEnabled; + items.clear(); + + Map> byPackage = new LinkedHashMap<>(); + for (FilterRule rule : rules) { + byPackage.computeIfAbsent(rule.packageName, ignored -> new ArrayList<>()).add(rule); + } + + int activeRows = 0; + int activeApps = 0; + int pausedApps = 0; + List activePackages = new ArrayList<>(); + List installed = new ArrayList<>(); + List missing = new ArrayList<>(); + for (Map.Entry> entry : byPackage.entrySet()) { + AppItem item = new AppItem(entry.getKey(), entry.getValue()); + boolean isInstalled = AppCatalog.isInstalled(context, item.packageName); + if (isInstalled && item.pausedUntil > System.currentTimeMillis()) { + pausedApps++; + } + int activeActions = item.activeRows + (item.destination.isEmpty() ? 0 : 1); + if (isInstalled && activeActions > 0 && !config.isPackageDisabled(item.packageName) + && item.pausedUntil <= System.currentTimeMillis()) { + activeRows += activeActions; + activeApps++; + activePackages.add(item.packageName); + } + (isInstalled ? installed : missing).add(item); + } + + items.add(new StatusItem(serviceEnabled, activeRows, activeApps, pausedApps, activePackages)); + items.addAll(installed); + if (!missing.isEmpty()) { + items.add(new MissingItem(missing)); + if (missingExpanded) { + items.addAll(missing); + } + } + items.add(new FooterItem()); + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + Object item = items.get(position); + if (item instanceof StatusItem) return STATUS; + if (item instanceof AppItem) return APP; + if (item instanceof MissingItem) return NOT_INSTALLED; + return FOOTER; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int type) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + if (type == STATUS) { + return new StatusHolder(inflater.inflate(R.layout.item_status_card, parent, false)); + } + if (type == APP || type == NOT_INSTALLED) { + return new AppHolder(inflater.inflate(R.layout.item_app_group, parent, false)); + } + return new FooterHolder(inflater.inflate(R.layout.item_footer, parent, false)); + } + + @Override + public int getItemCount() { + return items.size(); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + Object model = items.get(position); + if (holder instanceof StatusHolder) { + bindStatus((StatusHolder) holder, (StatusItem) model); + } else if (holder instanceof AppHolder && model instanceof AppItem) { + AppItem app = (AppItem) model; + bindApp((AppHolder) holder, app, !AppCatalog.isInstalled(context, app.packageName)); + } else if (holder instanceof AppHolder) { + bindMissingHeader((AppHolder) holder, (MissingItem) model); + } else if (holder instanceof FooterHolder) { + FooterHolder footer = (FooterHolder) holder; + footer.footer.setText(R.string.footer_branding); + footer.footer.setVisibility(context.getResources().getBoolean(R.bool.show_footer_branding) + ? View.VISIBLE : View.GONE); + footer.recruitment.setText(R.string.recruitment_message); + } + } + + private void bindStatus(StatusHolder holder, StatusItem status) { + holder.title.setText(status.serviceEnabled + ? R.string.focus_status_on : R.string.focus_status_off); + holder.details.setText(status.serviceEnabled + ? context.getString(R.string.focus_status_active, status.activeRows, + status.activeApps, status.pausedApps) + : context.getString(R.string.focus_status_enable_hint)); + holder.action.setVisibility(status.serviceEnabled && !status.activePackages.isEmpty() + ? View.VISIBLE : View.GONE); + holder.action.setOnClickListener(view -> { + if (context instanceof FrictionGateHost) { + ((FrictionGateHost) context).runWithFrictionGate( + context.getString(R.string.pause_all_title), () -> { + PauseManager.applyPackagePauses(context, status.activePackages); + reload(); + }); + } + }); + holder.itemView.setOnClickListener(status.serviceEnabled ? null : view -> enableService.run()); + } + + private void bindMissingHeader(AppHolder holder, MissingItem missing) { + holder.name.setText(context.getString(R.string.not_installed_group, missing.apps.size())); + holder.subtitle.setText(missingExpanded + ? R.string.not_installed_collapse : R.string.not_installed_expand); + holder.subtitle.setTextColor(ContextCompat.getColor(context, R.color.text_light)); + holder.icon.setImageResource(android.R.drawable.sym_def_app_icon); + holder.switchView.setVisibility(View.GONE); + holder.chevron.setVisibility(View.VISIBLE); + holder.chevron.setText(missingExpanded + ? R.string.chevron_expanded : R.string.chevron_collapsed); + holder.itemView.setOnClickListener(view -> { + missingExpanded = !missingExpanded; + reload(); + }); + } + + private void bindApp(AppHolder holder, AppItem item, boolean missing) { + holder.name.setText(AppCatalog.getDisplayName(context, item.packageName)); + holder.icon.setImageDrawable(AppCatalog.getIcon(context, item.packageName)); + if (missing) { + holder.subtitle.setText(R.string.not_installed); + holder.subtitle.setTextColor(ContextCompat.getColor(context, R.color.text_light)); + holder.switchView.setVisibility(View.GONE); + holder.chevron.setVisibility(View.INVISIBLE); + holder.itemView.setOnClickListener(null); + return; + } + + boolean paused = item.pausedUntil > System.currentTimeMillis(); + boolean enabled = !config.isPackageDisabled(item.packageName) && !paused; + if (paused) { + holder.subtitle.setText(context.getString(R.string.app_paused_resumes, + formatTime(item.pausedUntil))); + holder.subtitle.setTextColor(ContextCompat.getColor(context, R.color.state_paused)); + } else if (enabled) { + holder.subtitle.setText(item.destination.isEmpty() + ? context.getResources().getQuantityString( + R.plurals.hides_elements, item.activeRows, item.activeRows) + : context.getString(R.string.app_hidden_and_opens, + item.activeRows, item.destination)); + holder.subtitle.setTextColor(ContextCompat.getColor(context, R.color.accent_green)); + } else { + holder.subtitle.setText(R.string.click_to_hide_elements); + holder.subtitle.setTextColor(ContextCompat.getColor(context, R.color.text_light)); + } + + holder.switchView.setVisibility(View.VISIBLE); + holder.chevron.setVisibility(View.VISIBLE); + holder.chevron.setText(R.string.chevron_collapsed); + holder.switchView.setOnCheckedChangeListener(null); + holder.switchView.setChecked(enabled); + holder.switchView.setContentDescription(context.getString(enabled + ? R.string.disable_all_rules_for_app : R.string.enable_all_rules_for_app)); + holder.switchView.setOnCheckedChangeListener((button, checked) -> { + if (checked) { + config.enablePackageRules(item.packageName, item.rules); + notifyService(); + reload(); + return; + } + holder.switchView.setOnCheckedChangeListener(null); + holder.switchView.setChecked(true); + if (context instanceof FrictionGateHost) { + ((FrictionGateHost) context).runWithFrictionGate( + context.getString(R.string.disable_app_title, holder.name.getText()), + () -> showPauseOrDisable(item)); + } + }); + holder.itemView.setOnClickListener(view -> context.startActivity( + new Intent(context, AppDetailActivity.class) + .putExtra(AppDetailActivity.EXTRA_PACKAGE_NAME, item.packageName))); + } + + 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) -> { + 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(); + } + + private void reload() { + List rules = new ArrayList<>(config.getRules()); + rules.addAll(config.getNavigationRules()); + setRules(rules, serviceEnabled); + } + + private void notifyService() { + DistractionControlService service = DistractionControlService.getInstance(); + if (service != null) { + service.updateRules(); + } + } + + private String formatTime(long time) { + return DateFormat.getTimeInstance(DateFormat.SHORT).format(new Date(time)); + } + + private static final class StatusItem { + final boolean serviceEnabled; + final int activeRows; + final int activeApps; + final int pausedApps; + final List activePackages; + + StatusItem(boolean serviceEnabled, int activeRows, int activeApps, int pausedApps, + List activePackages) { + this.serviceEnabled = serviceEnabled; + this.activeRows = activeRows; + this.activeApps = activeApps; + this.pausedApps = pausedApps; + this.activePackages = activePackages; + } + } + + private static final class AppItem { + final String packageName; + final List rules; + final int activeRows; + final long pausedUntil; + final String destination; + + AppItem(String packageName, List rules) { + this.packageName = packageName; + this.rules = rules; + int active = 0; + String navigationDestination = ""; + for (List row : RuleRows.mergeRules(rules)) { + if (RuleRows.isNavigationRow(row)) { + if (RuleRows.isRowEnabled(row)) { + navigationDestination = RuleRows.compactNavigationDestination(row.get(0)); + } + } else if (RuleRows.isRowEnabled(row)) { + active++; + } + } + activeRows = active; + destination = navigationDestination; + long pause = 0; + for (FilterRule rule : rules) { + pause = Math.max(pause, rule.pausedUntil); + } + pausedUntil = pause; + } + } + + private static final class MissingItem { + final List apps; + + MissingItem(List apps) { + this.apps = apps; + } + } + + private static final class FooterItem { + } + + static final class StatusHolder extends RecyclerView.ViewHolder { + final TextView title; + final TextView details; + final MaterialButton action; + + StatusHolder(View view) { + super(view); + title = view.findViewById(R.id.status_title); + details = view.findViewById(R.id.status_details); + action = view.findViewById(R.id.status_action); + } + } + + static final class AppHolder extends RecyclerView.ViewHolder { + final ImageView icon; + final TextView name; + final TextView subtitle; + final TextView chevron; + final MaterialSwitch switchView; + + AppHolder(View view) { + super(view); + icon = view.findViewById(R.id.app_icon); + name = view.findViewById(R.id.app_name); + subtitle = view.findViewById(R.id.package_name); + chevron = view.findViewById(R.id.app_chevron); + switchView = view.findViewById(R.id.package_switch); + } + } + + static final class FooterHolder extends RecyclerView.ViewHolder { + final TextView footer; + final TextView recruitment; + + FooterHolder(View view) { + super(view); + footer = view.findViewById(R.id.footer_text); + recruitment = view.findViewById(R.id.recruitment_text); + } + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java b/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java index ef05343..b0ffe6a 100644 --- a/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java +++ b/app/src/main/java/net/kollnig/greasemilkyway/PauseManager.java @@ -2,21 +2,83 @@ import android.content.Context; +import java.util.Calendar; +import java.util.Collections; + public final class PauseManager { private PauseManager() { } public static long applyPackagePause(Context context, String packageName) { ServiceConfig config = new ServiceConfig(context); - long until = System.currentTimeMillis() + (config.getPauseDurationMins() * 60_000L); + return applyPackagePause(context, packageName, config.getPauseDurationMins()); + } + + /** Applies a duration-specific package pause. */ + public static long applyPackagePause(Context context, String packageName, int minutes) { + if (minutes <= 0) { + throw new IllegalArgumentException("Pause duration must be positive"); + } + return applyPackagePauseUntil(context, packageName, durationUntil(minutes)); + } - config.setPackageDisabled(packageName, true); - config.setPackagePausedUntil(packageName, until); + /** Applies a pause ending at an explicit absolute timestamp. */ + public static long applyPackagePauseUntil(Context context, String packageName, long until) { + ServiceConfig config = new ServiceConfig(context); + config.pausePackagesUntil(Collections.singletonList(packageName), until); notifyService(); + return until; + } + /** Applies a pause that ends at the next local midnight, including across daylight changes. */ + public static long applyPackagePauseUntilLocalMidnight(Context context, String packageName) { + return applyPackagePauseUntil(context, packageName, + nextLocalMidnightMillis(System.currentTimeMillis())); + } + + /** Pauses several packages together and refreshes the service once after the batch is saved. */ + public static long applyPackagePauses(Context context, Iterable packageNames, + long until) { + ServiceConfig config = new ServiceConfig(context); + config.pausePackagesUntil(packageNames, until); + notifyService(); return until; } + /** Pauses several packages for the configured default duration with one service refresh. */ + public static long applyPackagePauses(Context context, Iterable packageNames) { + return applyPackagePauses(context, packageNames, + new ServiceConfig(context).getPauseDurationMins()); + } + + /** Pauses several packages for one explicit duration with one service refresh. */ + public static long applyPackagePauses(Context context, Iterable packageNames, + int minutes) { + if (minutes <= 0) { + throw new IllegalArgumentException("Pause duration must be positive"); + } + return applyPackagePauses(context, packageNames, durationUntil(minutes)); + } + + private static long durationUntil(int minutes) { + try { + return Math.addExact(System.currentTimeMillis(), Math.multiplyExact(minutes, 60_000L)); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("Pause duration is too large", e); + } + } + + static long nextLocalMidnightMillis(long nowMillis) { + Calendar midnight = Calendar.getInstance(); + midnight.setTimeInMillis(nowMillis); + midnight.add(Calendar.DATE, 1); + midnight.set(Calendar.HOUR_OF_DAY, 0); + midnight.set(Calendar.MINUTE, 0); + midnight.set(Calendar.SECOND, 0); + midnight.set(Calendar.MILLISECOND, 0); + return midnight.getTimeInMillis(); + } + private static void notifyService() { DistractionControlService service = DistractionControlService.getInstance(); if (service != null) { diff --git a/app/src/main/java/net/kollnig/greasemilkyway/RuleRows.java b/app/src/main/java/net/kollnig/greasemilkyway/RuleRows.java new file mode 100644 index 0000000..405a8af --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/RuleRows.java @@ -0,0 +1,84 @@ +package net.kollnig.greasemilkyway; + +import net.kollnig.distractionlib.FilterRule; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Shared display-row model. A row always changes all of its rule parts together. */ +final class RuleRows { + private RuleRows() { } + + static List> mergeRules(List rules) { + List> rows = new ArrayList<>(); + Map> byComment = new LinkedHashMap<>(); + for (FilterRule rule : rules) { + String comment = rule.description == null ? "" : rule.description.trim(); + if (comment.isEmpty() || rule.isNavigation) { + List row = new ArrayList<>(); + row.add(rule); + rows.add(row); + continue; + } + String key = rule.isCustom + "\0" + comment; + List row = byComment.get(key); + if (row == null) { + row = new ArrayList<>(); + byComment.put(key, row); + rows.add(row); + } + row.add(rule); + } + return rows; + } + + static boolean isRowEnabled(List row) { + for (FilterRule rule : row) { + if (!rule.enabled) return false; + } + return true; + } + + static boolean isNavigationRow(List row) { + return !row.isEmpty() && row.get(0).isNavigation; + } + + static void markOtherNavigationRulesDisabled(List rules, List kept) { + if (kept.isEmpty()) return; + String packageName = kept.get(0).packageName; + for (FilterRule rule : rules) { + if (rule.isNavigation && !kept.contains(rule) + && packageName.equals(rule.packageName)) { + rule.enabled = false; + } + } + } + + static Map>> groupRows(List> rows, + String navigationTitle, String customTitle, String otherTitle) { + Map>> groups = new LinkedHashMap<>(); + for (List row : rows) { + FilterRule rule = row.get(0); + String title; + if (rule.isNavigation) title = navigationTitle; + else if (rule.category != null && !rule.category.trim().isEmpty()) title = rule.category.trim(); + else if (rule.isCustom) title = customTitle; + else title = otherTitle; + groups.computeIfAbsent(title, ignored -> new ArrayList<>()).add(row); + } + return groups; + } + + static String compactNavigationDestination(FilterRule rule) { + String description = rule.description == null ? "" : rule.description.trim(); + if (description.startsWith("Go to ")) return description.substring("Go to ".length()); + if (description.startsWith("Open the ")) { + String destination = description.substring("Open the ".length()); + int instead = destination.indexOf(" instead"); + return (instead >= 0 ? destination.substring(0, instead) : destination); + } + return description; + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/RuleText.java b/app/src/main/java/net/kollnig/greasemilkyway/RuleText.java new file mode 100644 index 0000000..7903f01 --- /dev/null +++ b/app/src/main/java/net/kollnig/greasemilkyway/RuleText.java @@ -0,0 +1,33 @@ +package net.kollnig.greasemilkyway; + +/** Small text-only operations for a persisted custom rule line. */ +public final class RuleText { + private RuleText() { + } + + /** + * Replaces a rule line's comment without parsing or rebuilding its other fragments. + * Comments cannot contain the rule delimiter, just as any other rule value cannot. + */ + public static String withComment(String ruleLine, String newComment) { + if (ruleLine == null || newComment == null) { + throw new IllegalArgumentException("Rule line and comment are required"); + } + if (newComment.contains("##")) { + throw new IllegalArgumentException("A rule name cannot contain ##"); + } + + String commentPrefix = "##comment="; + int commentStart = ruleLine.indexOf(commentPrefix); + if (commentStart < 0) { + return ruleLine + commentPrefix + newComment; + } + + int valueStart = commentStart + commentPrefix.length(); + int valueEnd = ruleLine.indexOf("##", valueStart); + if (valueEnd < 0) { + valueEnd = ruleLine.length(); + } + return ruleLine.substring(0, valueStart) + newComment + ruleLine.substring(valueEnd); + } +} diff --git a/app/src/main/java/net/kollnig/greasemilkyway/RulesAdapter.java b/app/src/main/java/net/kollnig/greasemilkyway/RulesAdapter.java deleted file mode 100644 index 6dd1b9b..0000000 --- a/app/src/main/java/net/kollnig/greasemilkyway/RulesAdapter.java +++ /dev/null @@ -1,904 +0,0 @@ -package net.kollnig.greasemilkyway; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import com.google.android.material.materialswitch.MaterialSwitch; -import android.widget.TextView; -import android.app.AlertDialog; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import net.kollnig.distractionlib.FilterRule; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import android.widget.Toast; -import android.graphics.ColorMatrix; -import android.graphics.ColorMatrixColorFilter; - -public class RulesAdapter extends RecyclerView.Adapter { - private static final int TYPE_APP_HEADER = 1; - private static final int TYPE_RULE = 2; - private static final int TYPE_FOOTER = 3; - private static final int TYPE_RULE_SECTION = 4; - - private static final String PREFS_NAME = "AppCollapseStates"; - private static final String KEY_FIRST_RUN = "first_run"; - - private final Context context; - private final ServiceConfig config; - private final PackageManager packageManager; - private final SharedPreferences collapsePrefs; - private final List items = new ArrayList<>(); - private List currentRules = new ArrayList<>(); - - // Hardcoded app names for known packages - private static final Map KNOWN_APP_NAMES = new HashMap<>() { - { - put("com.whatsapp", "WhatsApp"); - put("com.google.android.youtube", "YouTube"); - put("com.instagram.android", "Instagram"); - put("com.linkedin.android", "LinkedIn"); - } - }; - - // Hardcoded app icons for known packages - private static final Map KNOWN_APP_ICONS = new HashMap<>() { - { - put("com.whatsapp", R.drawable.ic_whatsapp); - put("com.google.android.youtube", R.drawable.ic_youtube); - put("com.instagram.android", R.drawable.ic_instagram); - put("com.linkedin.android", R.drawable.ic_linkedin); - } - }; - - public RulesAdapter(Context context, ServiceConfig config) { - this.context = context; - this.config = config; - this.packageManager = context.getPackageManager(); - this.collapsePrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - - // Check if this is first run - if (collapsePrefs.getBoolean(KEY_FIRST_RUN, true)) { - // First run - mark as no longer first run - collapsePrefs.edit().putBoolean(KEY_FIRST_RUN, false).apply(); - // All apps will default to collapsed (false) on first run - } - } - - public void setRules(List rules) { - this.currentRules = rules; - rebuildItemsList(); - } - - private void rebuildItemsList() { - // Preserve full rule state (enabled, paused, pausedUntil) from existing items - // so that external setRules() calls don't overwrite local changes - Map existingRules = new HashMap<>(); - for (Object item : items) { - if (item instanceof RuleItem) { - for (FilterRule rule : ((RuleItem) item).parts) { - existingRules.put(rule.hashCode(), rule); - } - } - } - - this.items.clear(); - - // Group rules by package name - Map> rulesByPackage = new HashMap<>(); - for (FilterRule rule : currentRules) { - // Preserve state from existing rules - FilterRule existing = existingRules.get(rule.hashCode()); - if (existing != null) { - rule.enabled = existing.enabled; - rule.isPaused = existing.isPaused; - rule.pausedUntil = existing.pausedUntil; - } - rulesByPackage.computeIfAbsent(rule.packageName, k -> new ArrayList<>()).add(rule); - } - - // Add items in order: app header followed by its rules (if expanded) - for (Map.Entry> entry : rulesByPackage.entrySet()) { - String packageName = entry.getKey(); - List packageRules = entry.getValue(); - - // Sort rules by description - packageRules.sort((r1, r2) -> { - String d1 = r1.description != null ? r1.description : ""; - String d2 = r2.description != null ? r2.description : ""; - return d1.compareToIgnoreCase(d2); - }); - - // Merge before counting, so the header's "hides N elements" agrees - // with the number of switches shown underneath it. - List> packageRows = mergeRules(packageRules); - - // Counted apart, because the header says how many elements are hidden and a - // navigation rule hides nothing -- it opens a screen. - int enabledCount = 0; - int enabledNavigationCount = 0; - int hidingRows = 0; - for (List row : packageRows) { - boolean navigation = isNavigationRow(row); - if (!navigation) hidingRows++; - if (isRowEnabled(row)) { - if (navigation) enabledNavigationCount++; - else enabledCount++; - } - } - - // Add app header - items.add(new AppHeaderItem(packageName, enabledCount, hidingRows, - enabledNavigationCount)); - - // Show rules only when the package is enabled (not disabled) - boolean isPackageEnabled = !config.isPackageDisabled(packageName); - if (isPackageEnabled) { - Map>> groupedRows = groupRows(packageRows); - for (Map.Entry>> group : groupedRows.entrySet()) { - String groupTitle = group.getKey(); - List> groupRows = group.getValue(); - boolean expanded = isRuleGroupExpanded(packageName, groupTitle); - items.add(new RuleSectionItem(packageName, groupTitle, groupRows, expanded)); - if (expanded) { - for (List row : groupRows) { - items.add(new RuleItem(row)); - } - } - } - } - } - - // Add footer at end of list - items.add(new FooterItem()); - - notifyDataSetChanged(); - } - - @Override - public int getItemViewType(int position) { - Object item = items.get(position); - if (item instanceof AppHeaderItem) - return TYPE_APP_HEADER; - if (item instanceof FooterItem) - return TYPE_FOOTER; - if (item instanceof RuleSectionItem) - return TYPE_RULE_SECTION; - return TYPE_RULE; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - if (viewType == TYPE_APP_HEADER) { - return new AppHeaderViewHolder(inflater.inflate(R.layout.item_app_group, parent, false)); - } - if (viewType == TYPE_FOOTER) { - return new FooterViewHolder(inflater.inflate(R.layout.item_footer, parent, false)); - } - if (viewType == TYPE_RULE_SECTION) { - return new RuleSectionViewHolder(inflater.inflate(R.layout.item_rule_section, parent, false)); - } - return new RuleViewHolder(inflater.inflate(R.layout.item_rule, parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { - Object item = items.get(position); - if (holder instanceof AppHeaderViewHolder && item instanceof AppHeaderItem) { - AppHeaderViewHolder viewHolder = (AppHeaderViewHolder) holder; - AppHeaderItem appItem = (AppHeaderItem) item; - String packageName = appItem.packageName; - - // Check if this is a known app - String displayName = KNOWN_APP_NAMES.get(packageName); - Integer iconRes = KNOWN_APP_ICONS.get(packageName); - - // Try to get app info to check if installed - boolean isInstalled = false; - try { - packageManager.getApplicationInfo(packageName, 0); - isInstalled = true; - } catch (PackageManager.NameNotFoundException e) { - // do nothing - } - final boolean finalIsInstalled = isInstalled; - - // Determine if the app toggle is currently enabled - boolean isAppEnabled = !config.isPackageDisabled(packageName); - - // Set subtitle text based on state - if (!finalIsInstalled) { - viewHolder.packageName.setText(context.getString(R.string.not_installed)); - } else if (isAppEnabled) { - String opensOnLaunch = appItem.navigationCount > 0 - ? context.getString(R.string.header_opens_on_launch) - : null; - if (appItem.ruleCount > 0) { - String hidden = context.getResources().getQuantityString( - R.plurals.hides_elements, appItem.ruleCount, appItem.ruleCount); - viewHolder.packageName.setText(opensOnLaunch == null - ? hidden - : context.getString(R.string.header_combined, hidden, opensOnLaunch)); - } else if (opensOnLaunch != null) { - // Nothing hidden, but the app is not idle either. - viewHolder.packageName.setText(opensOnLaunch); - } else if (appItem.totalRuleCount > 0) { - viewHolder.packageName.setText(context.getString(R.string.no_rules_active)); - } else { - viewHolder.packageName.setText(context.getString(R.string.click_to_hide_elements)); - } - } else { - long pauseUntil = config.getPackagePausedUntil(packageName); - if (pauseUntil > System.currentTimeMillis()) { - SimpleDateFormat sdf = new SimpleDateFormat("HH:mm", Locale.getDefault()); - String timeStr = sdf.format(new Date(pauseUntil)); - viewHolder.packageName.setText(context.getString(R.string.paused_until, timeStr)); - } else { - viewHolder.packageName.setText(context.getString(R.string.click_to_hide_elements)); - } - } - - // Set name and icon - if (displayName != null && iconRes != null) { - viewHolder.appName.setText(displayName); - viewHolder.appIcon.setImageResource(iconRes); - } else { - try { - ApplicationInfo appInfo = packageManager.getApplicationInfo(packageName, 0); - viewHolder.appName.setText(packageManager.getApplicationLabel(appInfo)); - viewHolder.appIcon.setImageDrawable(packageManager.getApplicationIcon(appInfo)); - } catch (PackageManager.NameNotFoundException e) { - viewHolder.appName.setText(packageName); - viewHolder.appIcon.setImageResource(android.R.drawable.sym_def_app_icon); - } - } - - // Gray out icon if not installed - if (!finalIsInstalled) { - ColorMatrix matrix = new ColorMatrix(); - matrix.setSaturation(0); - viewHolder.appIcon.setColorFilter(new ColorMatrixColorFilter(matrix)); - viewHolder.appIcon.setAlpha(0.5f); - } else { - viewHolder.appIcon.clearColorFilter(); - viewHolder.appIcon.setAlpha(1.0f); - } - - // Set up package switch - viewHolder.packageSwitch.setOnCheckedChangeListener(null); - viewHolder.packageSwitch.setChecked(isAppEnabled); - viewHolder.packageSwitch.setOnClickListener(v -> { - if (!finalIsInstalled) { - viewHolder.packageSwitch.setChecked(isAppEnabled); - Toast.makeText(context, R.string.app_not_installed, Toast.LENGTH_SHORT).show(); - } - }); - viewHolder.packageSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (!finalIsInstalled) return; - - if (!isChecked) { - // Intercept disabling - viewHolder.packageSwitch.setOnCheckedChangeListener(null); - viewHolder.packageSwitch.setChecked(true); // Revert visually - - if (context instanceof MainActivity) { - ((MainActivity) context).runWithFrictionGate("Disable " + viewHolder.appName.getText(), () -> { - showPauseDialog(packageName, null); - }); - } - viewHolder.packageSwitch.setOnCheckedChangeListener(((buttonView1, isChecked1) -> { /* Re-register will happen in rebuild */ })); - return; - } - - config.enablePackageRules(packageName, currentRules); - - // Rebuild to show/hide rules - rebuildItemsList(); - - // Notify the service to update its rules - notifyService(); - }); - - // Whole-row click toggles the switch (when installed) - viewHolder.itemView.setOnClickListener(v -> { - if (!finalIsInstalled) { - Toast.makeText(context, R.string.app_not_installed, Toast.LENGTH_SHORT).show(); - return; - } - viewHolder.packageSwitch.toggle(); - }); - } else if (holder instanceof RuleSectionViewHolder && item instanceof RuleSectionItem) { - RuleSectionViewHolder viewHolder = (RuleSectionViewHolder) holder; - RuleSectionItem section = (RuleSectionItem) item; - viewHolder.sectionTitle.setText(section.title); - viewHolder.sectionCount.setText(getRuleGroupSummary(section.rows)); - viewHolder.sectionIndicator.setText(section.expanded ? "v" : ">"); - viewHolder.itemView.setOnClickListener(v -> { - collapsePrefs.edit() - .putBoolean(getRuleGroupExpandedKey(section.packageName, section.title), - !section.expanded) - .apply(); - rebuildItemsList(); - }); - } else if (holder instanceof RuleViewHolder && item instanceof RuleItem) { - RuleViewHolder viewHolder = (RuleViewHolder) holder; - RuleItem ruleItem = (RuleItem) item; - List parts = ruleItem.parts; - FilterRule rule = ruleItem.primary(); - // A row counts as on only when every part of it is: a partially - // applied row would leave whatever it hides visible anyway. - boolean rowEnabled = isRowEnabled(parts); - - viewHolder.ruleDescription.setText(getRuleDisplayName(rule)); - - long pausedUntil = 0; - for (FilterRule part : parts) { - if (part.isPaused && part.pausedUntil > pausedUntil) { - pausedUntil = part.pausedUntil; - } - } - - // Set subtitle text based on state - if (rowEnabled) { - viewHolder.ruleDetails.setVisibility(View.GONE); - } else if (pausedUntil > System.currentTimeMillis()) { - SimpleDateFormat sdf = new SimpleDateFormat("HH:mm", Locale.getDefault()); - String timeStr = sdf.format(new Date(pausedUntil)); - viewHolder.ruleDetails.setText(context.getString(R.string.paused_until, timeStr)); - viewHolder.ruleDetails.setVisibility(View.VISIBLE); - } else { - viewHolder.ruleDetails.setText(R.string.rule_disabled); - viewHolder.ruleDetails.setVisibility(View.VISIBLE); - } - - // Check if the package is disabled - boolean isPackageDisabled = config.isPackageDisabled(rule.packageName); - - // Remove any existing listener to prevent duplicate callbacks - viewHolder.ruleSwitch.setOnCheckedChangeListener(null); - // Set the current state - viewHolder.ruleSwitch.setChecked(rowEnabled); - // Disable the switch if the package is disabled - viewHolder.ruleSwitch.setEnabled(!isPackageDisabled); - // Add the listener back - viewHolder.ruleSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - int adapterPosition = viewHolder.getAdapterPosition(); - if (adapterPosition == RecyclerView.NO_POSITION) - return; - Object currentItem = items.get(adapterPosition); - if (currentItem instanceof RuleItem) { - List currentParts = ((RuleItem) currentItem).parts; - if (isRowEnabled(currentParts) != isChecked) { // Only update if the state actually changed - if (!isChecked) { - // Intercept disabling - viewHolder.ruleSwitch.setOnCheckedChangeListener(null); - viewHolder.ruleSwitch.setChecked(true); // Revert visually - - if (context instanceof MainActivity) { - // Pausing is a blocking-rule notion -- it exists so hidden - // content can be let through for a while. There is nothing to - // let through here, so switching off is the only option. - Runnable disable = isNavigationRow(currentParts) - ? () -> { - setRowEnabled(currentParts, false); - rebuildItemsList(); - notifyService(); - } - : () -> showPauseDialog( - currentParts.get(0).packageName, currentParts); - ((MainActivity) context) - .runWithFrictionGate("Disable Rule", disable); - } - return; - } - - // Every part of the row moves together, so a row can - // never end up half applied. - setRowEnabled(currentParts, true); - - // Rebuild to update the package switch UI, showing rules, and updated counts - rebuildItemsList(); - - // Notify the service to update its rules - notifyService(); - } - } - }); - - // Set up long click to delete custom rules - viewHolder.itemView.setOnLongClickListener(v -> { - // A merged row is only deletable if it is custom throughout; - // deleting half of it would leave an orphaned built-in part. - boolean allCustom = true; - for (FilterRule part : parts) { - if (!part.isCustom) { - allCustom = false; - break; - } - } - if (allCustom) { - if (context instanceof MainActivity) { - ((MainActivity) context).runWithFrictionGate("Delete Rule", () -> new AlertDialog.Builder(context) - .setTitle(R.string.delete_rule_title) - .setMessage(R.string.delete_rule_message) - .setPositiveButton(R.string.delete_rule_confirm, (dialog, which) -> { - removeCustomRow(parts); - - // Check if we need to disable the package if it was the last rule - boolean anyRulesStillEnabled = false; - for (FilterRule r : currentRules) { - if (r.packageName.equals(rule.packageName) && r.enabled) { - anyRulesStillEnabled = true; - break; - } - } - if (!anyRulesStillEnabled) { - config.setPackageDisabled(rule.packageName, true); - } - - rebuildItemsList(); - notifyService(); - - Toast.makeText(context, R.string.rule_deleted, Toast.LENGTH_SHORT).show(); - }) - .setNegativeButton(R.string.delete_rule_cancel, null) - .show()); - } - return true; - } else { - Toast.makeText(context, R.string.builtin_rule_no_delete, Toast.LENGTH_SHORT).show(); - return true; // Consume the long click anyway to show the feedback - } - }); - } else if (holder instanceof FooterViewHolder) { - FooterViewHolder viewHolder = (FooterViewHolder) holder; - - if (context.getResources().getBoolean(R.bool.show_footer_branding)) { - // "Made with ❤️ by reddfocus.org" with clickable link - String fullText = "Made with ❤️ by reddfocus.org"; - SpannableString spannableString = new SpannableString(fullText); - int start = fullText.indexOf("reddfocus.org"); - if (start >= 0) { - int end = start + "reddfocus.org".length(); - ClickableSpan clickableSpan = new ClickableSpan() { - @Override - public void onClick(View widget) { - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://reddfocus.org")); - context.startActivity(browserIntent); - } - @Override - public void updateDrawState(android.text.TextPaint ds) { - ds.setUnderlineText(false); - ds.setColor(viewHolder.footerText.getCurrentTextColor()); - } - }; - spannableString.setSpan(clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - viewHolder.footerText.setText(spannableString); - viewHolder.footerText.setMovementMethod(LinkMovementMethod.getInstance()); - viewHolder.footerText.setHighlightColor(android.graphics.Color.TRANSPARENT); - } else { - viewHolder.footerText.setVisibility(View.GONE); - } - - // Recruitment message with clickable email (always shown) - String recruitmentFull = context.getString(R.string.recruitment_message); - String email = "konrad.kollnig@maastrichtuniversity.nl"; - SpannableString recruitmentSpannable = new SpannableString(recruitmentFull); - int emailStart = recruitmentFull.indexOf(email); - if (emailStart >= 0) { - int emailEnd = emailStart + email.length(); - ClickableSpan emailSpan = new ClickableSpan() { - @Override - public void onClick(View widget) { - Intent emailIntent = new Intent(Intent.ACTION_SENDTO); - emailIntent.setData(Uri.parse("mailto:" + email)); - context.startActivity(emailIntent); - } - @Override - public void updateDrawState(android.text.TextPaint ds) { - ds.setUnderlineText(true); - ds.setColor(viewHolder.recruitmentText.getCurrentTextColor()); - } - }; - recruitmentSpannable.setSpan(emailSpan, emailStart, emailEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - viewHolder.recruitmentText.setText(recruitmentSpannable); - viewHolder.recruitmentText.setMovementMethod(LinkMovementMethod.getInstance()); - viewHolder.recruitmentText.setHighlightColor(android.graphics.Color.TRANSPARENT); - } - } - - /** - * @param rules the parts of a single row, or null to act on the whole package - */ - private void showPauseDialog(String packageName, List rules) { - int durationMins = config.getPauseDurationMins(); - String message = "Do you want to pause for " + durationMins + " minutes or disable permanently?"; - - new AlertDialog.Builder(context) - .setTitle("Disable Rule") - .setMessage(message) - .setPositiveButton("Pause (" + durationMins + "m)", (dialog, which) -> { - if (rules != null) { - long until = System.currentTimeMillis() + (durationMins * 60 * 1000L); - for (FilterRule rule : rules) { - config.setRuleEnabled(rule, false); - config.setRulePausedUntil(rule, until); - rule.enabled = false; - rule.isPaused = true; - rule.pausedUntil = until; - } - } else { - long until = PauseManager.applyPackagePause(context, packageName); - // Update in-memory state for UI only; individual rule prefs are - // intentionally left untouched so they can be restored on re-enable. - for (FilterRule r : currentRules) { - if (r.packageName.equals(packageName)) { - r.enabled = false; - r.isPaused = true; - r.pausedUntil = until; - } - } - } - rebuildItemsList(); - if (rules != null) { - notifyService(); - } - }) - .setNeutralButton("Disable Permanently", (dialog, which) -> { - if (rules != null) { - for (FilterRule rule : rules) { - config.setRuleEnabled(rule, false); - config.setRulePausedUntil(rule, 0); - rule.enabled = false; - rule.isPaused = false; - rule.pausedUntil = 0; - } - } else { - config.setPackageDisabled(packageName, true); - config.setPackagePausedUntil(packageName, 0); - // Update in-memory state for UI only; individual rule prefs are - // intentionally left untouched so they can be restored on re-enable. - for (FilterRule r : currentRules) { - if (r.packageName.equals(packageName)) { - r.enabled = false; - r.isPaused = false; - r.pausedUntil = 0; - } - } - } - rebuildItemsList(); - notifyService(); - }) - .setNegativeButton("Cancel", (dialog, which) -> rebuildItemsList()) - .setOnCancelListener(dialog -> rebuildItemsList()) - .show(); - } - - /** - * Writes a row's on/off state to whichever store it came from. Navigation and blocking - * rules can share an identity -- hiding Instagram's inbox tab and opening it name the same - * element -- so the target store is chosen by the rule, never by its key. - */ - private void setRowEnabled(List parts, boolean enabled) { - boolean displaced = false; - for (FilterRule part : parts) { - part.enabled = enabled; - if (part.isNavigation) { - displaced |= config.setNavigationRuleEnabled(part, enabled); - } else { - part.isPaused = false; - part.pausedUntil = 0; - config.setRuleEnabled(part, enabled); - config.setRulePausedUntil(part, 0); - } - } - if (displaced) { - // Storage has switched the other rule off; the list renders from these objects, and - // rebuildItemsList carries their current state forward rather than re-reading, so - // without this the displaced switch stays visibly on and the section keeps counting - // it. Only the rule that moved is touched, so nothing else is disturbed. - markOtherNavigationRulesDisabled(currentRules, parts); - // The switch flipping on its own reads as a glitch unless something says why. - Toast.makeText(context, R.string.navigation_rule_replaced, Toast.LENGTH_LONG).show(); - } - } - - private void removeCustomRow(List parts) { - for (FilterRule part : parts) { - if (part.isNavigation) { - config.removeCustomNavigationRule(part.ruleString); - } else { - config.removeCustomRule(part.ruleString); - } - currentRules.remove(part); - } - } - - /** - * Mirrors, in memory, the switching-off that storage has just done to the other navigation - * rules for an app. - * - *

The list renders from these objects and {@link #rebuildItemsList} carries their current - * state forward rather than re-reading it, so a rule displaced only in preferences keeps its - * switch visibly on and keeps being counted as active -- the very thing this limit exists to - * stop. Rules for other apps, and blocking rules, are left alone. - */ - static void markOtherNavigationRulesDisabled(List rules, List kept) { - if (kept.isEmpty()) { - return; - } - String packageName = kept.get(0).packageName; - for (FilterRule rule : rules) { - if (rule.isNavigation && !kept.contains(rule) - && rule.packageName.equals(packageName)) { - rule.enabled = false; - } - } - } - - static boolean isNavigationRow(List 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