fix(android): pop to start destination on back per nav guidelines

The hardware back button replayed the SPA history stack — every bottom-nav
tab switch pushed a window.history entry, so back walked through every
previously visited tab instead of exiting (#7972).

Add AndroidBackButtonService, which main.ts delegates the backButton event to:
overlays still close via window.history.back(); a top-level destination pops
to the configured start destination (or exits if already there); deeper pages
keep normal up-navigation. The start destination reuses getStartPageUrlPath().

Covered by unit + real-Router integration specs. Native backButton/minimizeApp
wiring still needs on-device verification.
This commit is contained in:
Johannes Millan 2026-06-03 11:47:58 +02:00
parent 145ed02774
commit 89d5fd39ba
4 changed files with 618 additions and 1 deletions

View file

@ -0,0 +1,141 @@
import { Component } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideLocationMocks } from '@angular/common/testing';
import { provideRouter, Router } from '@angular/router';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { AndroidBackButtonService } from './android-back-button.service';
import { GlobalConfigService } from '../config/global-config.service';
import { DefaultStartPage } from '../config/default-start-page.const';
import { selectIsOverlayShown } from '../focus-mode/store/focus-mode.selectors';
import { selectAllProjects } from '../project/store/project.selectors';
import { TODAY_TAG } from '../tag/tag.const';
/**
* Integration test that exercises the decision logic against the REAL Angular
* Router (in-memory history via provideLocationMocks) so it verifies the actual
* serialized `router.url` strings match the service's predicates something the
* mocked-Router unit spec cannot prove.
*/
@Component({ standalone: true, template: '' })
class DummyComponent {}
const TODAY_URL = `/tag/${TODAY_TAG.id}/tasks`;
describe('AndroidBackButtonService integration with real Router (#7972)', () => {
let service: AndroidBackButtonService;
let router: Router;
let minimizeApp: jasmine.Spy;
let historyBack: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AndroidBackButtonService,
provideMockStore(),
provideLocationMocks(),
provideRouter([
{ path: 'tag/:id/tasks', component: DummyComponent },
{ path: 'tag/:id/worklog', component: DummyComponent },
{ path: 'project/:id/tasks', component: DummyComponent },
{ path: 'planner', component: DummyComponent },
{ path: 'boards', component: DummyComponent },
{ path: 'config', component: DummyComponent },
{ path: 'search', component: DummyComponent },
{ path: '**', component: DummyComponent },
]),
{
provide: GlobalConfigService,
useValue: {
misc: () => ({ defaultStartPage: DefaultStartPage.Today }),
appFeatures: () => ({
isPlannerEnabled: true,
isSchedulerEnabled: true,
isBoardsEnabled: true,
}),
},
},
],
});
const store = TestBed.inject(MockStore);
store.overrideSelector(selectIsOverlayShown, false);
store.overrideSelector(selectAllProjects, []);
router = TestBed.inject(Router);
service = TestBed.inject(AndroidBackButtonService);
minimizeApp = spyOn(
service as unknown as { _minimizeApp: () => void },
'_minimizeApp',
);
historyBack = spyOn(
service as unknown as { _historyBack: () => void },
'_historyBack',
);
});
it('collapses repeated tab switches: back pops to the start destination', fakeAsync(() => {
router.navigateByUrl(TODAY_URL);
tick();
router.navigateByUrl('/planner');
tick();
router.navigateByUrl(TODAY_URL);
tick();
router.navigateByUrl('/planner');
tick();
expect(router.url).toBe('/planner');
service.handleBackButton();
tick();
expect(router.url).toBe(TODAY_URL);
expect(minimizeApp).not.toHaveBeenCalled();
expect(historyBack).not.toHaveBeenCalled();
}));
it('exits the app when back is pressed on the start destination', fakeAsync(() => {
router.navigateByUrl(TODAY_URL);
tick();
service.handleBackButton();
tick();
expect(minimizeApp).toHaveBeenCalled();
expect(router.url).toBe(TODAY_URL);
}));
it('pops a project task list (top-level) to the start destination', fakeAsync(() => {
router.navigateByUrl('/project/p1/tasks');
tick();
expect(router.url).toBe('/project/p1/tasks');
service.handleBackButton();
tick();
expect(router.url).toBe(TODAY_URL);
}));
it('navigates up via history on a context sub-page', fakeAsync(() => {
router.navigateByUrl('/project/p1/worklog');
tick();
expect(router.url).toBe('/project/p1/worklog');
service.handleBackButton();
tick();
expect(historyBack).toHaveBeenCalled();
expect(minimizeApp).not.toHaveBeenCalled();
// history.back() is stubbed, so the route stays put
expect(router.url).toBe('/project/p1/worklog');
}));
it('navigates up via history on a utility page (config)', fakeAsync(() => {
router.navigateByUrl('/config');
tick();
service.handleBackButton();
tick();
expect(historyBack).toHaveBeenCalled();
expect(minimizeApp).not.toHaveBeenCalled();
}));
});

View file

@ -0,0 +1,345 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { AndroidBackButtonService } from './android-back-button.service';
import { GlobalConfigService } from '../config/global-config.service';
import { DefaultStartPage } from '../config/default-start-page.const';
import { HISTORY_STATE } from '../../app.constants';
import { TODAY_TAG } from '../tag/tag.const';
import { INBOX_PROJECT } from '../project/project.const';
import { Project } from '../project/project.model';
import { selectAllProjects } from '../project/store/project.selectors';
import { AppFeaturesConfig } from '../config/global-config.model';
import { getStartPageUrlPath } from '../config/default-start-page.util';
const TODAY_URL = `/tag/${TODAY_TAG.id}/tasks`;
const project = (over: Partial<Project>): Project =>
({ id: 'p1', isArchived: false, isHiddenFromMenu: false, ...over }) as Project;
describe('AndroidBackButtonService (#7972)', () => {
let service: AndroidBackButtonService;
let store: MockStore;
let routerUrl: string;
let navigateByUrl: jasmine.Spy;
let historyBack: jasmine.Spy;
let minimizeApp: jasmine.Spy;
let misc: { defaultStartPage?: number | string } | undefined;
let appFeatures: Record<string, boolean>;
const setProjects = (projects: Project[]): void => {
store.overrideSelector(selectAllProjects, projects);
store.refreshState();
};
beforeEach(() => {
routerUrl = TODAY_URL;
navigateByUrl = jasmine.createSpy('navigateByUrl');
misc = { defaultStartPage: DefaultStartPage.Today };
appFeatures = {
isPlannerEnabled: true,
isSchedulerEnabled: true,
isBoardsEnabled: true,
};
TestBed.configureTestingModule({
providers: [
AndroidBackButtonService,
provideMockStore(),
{
provide: Router,
useValue: {
get url(): string {
return routerUrl;
},
navigateByUrl,
},
},
{
provide: GlobalConfigService,
useValue: {
misc: () => misc,
appFeatures: () => appFeatures,
},
},
],
});
store = TestBed.inject(MockStore);
store.overrideSelector(selectAllProjects, []);
service = TestBed.inject(AndroidBackButtonService);
// Prevent real side effects on globals.
historyBack = spyOn(
service as unknown as { _historyBack: () => void },
'_historyBack',
);
minimizeApp = spyOn(
service as unknown as { _minimizeApp: () => void },
'_minimizeApp',
);
// Default: no overlays open.
spyOn(
service as unknown as { _isFocusOverlayShown: () => boolean },
'_isFocusOverlayShown',
).and.returnValue(false);
spyOn(
service as unknown as { _isHistoryOverlayOpen: () => boolean },
'_isHistoryOverlayOpen',
).and.returnValue(false);
});
describe('overlays', () => {
it('closes a history-state overlay via window.history.back()', () => {
(
service as unknown as { _isHistoryOverlayOpen: jasmine.Spy }
)._isHistoryOverlayOpen.and.returnValue(true);
service.handleBackButton();
expect(historyBack).toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
expect(minimizeApp).not.toHaveBeenCalled();
});
it('defers to window.history.back() when the focus-mode overlay is shown', () => {
(
service as unknown as { _isFocusOverlayShown: jasmine.Spy }
)._isFocusOverlayShown.and.returnValue(true);
service.handleBackButton();
expect(historyBack).toHaveBeenCalled();
expect(minimizeApp).not.toHaveBeenCalled();
});
});
describe('non-top-level pages navigate up normally', () => {
[
'/project/abc/worklog',
`/tag/${TODAY_TAG.id}/metrics`,
'/tag/x/quick-history',
'/config',
'/search',
'/scheduled-list',
'/donate',
].forEach((url) => {
it(`uses window.history.back() on ${url}`, () => {
routerUrl = url;
service.handleBackButton();
expect(historyBack).toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
expect(minimizeApp).not.toHaveBeenCalled();
});
});
});
describe('top-level destinations', () => {
it('pops to the start destination when not already there', () => {
routerUrl = '/planner';
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(TODAY_URL, { replaceUrl: true });
expect(minimizeApp).not.toHaveBeenCalled();
expect(historyBack).not.toHaveBeenCalled();
});
it('exits the app when already on the start destination', () => {
routerUrl = TODAY_URL;
service.handleBackButton();
expect(minimizeApp).toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
it('exits even when a project task list is the top-level destination and start', () => {
misc = { defaultStartPage: 'my-project' };
setProjects([project({ id: 'my-project' })]);
routerUrl = '/project/my-project/tasks';
service.handleBackButton();
expect(minimizeApp).toHaveBeenCalled();
});
it('pops a project task list to the start destination', () => {
routerUrl = '/project/some-project/tasks';
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(TODAY_URL, { replaceUrl: true });
});
it('ignores query/fragment when comparing to the start destination', () => {
routerUrl = `${TODAY_URL}?foo=1#bar`;
service.handleBackButton();
expect(minimizeApp).toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
it('treats a deeper path under a task list as non-top-level (navigates up)', () => {
routerUrl = `${TODAY_URL}/123`;
service.handleBackButton();
expect(historyBack).toHaveBeenCalled();
expect(minimizeApp).not.toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
});
// Full enum/project-validity matrix lives in default-start-page.util.spec.ts;
// here we verify the service wires misc + appFeatures + the looked-up project
// into that helper, including the store-based project lookup.
describe('start-page resolution wiring', () => {
it('resolves the Planner start page', () => {
misc = { defaultStartPage: DefaultStartPage.Planner };
routerUrl = TODAY_URL;
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith('/planner', { replaceUrl: true });
});
it('falls back to Today when the Planner feature is disabled', () => {
misc = { defaultStartPage: DefaultStartPage.Planner };
appFeatures.isPlannerEnabled = false;
routerUrl = '/boards';
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(TODAY_URL, { replaceUrl: true });
});
it('resolves the legacy Inbox start page to the inbox project', () => {
misc = { defaultStartPage: DefaultStartPage.Inbox };
routerUrl = TODAY_URL;
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(`/project/${INBOX_PROJECT.id}/tasks`, {
replaceUrl: true,
});
});
it('resolves a valid project-id start page via the store', () => {
misc = { defaultStartPage: 'proj-123' };
setProjects([project({ id: 'proj-123' })]);
routerUrl = TODAY_URL;
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith('/project/proj-123/tasks', {
replaceUrl: true,
});
});
it('falls back to Today when the project start page is archived', () => {
misc = { defaultStartPage: 'proj-123' };
setProjects([project({ id: 'proj-123', isArchived: true })]);
routerUrl = '/planner';
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(TODAY_URL, { replaceUrl: true });
});
it('falls back to Today when the project start page is missing', () => {
misc = { defaultStartPage: 'gone' };
setProjects([]);
routerUrl = '/planner';
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(TODAY_URL, { replaceUrl: true });
});
it('falls back to Today when misc config is undefined', () => {
misc = undefined;
routerUrl = '/planner';
service.handleBackButton();
expect(navigateByUrl).toHaveBeenCalledWith(TODAY_URL, { replaceUrl: true });
});
});
// Guards the _isTopLevelDestination ⇄ getStartPageUrlPath invariant: being ON
// the resolved start destination must exit, never navigate history. Iterating
// the enum auto-covers any future DefaultStartPage value.
describe('start destination is always a top-level destination (back exits)', () => {
const allBuiltInStartPages = Object.values(DefaultStartPage).filter(
(v): v is DefaultStartPage => typeof v === 'number',
);
allBuiltInStartPages.forEach((startPage) => {
it(`exits when on the resolved start page (DefaultStartPage=${startPage})`, () => {
misc = { defaultStartPage: startPage };
routerUrl = getStartPageUrlPath(
startPage,
appFeatures as unknown as AppFeaturesConfig,
undefined,
);
service.handleBackButton();
expect(minimizeApp).toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
});
it('exits when on a valid project start page', () => {
misc = { defaultStartPage: 'proj-x' };
setProjects([project({ id: 'proj-x' })]);
routerUrl = '/project/proj-x/tasks';
service.handleBackButton();
expect(minimizeApp).toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
});
describe('_isHistoryOverlayOpen (real implementation)', () => {
it('detects an overlay history state', () => {
(
service as unknown as { _isHistoryOverlayOpen: jasmine.Spy }
)._isHistoryOverlayOpen.and.callThrough();
const original = window.history.state;
try {
window.history.replaceState({ [HISTORY_STATE.NOTES]: true }, '');
expect(
(
service as unknown as { _isHistoryOverlayOpen: () => boolean }
)._isHistoryOverlayOpen(),
).toBe(true);
} finally {
window.history.replaceState(original, '');
}
});
it('returns false when no overlay state is present', () => {
(
service as unknown as { _isHistoryOverlayOpen: jasmine.Spy }
)._isHistoryOverlayOpen.and.callThrough();
const original = window.history.state;
try {
window.history.replaceState(null, '');
expect(
(
service as unknown as { _isHistoryOverlayOpen: () => boolean }
)._isHistoryOverlayOpen(),
).toBe(false);
} finally {
window.history.replaceState(original, '');
}
});
});
});

View file

@ -0,0 +1,124 @@
import { inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { App as CapacitorApp } from '@capacitor/app';
import { HISTORY_STATE } from '../../app.constants';
import { GlobalConfigService } from '../config/global-config.service';
import { getStartPageUrlPath } from '../config/default-start-page.util';
import { selectIsOverlayShown } from '../focus-mode/store/focus-mode.selectors';
import { selectAllProjects } from '../project/store/project.selectors';
/**
* Implements Android back-button behavior for top-level (bottom-nav) destinations
* per https://developer.android.com/guide/navigation/backstack:
* back from a top-level destination returns to the start destination, and back
* from the start destination exits the app.
*
* Without this, the back button replays the SPA history stack every tab switch
* pushes a `window.history` entry, so back walks through every previously visited
* tab instead of exiting (issue #7972).
*
* Overlays (side-nav, task-detail, notes, fullscreen-markdown, focus-mode) and
* non-top-level pages (context sub-pages, settings, search, ) keep their
* existing `window.history.back()` behavior so back still closes overlays and
* navigates up.
*/
@Injectable({ providedIn: 'root' })
export class AndroidBackButtonService {
private readonly _router = inject(Router);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
private readonly _isFocusOverlayShown = this._store.selectSignal(selectIsOverlayShown);
private readonly _allProjects = this._store.selectSignal(selectAllProjects);
handleBackButton(): void {
// 1. An overlay that pushed a history state is open → let its popstate
// listener close it. Also covers the focus-mode overlay, which is
// store-based and whose route navigation is blocked by FocusOverlayOpenGuard.
if (this._isHistoryOverlayOpen() || this._isFocusOverlayShown()) {
this._historyBack();
return;
}
// 2. Not a top-level destination (context sub-page, settings, search, …)
// → navigate up via the history stack as before.
const currentUrl = this._router.url;
if (!this._isTopLevelDestination(currentUrl)) {
this._historyBack();
return;
}
// 3. A top-level destination → pop to the start destination, or exit if
// already there.
const startUrl = this._getStartPageUrl();
if (this._isSamePath(currentUrl, startUrl)) {
this._minimizeApp();
} else {
this._router.navigateByUrl(startUrl, { replaceUrl: true });
}
}
private _isHistoryOverlayOpen(): boolean {
const state = window.history.state;
return !!state && Object.values(HISTORY_STATE).some((key) => state[key]);
}
/**
* Primary navigation destinations (bottom-nav tabs + main feature pages).
* Context sub-pages (worklog, metrics, ) and utility pages (settings, search,
* scheduled-list, donate, ) are intentionally excluded so back navigates up
* from them rather than jumping home.
*
* INVARIANT: this must classify every URL `getStartPageUrlPath()` can return
* as top-level, otherwise back from the configured start destination would
* navigate history instead of exiting. Enforced by the "start destination is
* a top-level destination" spec keep this list in sync when adding a start
* page route.
*/
private _isTopLevelDestination(url: string): boolean {
const path = this._pathOf(url);
return (
/^\/(?:tag|project)\/[^/]+\/tasks$/.test(path) ||
['/planner', '/schedule', '/boards', '/habits'].includes(path)
);
}
/**
* Resolve the configured start destination to a URL. Delegates to the same
* helper as DefaultStartPageGuard so the boot redirect and back navigation
* stay in lockstep; a missing/archived/hidden project start page falls back
* to Today.
*/
private _getStartPageUrl(): string {
const startPage = this._globalConfigService.misc()?.defaultStartPage;
const startProject =
typeof startPage === 'string'
? this._allProjects().find((project) => project.id === startPage)
: undefined;
return getStartPageUrlPath(
startPage,
this._globalConfigService.appFeatures(),
startProject,
);
}
private _isSamePath(a: string, b: string): boolean {
return this._pathOf(a) === this._pathOf(b);
}
private _pathOf(url: string): string {
return url.split(/[?#]/)[0];
}
// Thin wrappers around side-effecting globals so they can be spied in tests
// (spying Capacitor plugin methods directly is a no-op due to their proxy).
private _historyBack(): void {
window.history.back();
}
private _minimizeApp(): void {
void CapacitorApp.minimizeApp();
}
}

View file

@ -20,6 +20,7 @@ import {
} from './app/core/locale.constants';
import { IS_ANDROID_WEB_VIEW } from './app/util/is-android-web-view';
import { androidInterface } from './app/features/android/android-interface';
import { AndroidBackButtonService } from './app/features/android/android-back-button.service';
import { IS_IOS_NATIVE, IS_NATIVE_PLATFORM } from './app/util/is-native-platform';
import { DataInitStateService } from './app/core/data-init/data-init-state.service';
// Type definitions for window.ea are in ./app/core/window-ea.d.ts
@ -449,7 +450,13 @@ if (!(environment.production || environment.stage) && IS_ANDROID_WEB_VIEW) {
// Android-specific: Handle back button
if (IS_ANDROID_WEB_VIEW) {
CapacitorApp.addListener('backButton', ({ canGoBack }) => {
if (!canGoBack) {
// Delegate to the Angular service so back from a top-level destination pops
// to the start destination / exits per Android guidelines (issue #7972).
const backButtonService = appInjector?.get(AndroidBackButtonService);
if (backButtonService) {
backButtonService.handleBackButton();
} else if (!canGoBack) {
// Pre-bootstrap fallback (back pressed before Angular is ready).
CapacitorApp.minimizeApp();
} else {
window.history.back();