mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
145ed02774
commit
89d5fd39ba
4 changed files with 618 additions and 1 deletions
|
|
@ -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();
|
||||
}));
|
||||
});
|
||||
345
src/app/features/android/android-back-button.service.spec.ts
Normal file
345
src/app/features/android/android-back-button.service.spec.ts
Normal 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, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
124
src/app/features/android/android-back-button.service.ts
Normal file
124
src/app/features/android/android-back-button.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue