Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
</intent-filter>
</activity>

<activity
android:name="net.kollnig.greasemilkyway.AppDetailActivity"
android:exported="false"
android:parentActivityName="net.kollnig.greasemilkyway.MainActivity" />

<activity
android:name="net.kollnig.greasemilkyway.CustomRulesActivity"
android:exported="false"
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/assets/navigation_rules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
// and only viewId and desc are used to find it. Prefer viewId: it survives translation, which
// matters here because these targets are tab bars and filter chips whose labels are localised.
// Each rule fires once per visit -- navigating back afterwards is left to the user.
com.instagram.android##viewId=com.instagram.android:id/direct_tab##category=Open on launch##comment=Open Messages instead of the feed
com.whatsapp##viewId=com.whatsapp:id/conversations_filter_debug_view_id_favorite##category=Open on launch##comment=Open the Favourites list instead of all chats
com.instagram.android##viewId=com.instagram.android:id/direct_tab##category=Open on launch##comment=Go to Messages
com.whatsapp##viewId=com.whatsapp:id/conversations_filter_debug_view_id_favorite##category=Open on launch##comment=Go to Favourites
40 changes: 40 additions & 0 deletions app/src/main/java/net/kollnig/greasemilkyway/AppCatalog.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package net.kollnig.greasemilkyway;

import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;

import java.util.HashMap;
import java.util.Map;

/** App labels/icons have one fallback path so overview and detail cannot disagree. */
final class AppCatalog {
private static final Map<String, String> NAMES = new HashMap<>();
private static final Map<String, Integer> 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; }
}
}
272 changes: 272 additions & 0 deletions app/src/main/java/net/kollnig/greasemilkyway/AppDetailActivity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
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<FilterRule> rules = new ArrayList<>();
private AppDetailAdapter adapter;
private Runnable afterGate;

private final ActivityResultLauncher<Intent> 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_shorter).setOnClickListener(view -> pauseForChip(0));
findViewById(R.id.pause_default).setOnClickListener(view -> pauseForChip(1));
findViewById(R.id.pause_longer).setOnClickListener(view -> pauseForChip(2));

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<FilterRule> 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);
}
int defaultMinutes = config.getPauseDurationMins();
long[] pauseDurations = PauseChipDurations.aroundDefault(defaultMinutes);
((MaterialButton) findViewById(R.id.pause_shorter))
.setText(formatPauseDuration(pauseDurations[0]));
((MaterialButton) findViewById(R.id.pause_default)).setText(
getString(R.string.pause_chip_default,
formatPauseDuration(pauseDurations[1])));
((MaterialButton) findViewById(R.id.pause_longer))
.setText(formatPauseDuration(pauseDurations[2]));
adapter.setRules(appRules);
}

private void showPauseOrDisableDialog() {
PauseOrDisableDialog.show(this, config,
() -> {
PauseManager.applyPackagePause(this, packageName);
load();
}, () -> {
config.setPackageDisabled(packageName, true);
config.setPackagePausedUntil(packageName, 0);
notifyService();
load();
}, this::load);
}

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<ElementPickerOverlay.Mode> 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 pauseForChip(int index) {
long durationMillis = PauseChipDurations.aroundDefault(
config.getPauseDurationMins())[index];
runWithFrictionGate(getString(R.string.pause_app_title), () -> {
PauseManager.applyPackagePauseUntil(this, packageName,
Math.addExact(System.currentTimeMillis(), durationMillis));
load();
});
}

private String formatPauseDuration(long durationMillis) {
if (durationMillis < PauseChipDurations.MINUTE_MILLIS) {
int seconds = (int) (durationMillis / PauseChipDurations.SECOND_MILLIS);
return getResources().getQuantityString(
R.plurals.pause_chip_seconds, seconds, seconds);
}
int minutes = (int) (durationMillis / PauseChipDurations.MINUTE_MILLIS);
if (minutes % 60 == 0) {
int hours = minutes / 60;
return getResources().getQuantityString(
R.plurals.pause_chip_hours, hours, hours);
}
return getResources().getQuantityString(
R.plurals.pause_chip_minute_value, minutes, minutes);
}

@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;
});
}
}
Loading
Loading