From 49ef4a159d09ccefdf3cbdd451b1ae0e2a6a3fb5 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:34:59 +0200 Subject: [PATCH 1/2] Collapse the per-app controls into rows that open on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protection and Remote VPN each showed every option's full explanation at all times: eight paragraphs of copy between the tab bar and the first tracker, so the list the screen exists for started two and a half screens down. The explanations are worth their length — "Trackers allowed" versus "Bypass TrackerControl" is not a choice anyone should make from the label alone — but paying for them on every visit is what pushed the trackers off the screen. Each control is now a row carrying its current value ("Protection — Protected"), and the radio list with the full explanations lives in a bottom sheet behind it. Comparing the options before choosing still works, because the sheet shows all of them together with their copy intact; it just no longer happens by default. The tracker categories are back on the first screen. The remote-routing row states its own unavailability instead of hiding: when there is no remote VPN, the app bypasses TrackerControl, or the tunnel carries only part of the traffic, the reason sits where the value would be and the row does not open. Nothing about the underlying state changes. resolve/applyState, the reload gating, and the per-app wg_route write are the same calls in the same order — the diff is the view layer, and AppProtectionStateTest and RemoteRoutingLogicTest still cover the logic unchanged. No new strings, so no locale file is touched. Two details the radio groups did not have to handle. The sheet dismisses before the state is applied, because applyState triggers a rebind and the old isPressed() guard against programmatic re-checks has no equivalent here. And the sheet is dropped in onDetachedFromRecyclerView, so a details screen torn down with one open does not leak its window. Co-Authored-By: Claude Fable 5 --- .../details/TrackersListAdapter.java | 190 +++++++++---- .../main/res/drawable/ic_chevron_right.xml | 10 + .../res/layout/bottom_sheet_app_route.xml | 75 +++++ .../res/layout/bottom_sheet_app_state.xml | 111 ++++++++ .../res/layout/list_item_trackers_header.xml | 262 +++++++----------- 5 files changed, 437 insertions(+), 211 deletions(-) create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/layout/bottom_sheet_app_route.xml create mode 100644 app/src/main/res/layout/bottom_sheet_app_state.xml diff --git a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java index 44c9ef101..e29f80d80 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java +++ b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersListAdapter.java @@ -37,6 +37,7 @@ import android.widget.ListView; import android.widget.ProgressBar; import android.widget.RadioGroup; +import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.materialswitch.MaterialSwitch; import android.widget.TextView; import android.widget.Toast; @@ -94,6 +95,9 @@ public class TrackersListAdapter extends RecyclerView.Adapter { - AppProtectionState selected = stateForRadioId(checkedId); - if (selected == null || selected == currentState(w)) - return; - - applyState(selected, w); - notifyDataSetChanged(); - }); + holder.mAppStateValue.setText(stateLabelRes(currentState(w))); + holder.mRowAppState.setOnClickListener(v -> showProtectionSheet(w)); bindRemoteRouting(holder); } @@ -498,34 +481,129 @@ private void bindRemoteRouting(VHHeader holder) { RemoteRoutingLogic.Unavailable unavailable = RemoteRoutingLogic.getUnavailableReason(wgEnabled, defaultRoutes, applyApp); if (unavailable != null) { - holder.mAppRoute.setVisibility(View.GONE); - holder.mAppRouteUnavailable.setVisibility(View.VISIBLE); - holder.mAppRouteUnavailable.setText(explainUnavailable(unavailable)); + holder.mRowAppRoute.setOnClickListener(null); + holder.mRowAppRoute.setClickable(false); + holder.mRowAppRoute.setEnabled(false); + holder.mAppRouteChevron.setVisibility(View.GONE); + holder.mAppRouteValue.setText(explainUnavailable(unavailable)); return; } - holder.mAppRouteUnavailable.setVisibility(View.GONE); - holder.mAppRoute.setVisibility(View.VISIBLE); + String mode = RemoteRoutingLogic.normalizeMode( + prefs.getString(Rule.PREF_WG_ROUTE_MODE, RemoteRoutingLogic.getDefaultMode())); + boolean tunnelled = RemoteRoutingLogic.routesThroughTunnel(mode, getRouteOverride(), true); + holder.mRowAppRoute.setEnabled(true); + holder.mRowAppRoute.setClickable(true); + holder.mAppRouteChevron.setVisibility(View.VISIBLE); + holder.mAppRouteValue.setText(tunnelled ? R.string.app_route_through : R.string.app_route_direct); + holder.mRowAppRoute.setOnClickListener(v -> showRouteSheet()); + } + + /** + * Shows the protection-state bottom sheet, wiring the shared-UID + * No-Internet explanation into the sheet's own description view. + */ + private void showProtectionSheet(InternetBlocklist w) { + BottomSheetDialog sheet = new BottomSheetDialog(mContext); + View view = LayoutInflater.from(mContext).inflate(R.layout.bottom_sheet_app_state, null); + sheet.setContentView(view); + + // The internet block is keyed by UID, so it necessarily covers every + // package sharing that UID. Say so rather than letting it surprise. + TextView noInternetDesc = view.findViewById(R.id.tvStateNoInternetDesc); + String relatedApps = getRelatedApps(); + if (relatedApps == null) { + noInternetDesc.setText(R.string.app_state_no_internet_explanation); + } else { + String explanation = mContext.getString(R.string.app_state_no_internet_explanation_shared, + mContext.getString(R.string.app_state_no_internet_explanation), + mContext.getString(R.string.app_state_no_internet_shared_uid, relatedApps)); + noInternetDesc.setText(explanation); + } + + RadioGroup rgAppState = view.findViewById(R.id.rgAppState); + rgAppState.check(radioIdFor(currentState(w))); + rgAppState.setOnCheckedChangeListener((group, checkedId) -> { + AppProtectionState selected = stateForRadioId(checkedId); + + // Dismiss first: applyState() may trigger a reload, and the sheet + // shouldn't linger on screen while that happens. + sheet.dismiss(); + + if (selected != null && selected != currentState(w)) { + applyState(selected, w); + notifyDataSetChanged(); + } + }); + + showSheet(sheet); + } + + /** + * Shows the remote-routing bottom sheet, recomputing the current mode the + * same way {@link #bindRemoteRouting(VHHeader)} does. + */ + private void showRouteSheet() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext); String mode = RemoteRoutingLogic.normalizeMode( prefs.getString(Rule.PREF_WG_ROUTE_MODE, RemoteRoutingLogic.getDefaultMode())); boolean tunnelled = RemoteRoutingLogic.routesThroughTunnel(mode, getRouteOverride(), true); - holder.mAppRoute.setOnCheckedChangeListener(null); - holder.mAppRoute.check(tunnelled ? R.id.rbRouteTunnel : R.id.rbRouteDirect); - holder.mAppRoute.setOnCheckedChangeListener((group, checkedId) -> { + BottomSheetDialog sheet = new BottomSheetDialog(mContext); + View view = LayoutInflater.from(mContext).inflate(R.layout.bottom_sheet_app_route, null); + sheet.setContentView(view); + + RadioGroup rgAppRoute = view.findViewById(R.id.rgAppRoute); + rgAppRoute.check(tunnelled ? R.id.rbRouteTunnel : R.id.rbRouteDirect); + rgAppRoute.setOnCheckedChangeListener((group, checkedId) -> { boolean wantsTunnel = (checkedId == R.id.rbRouteTunnel); - if (wantsTunnel == RemoteRoutingLogic.routesThroughTunnel(mode, getRouteOverride(), true)) - return; - mContext.getSharedPreferences(Rule.PREF_WG_ROUTE, Context.MODE_PRIVATE) - .edit().putBoolean(mAppId, wantsTunnel).apply(); + // Dismiss first: the reload triggered below shouldn't hold the + // sheet open while it runs. + sheet.dismiss(); + + if (wantsTunnel != RemoteRoutingLogic.routesThroughTunnel(mode, getRouteOverride(), true)) { + mContext.getSharedPreferences(Rule.PREF_WG_ROUTE, Context.MODE_PRIVATE) + .edit().putBoolean(mAppId, wantsTunnel).apply(); + + AsyncTask.execute(() -> { + Rule.clearCache(mContext); + ServiceSinkhole.reload("app routing changed", mContext, false); + }); + + // The row subtitle now depends on this value. + notifyDataSetChanged(); + } + }); + + showSheet(sheet); + } - AsyncTask.execute(() -> { - Rule.clearCache(mContext); - ServiceSinkhole.reload("app routing changed", mContext, false); - }); + /** + * Only one sheet should be open at a time, and it must not outlive the + * RecyclerView that hosts the row that opened it. + */ + private void showSheet(BottomSheetDialog sheet) { + if (mOpenSheet != null) + mOpenSheet.dismiss(); + + mOpenSheet = sheet; + sheet.setOnDismissListener(d -> { + if (mOpenSheet == d) + mOpenSheet = null; }); + sheet.show(); + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + super.onDetachedFromRecyclerView(recyclerView); + + if (mOpenSheet != null) { + mOpenSheet.dismiss(); + mOpenSheet = null; + } } @Nullable @@ -657,6 +735,20 @@ private static AppProtectionState stateForRadioId(int checkedId) { return null; } + private static int stateLabelRes(AppProtectionState state) { + switch (state) { + case TRACKERS_ALLOWED: + return R.string.app_state_trackers_allowed; + case NO_INTERNET: + return R.string.app_state_no_internet; + case BYPASSED: + return R.string.app_state_bypassed; + case PROTECTED: + default: + return R.string.app_state_protected; + } + } + @Override public int getItemCount() { return mValues.size() + 1; @@ -698,21 +790,21 @@ static class VHItem extends RecyclerView.ViewHolder { static class VHHeader extends RecyclerView.ViewHolder { final TextView mLibraryExplanation; final TextView mLibraryDisclaimer; - final RadioGroup mAppState; - final TextView mNoInternetExplanation; - final View mAppRouteCard; - final RadioGroup mAppRoute; - final TextView mAppRouteUnavailable; + final View mRowAppState; + final TextView mAppStateValue; + final View mRowAppRoute; + final TextView mAppRouteValue; + final View mAppRouteChevron; VHHeader(View view) { super(view); mLibraryExplanation = view.findViewById(R.id.tvLibraryExplanation); mLibraryDisclaimer = view.findViewById(R.id.tvLibraryDisclaimer); - mAppState = view.findViewById(R.id.rgAppState); - mNoInternetExplanation = view.findViewById(R.id.tvStateNoInternetDesc); - mAppRouteCard = view.findViewById(R.id.cardAppRoute); - mAppRoute = view.findViewById(R.id.rgAppRoute); - mAppRouteUnavailable = view.findViewById(R.id.tvAppRouteUnavailable); + mRowAppState = view.findViewById(R.id.rowAppState); + mAppStateValue = view.findViewById(R.id.tvAppStateValue); + mRowAppRoute = view.findViewById(R.id.rowAppRoute); + mAppRouteValue = view.findViewById(R.id.tvAppRouteValue); + mAppRouteChevron = view.findViewById(R.id.ivAppRouteChevron); } } } diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..650515bba --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/bottom_sheet_app_route.xml b/app/src/main/res/layout/bottom_sheet_app_route.xml new file mode 100644 index 000000000..1b68813e1 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_app_route.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_sheet_app_state.xml b/app/src/main/res/layout/bottom_sheet_app_state.xml new file mode 100644 index 000000000..6fa270219 --- /dev/null +++ b/app/src/main/res/layout/bottom_sheet_app_state.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/list_item_trackers_header.xml b/app/src/main/res/layout/list_item_trackers_header.xml index 194b82e4f..f090ec373 100644 --- a/app/src/main/res/layout/list_item_trackers_header.xml +++ b/app/src/main/res/layout/list_item_trackers_header.xml @@ -180,11 +180,12 @@ - + - - + android:orientation="vertical"> - - - - - - - - - - - + + - - - - - - - - - - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + - - - - - - - - + - + android:layout_height="1dp" + android:layout_marginStart="16dp" + android:background="?android:attr/listDivider" /> - - - - - + + + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + - - - - - + From aac7675ad56400ec0fc6098d155a1460f70b633a Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:49:06 +0200 Subject: [PATCH 2/2] Dismiss app settings sheets on view teardown --- .../missioncontrol/details/TrackersFragment.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersFragment.java b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersFragment.java index fcbb089a5..1273ebf18 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/details/TrackersFragment.java +++ b/app/src/main/java/net/kollnig/missioncontrol/details/TrackersFragment.java @@ -40,6 +40,7 @@ public class TrackersFragment extends Fragment { private int mAppUid; private SwipeRefreshLayout swipeRefresh; + private RecyclerView recyclerView; private TrackersListAdapter adapter; private boolean running = false; @@ -79,7 +80,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Context c = v.getContext(); trackerList = TrackerList.getInstance(c); - RecyclerView recyclerView = v.findViewById(R.id.transmissions_list); + recyclerView = v.findViewById(R.id.transmissions_list); recyclerView.setLayoutManager(new LinearLayoutManager(c)); adapter = new TrackersListAdapter(getActivity(), recyclerView, mAppUid, mAppId); recyclerView.setAdapter(adapter); @@ -212,10 +213,19 @@ private void suggestLaunchingApp() { @Override public void onDestroyView() { - super.onDestroyView(); + // Explicitly detach the adapter while the RecyclerView still exists. + // This invokes onDetachedFromRecyclerView(), which dismisses any open + // BottomSheetDialog before the view's Activity context can be retained. + if (recyclerView != null) { + recyclerView.setAdapter(null); + recyclerView = null; + } + adapter = null; + // Avoid leaking the SwipeRefreshLayout (and its Activity context) beyond // the view's lifecycle when the Fragment instance is retained. swipeRefresh = null; + super.onDestroyView(); } @Override