Skip to content

Commit 58b61cf

Browse files
kasnderclaude
andauthored
Make timeline default view (#572)
* Make Timeline live-update and give it a guiding empty state New users landed on an empty Timeline with no feedback: the screen only refreshed on onResume, and the empty text said "Enable TrackerControl" even when it was already on. They had no way to know they needed to open an app to generate traffic. - ActivityTimeline subscribes to DatabaseHelper.AccessChangedListener (same pattern as ActivityLog) with a 500ms debounce so entries stream in while the screen is open. - Wrap the RecyclerView in a SwipeRefreshLayout as a manual fallback. - Replace the single tvEmpty label with a context-aware empty state: "TrackerControl is off" when disabled, or "Watching for trackers…" plus an "Open an app" button (launches the system launcher) when enabled. - Drop the obsolete msg_private_dns Toast in ActivityMain — private DNS is blocked by default now, so the nudge no longer applies. - Remove the dead llUsage / hint_usage banner plumbing (the layout was already visibility="gone"; the flag was only gating other hints). * Add bottom nav with Timeline and Apps tabs Onboarding dropped new users on the app list with no guidance. With the Timeline now live-updating and owning a guiding empty state, promote it to a peer destination via a bottom navigation bar, landing users there by default while keeping the filterable app list and SearchView a tap away. - Extract TimelineFragment from ActivityTimeline (same live refresh, swipe refresh, and context-aware empty state with "Open an app" CTA) and reuse it in both ActivityMain and ActivityTimeline. - Add BottomNavigationView to main.xml with Timeline and Apps items; ActivityMain swaps visibility of the Apps content vs. the Timeline fragment container and adjusts the toolbar (custom on/off switch visible only on the Apps tab; screen title shown on Timeline). - onPrepareOptionsMenu hides search/filter/sort on the Timeline tab. - Remove the now-redundant "Tracker activity" overflow menu item and the ActivityMain->ActivityTimeline launcher intent. The standalone ActivityTimeline still exists as a thin fragment host so the Insights hero card's "timeline" shortcut keeps working (it now switches tabs in-place when launched from ActivityMain). - Back from Timeline returns to Apps instead of exiting. * Move insights hero card from Apps tab to Timeline The insights card (total trackers blocked, companies, block %) used to sit above the app list on the Apps tab. With Timeline as the default home, it makes more sense as the top card there — an at-a-glance dashboard that frames whatever tracker activity follows. - TimelineFragment now owns a ConcatAdapter of (InsightsHeaderAdapter, TimelineEmptyAdapter, TimelineAdapter). The insights card is always present; the empty-state card appears only when there are no timeline entries so the hero stays visible. - Move loadInsightsData() into TimelineFragment so insights refresh on the same debounced access-change callback as the timeline. - Strip InsightsHeaderAdapter, ConcatAdapter, and loadInsightsData from ActivityMain. The Apps tab is now a plain AdapterRule list. - Hide the now-redundant "open timeline" shortcut inside the insights card (the card already lives on Timeline). - Delete ActivityTimeline and its layout/manifest entry — no longer reachable now that the tab + in-card action route traffic directly. - Replace the overlay-style empty view in fragment_timeline.xml with an in-list TimelineEmptyAdapter so the insights card remains visible alongside the empty state. * Keep top bar identical across tabs and fix insights clipping Two fixes from on-device feedback: - The insights hero card was being clipped under the AppBar. The cause was wrapping the tab contents in an extra FrameLayout that broke the CoordinatorLayout's appbar_scrolling_view_behavior offset. Drop the wrapper and put llApps and timelineContainer back as direct children of the CoordinatorLayout (the original llApps pattern), each with the scrolling behavior + dodgeInsetEdges so they sit below the AppBar and above the bottom nav. - The toolbar was swapping its custom view (on/off switch) and title between tabs, which felt jarring. Keep the on/off switch always visible — the global VPN toggle now works on Timeline too — and drop the per-tab title swap. Filter and sort still hide on Timeline since they're meaningless without the app list, but Search stays visible: tapping it on Timeline jumps to the Apps tab and expands the search field. * Fix insights clipping by switching root to LinearLayout The clipping was real and caused by app:layout_dodgeInsetEdges="bottom" on the content child interacting with the BottomNavigationView's app:layout_insetEdge="bottom". CoordinatorLayout's dodge translates the dodging child upward by the insetting sibling's height, and combined with appbar_scrolling_view_behavior the result was the content's top ending up above the AppBar — so the insights card's top rows (Last 7 days header + first ~30dp of the big numbers) rendered behind the toolbar. We don't need any of CoordinatorLayout's behavior magic here: the AppBar is pinned (liftOnScroll="false"), the bottom nav is always visible, and there are no scroll-dependent transitions. Switch the root to a vertical LinearLayout — AppBar / content FrameLayout (weight=1) / BottomNavigationView — which gives a predictable static layout. Also swap timelineContainer from FragmentContainerView to a plain FrameLayout to avoid FragmentContainerView's window-insets interception, which was a second potential source of layout drift. * Fix doubled AppBar padding in Settings and Details Latent bug from commit 7e6bccb (the M3 edge-to-edge migration). Both activity_settings.xml and activity_details.xml have: CoordinatorLayout android:fitsSystemWindows="true" AppBarLayout MaterialToolbar Combined with the OnApplyWindowInsetsListener in ActivitySettings.java and DetailsActivity.java that pads the AppBar by sysBars.top, the status-bar inset was applied twice: once by the CoordinatorLayout (fitsSystemWindows pads the parent) and once by the listener (pads the AppBar) — yielding 2 × statusBarHeight of red space above the toolbar. ActivityMain's CoordinatorLayout never had fitsSystemWindows, which is why this branch's screenshots only showed it after looking at Settings. Drop fitsSystemWindows from both layouts so the listener is the single source of truth, matching how main.xml has always behaved. * Clean up dead divider + fix search-from-Timeline focus Two follow-ups from the bottom-nav move: Insights card: the white horizontal divider and the "View recent tracker activity" row used to separate the stats from the link to the Timeline activity. With the card now living on the Timeline tab itself the forwarding row was already hidden — drop the divider and the row from item_insights_header.xml entirely, and remove the matching field/wiring in InsightsHeaderAdapter. Search from Timeline: tapping the toolbar search icon already jumped to the Apps tab, but the SearchView never received focus (so the keyboard did not pop up and the user could not type). Two fixes: 1. selectTab no longer calls invalidateOptionsMenu. Filter/sort live in the overflow, so they re-prepare lazily next time it opens; we avoid rebuilding the SearchView mid-expand and dropping its focus. Also only collapse the SearchView on Apps→Timeline, not in the Timeline→Apps direction triggered by expanding it. 2. The bottom-nav switch in onMenuItemActionExpand is posted so the SearchView finishes expanding and the IME shows on the current frame; the layout toggle then runs without pre-empting focus. * Fix empty Timeline on open and add periodic refresh Two issues on the Timeline tab: 1. Opening the app went to "Watching for trackers…" even when there was recent activity; only pulling to refresh populated the list. Root cause: TimelineFragment.buildTimeline() calls the static TrackerList.findTracker(daddr), which reads a static hostname → Tracker map populated lazily by TrackerList.getInstance(context). InsightsDataProvider initializes it (kt:50, "Ensure TrackerList is initialized") but the timeline path never did. loadTimeline() and loadInsights() race on different executors; if the timeline ran first the map was empty, every entry was dropped, and the empty state showed. Pull-to- refresh worked because by then insights had won the race. Call TrackerList.getInstance() at the top of buildTimeline. 2. The screen relied solely on AccessChangedListener for updates, so relative timestamps drifted and any missed callback left the list stale. Add a 30-second periodic tick (Handler.postDelayed loop) that re-runs refreshAll while the fragment is resumed, and cancel it in onPause. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f94d80 commit 58b61cf

16 files changed

Lines changed: 508 additions & 379 deletions

app/src/main/AndroidManifest.xml

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,15 +169,6 @@
169169
android:value="eu.faircode.netguard.ActivitySettings" />
170170
</activity>
171171

172-
<activity
173-
android:name="net.kollnig.missioncontrol.ActivityTimeline"
174-
android:label="@string/title_tracker_activity"
175-
android:parentActivityName="eu.faircode.netguard.ActivityMain"
176-
android:theme="@style/AppThemeRed.NoActionBar">
177-
<meta-data
178-
android:name="android.support.PARENT_ACTIVITY"
179-
android:value="eu.faircode.netguard.ActivityMain" />
180-
</activity>
181172

182173
<activity
183174
android:name="net.kollnig.missioncontrol.DetailsActivity"

app/src/main/java/eu/faircode/netguard/ActivityMain.java

Lines changed: 85 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,17 @@
7575
import androidx.core.view.WindowInsetsCompat;
7676
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
7777
import androidx.preference.PreferenceManager;
78-
import androidx.recyclerview.widget.ConcatAdapter;
7978
import androidx.recyclerview.widget.LinearLayoutManager;
8079
import androidx.recyclerview.widget.RecyclerView;
8180
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
8281

82+
import com.google.android.material.bottomnavigation.BottomNavigationView;
8383
import com.opencsv.CSVWriter;
8484

8585
import net.kollnig.missioncontrol.ActivityOnboarding;
8686
import net.kollnig.missioncontrol.Common;
87-
import net.kollnig.missioncontrol.InsightsHeaderAdapter;
8887
import net.kollnig.missioncontrol.R;
89-
import net.kollnig.missioncontrol.data.InsightsData;
90-
import net.kollnig.missioncontrol.data.InsightsDataProvider;
88+
import net.kollnig.missioncontrol.TimelineFragment;
9189
import net.kollnig.missioncontrol.data.Tracker;
9290
import net.kollnig.missioncontrol.data.TrackerList;
9391

@@ -99,8 +97,6 @@
9997
import java.util.Collections;
10098
import java.util.Date;
10199
import java.util.List;
102-
import java.util.concurrent.ExecutorService;
103-
import java.util.concurrent.Executors;
104100

105101
public class ActivityMain extends AppCompatActivity implements SharedPreferences.OnSharedPreferenceChangeListener {
106102
private static final String TAG = "TrackerControl.Main";
@@ -119,9 +115,12 @@ public class ActivityMain extends AppCompatActivity implements SharedPreferences
119115
private MaterialSwitch swEnabled;
120116
private ImageView ivMetered;
121117
private SwipeRefreshLayout swipeRefresh;
122-
private InsightsHeaderAdapter headerAdapter;
123118
private AdapterRule adapter = null;
124119
private MenuItem menuSearch = null;
120+
private BottomNavigationView bottomNav;
121+
private View llAppsContent;
122+
private View timelineContainer;
123+
private boolean showingTimeline = false;
125124
private AlertDialog dialogVpn = null;
126125
private AlertDialog dialogLegend = null;
127126
private AlertDialog dialogAbout = null;
@@ -310,10 +309,6 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
310309
Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex));
311310
}
312311

313-
boolean filter = prefs.getBoolean("filter", true);
314-
if (filter && Util.isPrivateDns(ActivityMain.this))
315-
Toast.makeText(ActivityMain.this, R.string.msg_private_dns, Toast.LENGTH_LONG).show();
316-
317312
try {
318313
final Intent prepare = VpnService.prepare(ActivityMain.this);
319314
if (prepare == null) {
@@ -381,6 +376,36 @@ public boolean onLongClick(View view) {
381376
getSupportActionBar().setDisplayShowCustomEnabled(true);
382377
getSupportActionBar().setCustomView(actionView);
383378

379+
// Bottom navigation: Timeline / Apps
380+
llAppsContent = findViewById(R.id.llApps);
381+
timelineContainer = findViewById(R.id.timelineContainer);
382+
bottomNav = findViewById(R.id.bottomNav);
383+
384+
if (savedInstanceState == null) {
385+
getSupportFragmentManager()
386+
.beginTransaction()
387+
.replace(R.id.timelineContainer, new TimelineFragment())
388+
.commit();
389+
}
390+
391+
bottomNav.setOnItemSelectedListener(item -> {
392+
selectTab(item.getItemId());
393+
return true;
394+
});
395+
396+
int initialTab;
397+
if (savedInstanceState == null) {
398+
// Land new users on Timeline; keep returning users there too — it is
399+
// the "what's happening now" dashboard. Apps is one tap away.
400+
initialTab = R.id.nav_timeline;
401+
} else {
402+
initialTab = bottomNav.getSelectedItemId();
403+
if (initialTab == 0)
404+
initialTab = R.id.nav_timeline;
405+
}
406+
bottomNav.setSelectedItemId(initialTab);
407+
selectTab(initialTab);
408+
384409
// Disabled warning
385410
TextView tvDisabled = findViewById(R.id.tvDisabled);
386411
tvDisabled.setVisibility(enabled ? View.GONE : View.VISIBLE);
@@ -402,12 +427,8 @@ public void onClick(View v) {
402427
LinearLayoutManager llm = new LinearLayoutManager(this);
403428
rvApplication.setLayoutManager(llm);
404429
rvApplication.setItemViewCacheSize(20);
405-
headerAdapter = new InsightsHeaderAdapter(this);
406430
adapter = new AdapterRule(this, findViewById(R.id.vwPopupAnchor));
407-
ConcatAdapter concatAdapter = new ConcatAdapter(headerAdapter, adapter);
408-
rvApplication.setAdapter(concatAdapter);
409-
410-
loadInsightsData();
431+
rvApplication.setAdapter(adapter);
411432

412433
// Swipe to refresh
413434
swipeRefresh = findViewById(R.id.swipeRefresh);
@@ -417,21 +438,6 @@ public void onRefresh() {
417438
Rule.clearCache(ActivityMain.this);
418439
ServiceSinkhole.reload("pull", ActivityMain.this, false);
419440
updateApplicationList(null);
420-
loadInsightsData();
421-
}
422-
});
423-
424-
// Hint usage
425-
final LinearLayout llUsage = findViewById(R.id.llUsage);
426-
Button btnUsage = findViewById(R.id.btnUsage);
427-
boolean hintUsage = prefs.getBoolean("hint_usage", true);
428-
llUsage.setVisibility(hintUsage ? View.VISIBLE : View.GONE);
429-
btnUsage.setOnClickListener(new View.OnClickListener() {
430-
@Override
431-
public void onClick(View view) {
432-
prefs.edit().putBoolean("hint_usage", false).apply();
433-
llUsage.setVisibility(View.GONE);
434-
showHints();
435441
}
436442
});
437443

@@ -499,8 +505,6 @@ protected void onResume() {
499505
return;
500506
}
501507

502-
loadInsightsData();
503-
504508
DatabaseHelper.getInstance(this).addAccessChangedListener(accessChangedListener);
505509
if (adapter != null)
506510
adapter.notifyDataSetChanged();
@@ -821,7 +825,6 @@ public void onReceive(Context context, Intent intent) {
821825
}
822826
if (intent.getBooleanExtra(EXTRA_REFRESH, false)) {
823827
updateApplicationList(null);
824-
loadInsightsData();
825828
}
826829
} else
827830
updateApplicationList(null);
@@ -863,6 +866,15 @@ public boolean onCreateOptionsMenu(Menu menu) {
863866
menuSearch.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
864867
@Override
865868
public boolean onMenuItemActionExpand(MenuItem item) {
869+
// Search filters the app list, so jump to the Apps tab when
870+
// the user expands it from Timeline. Post the switch so the
871+
// SearchView finishes expanding (and grabs focus + opens
872+
// the keyboard) on the current frame; otherwise the layout
873+
// toggle in selectTab pre-empts focus before the IME shows.
874+
if (showingTimeline && bottomNav != null) {
875+
final BottomNavigationView nav = bottomNav;
876+
nav.post(() -> nav.setSelectedItemId(R.id.nav_apps));
877+
}
866878
return true;
867879
}
868880

@@ -895,8 +907,6 @@ public boolean onQueryTextChange(final String newText) {
895907
public void run() {
896908
if (adapter != null)
897909
adapter.getFilter().filter(newText);
898-
if (headerAdapter != null)
899-
headerAdapter.setVisible(TextUtils.isEmpty(newText));
900910
}
901911
};
902912
searchHandler.postDelayed(searchRunnable, SEARCH_DEBOUNCE_MS);
@@ -911,8 +921,6 @@ public boolean onClose() {
911921

912922
if (adapter != null)
913923
adapter.getFilter().filter(null);
914-
if (headerAdapter != null)
915-
headerAdapter.setVisible(true);
916924
return true;
917925
}
918926
});
@@ -937,6 +945,15 @@ public boolean onClose() {
937945
public boolean onPrepareOptionsMenu(Menu menu) {
938946
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
939947

948+
// Filter/sort are list-specific; hide them on the Timeline tab.
949+
// Search stays visible — clicking it on Timeline jumps to the Apps tab.
950+
MenuItem filterItem = menu.findItem(R.id.menu_filter);
951+
MenuItem sortItem = menu.findItem(R.id.menu_sort);
952+
if (filterItem != null) filterItem.setVisible(!showingTimeline);
953+
if (sortItem != null) sortItem.setVisible(!showingTimeline);
954+
if (showingTimeline)
955+
return super.onPrepareOptionsMenu(menu);
956+
940957
if (prefs.getBoolean("manage_system", false)) {
941958
menu.findItem(R.id.menu_app_user).setChecked(prefs.getBoolean("show_user", true));
942959
menu.findItem(R.id.menu_app_system).setChecked(prefs.getBoolean("show_system", false));
@@ -1012,9 +1029,6 @@ public boolean onOptionsItemSelected(MenuItem item) {
10121029
item.setChecked(true);
10131030
prefs.edit().putString("sort", "uid").apply();
10141031
return true;
1015-
} else if (itemId == R.id.menu_timeline) {
1016-
startActivity(new Intent(this, net.kollnig.missioncontrol.ActivityTimeline.class));
1017-
return true;
10181032
} else if (itemId == R.id.menu_log) {
10191033
if (Util.canFilter(this))
10201034
startActivity(new Intent(this, ActivityLog.class));
@@ -1059,9 +1073,36 @@ private Intent getIntentCreateExport() {
10591073
return intent;
10601074
}
10611075

1076+
private void selectTab(int itemId) {
1077+
boolean timeline = itemId == R.id.nav_timeline;
1078+
showingTimeline = timeline;
1079+
llAppsContent.setVisibility(timeline ? View.GONE : View.VISIBLE);
1080+
timelineContainer.setVisibility(timeline ? View.VISIBLE : View.GONE);
1081+
1082+
// Collapse SearchView only when leaving Apps for Timeline. When the
1083+
// user opens the SearchView from Timeline we switch *to* Apps; in
1084+
// that path we must NOT collapse, otherwise the freshly-expanded
1085+
// SearchView is torn down before it can take focus.
1086+
if (timeline && menuSearch != null && menuSearch.isActionViewExpanded())
1087+
menuSearch.collapseActionView();
1088+
1089+
// No invalidateOptionsMenu(): filter/sort live in the overflow,
1090+
// which calls onPrepareOptionsMenu the next time it is opened, so
1091+
// their visibility updates lazily — and we avoid rebuilding the
1092+
// SearchView mid-expand, which would also drop keyboard focus.
1093+
}
1094+
1095+
@Override
1096+
public void onBackPressed() {
1097+
if (showingTimeline && bottomNav != null) {
1098+
bottomNav.setSelectedItemId(R.id.nav_apps);
1099+
return;
1100+
}
1101+
super.onBackPressed();
1102+
}
1103+
10621104
private void showHints() {
10631105
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
1064-
boolean hintUsage = prefs.getBoolean("hint_usage", true);
10651106

10661107
// Hint white listing
10671108
final LinearLayout llWhitelist = findViewById(R.id.llWhitelist);
@@ -1070,7 +1111,7 @@ private void showHints() {
10701111
boolean whitelist_other = prefs.getBoolean("whitelist_other", false);
10711112
boolean hintWhitelist = prefs.getBoolean("hint_whitelist", true);
10721113
llWhitelist.setVisibility(
1073-
!(whitelist_wifi || whitelist_other) && hintWhitelist && !hintUsage ? View.VISIBLE : View.GONE);
1114+
!(whitelist_wifi || whitelist_other) && hintWhitelist ? View.VISIBLE : View.GONE);
10741115
btnWhitelist.setOnClickListener(new View.OnClickListener() {
10751116
@Override
10761117
public void onClick(View view) {
@@ -1083,7 +1124,7 @@ public void onClick(View view) {
10831124
final LinearLayout llPush = findViewById(R.id.llPush);
10841125
Button btnPush = findViewById(R.id.btnPush);
10851126
boolean hintPush = prefs.getBoolean("hint_push", true);
1086-
llPush.setVisibility(hintPush && !hintUsage ? View.VISIBLE : View.GONE);
1127+
llPush.setVisibility(hintPush ? View.VISIBLE : View.GONE);
10871128
btnPush.setOnClickListener(new View.OnClickListener() {
10881129
@Override
10891130
public void onClick(View view) {
@@ -1364,16 +1405,4 @@ private static Intent getIntentSupport(Context context) {
13641405
Uri.parse("https://github.com/TrackerControl/tracker-control-android#support-trackercontrol"));
13651406
}
13661407

1367-
private void loadInsightsData() {
1368-
ExecutorService executor = Executors.newSingleThreadExecutor();
1369-
executor.execute(() -> {
1370-
InsightsDataProvider provider = new InsightsDataProvider(this);
1371-
InsightsData data = provider.computeInsights();
1372-
runOnUiThread(() -> {
1373-
if (headerAdapter != null) {
1374-
headerAdapter.setData(data);
1375-
}
1376-
});
1377-
});
1378-
}
13791408
}

app/src/main/java/net/kollnig/missioncontrol/InsightsHeaderAdapter.java

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@
4949
import java.util.concurrent.Executor;
5050
import java.util.concurrent.Executors;
5151

52-
import eu.faircode.netguard.Util;
53-
5452
/**
5553
* Adapter that displays the insights hero card as a header in the main
5654
* RecyclerView.
@@ -129,9 +127,6 @@ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
129127
holder.tvSeeMore.setOnClickListener(v -> {
130128
context.startActivity(new Intent(context, InsightsActivity.class));
131129
});
132-
holder.llTimelineAction.setOnClickListener(v -> {
133-
context.startActivity(new Intent(context, ActivityTimeline.class));
134-
});
135130
holder.itemView.setOnClickListener(v -> {
136131
context.startActivity(new Intent(context, InsightsActivity.class));
137132
});
@@ -282,7 +277,6 @@ static class ViewHolder extends RecyclerView.ViewHolder {
282277
TextView tvCompanies;
283278
ImageButton btnShare;
284279
TextView tvSeeMore;
285-
LinearLayout llTimelineAction;
286280
TextView tvBlockedPct;
287281
View vBlockedProgress;
288282
View vAllowedProgress;
@@ -295,7 +289,6 @@ static class ViewHolder extends RecyclerView.ViewHolder {
295289
tvCompanies = itemView.findViewById(R.id.tvHeroCompanies);
296290
btnShare = itemView.findViewById(R.id.btnShare);
297291
tvSeeMore = itemView.findViewById(R.id.tvSeeMore);
298-
llTimelineAction = itemView.findViewById(R.id.llTimelineAction);
299292
tvBlockedPct = itemView.findViewById(R.id.tvHeroBlockedPct);
300293
vBlockedProgress = itemView.findViewById(R.id.vBlockedProgress);
301294
vAllowedProgress = itemView.findViewById(R.id.vAllowedProgress);

0 commit comments

Comments
 (0)