diff --git a/src/app/features/android/android-back-button.service.integration.spec.ts b/src/app/features/android/android-back-button.service.integration.spec.ts new file mode 100644 index 0000000000..1d8cd052c5 --- /dev/null +++ b/src/app/features/android/android-back-button.service.integration.spec.ts @@ -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(); + })); +}); diff --git a/src/app/features/android/android-back-button.service.spec.ts b/src/app/features/android/android-back-button.service.spec.ts new file mode 100644 index 0000000000..b440d9c1dd --- /dev/null +++ b/src/app/features/android/android-back-button.service.spec.ts @@ -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 => + ({ 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; + + 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, ''); + } + }); + }); +}); diff --git a/src/app/features/android/android-back-button.service.ts b/src/app/features/android/android-back-button.service.ts new file mode 100644 index 0000000000..ecf1ff66b6 --- /dev/null +++ b/src/app/features/android/android-back-button.service.ts @@ -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(); + } +} diff --git a/src/main.ts b/src/main.ts index 9479dfedec..30fac2d856 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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();