FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(core): restore application lifecycle events and window resolution under UIScene by edusperoni · Pull Request #11373 · NativeScript/NativeScript · GitHub

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .ts  (8) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
139 changes: 139 additions & 0 deletions packages/core/application/activity-lifecycle.android.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

// `application.android.ts` registers deferred event wiring while it loads, through a hook the
// `NativeScriptGlobals` stub in `vitest.setup.ts` does not carry. Hoisted so it lands before
// the import below.
vi.hoisted(() => {
(globalThis as any).NativeScriptGlobals.addEventWiring = () => {};
});

import { Observable } from '../data/observable';
import { AndroidApplication } from './application.android';
import type { NativeWindow } from '../native-window/native-window-common';

/**
* `vitest.setup.ts` installs a `NativeScriptGlobals` whose event bus methods are no-ops, and
* `ApplicationCommon` binds them per instance. Swapping in a real Observable before each
* Application is constructed is what makes its events observable at all.
*/
function installApplicationEventBus(): Observable {
const events = new Observable();
const bus = (global.NativeScriptGlobals as any).events;

bus.on = events.on.bind(events);
bus.once = events.once.bind(events);
bus.off = events.off.bind(events);
bus.notify = events.notify.bind(events);
bus.hasListeners = events.hasListeners.bind(events);

return events;
}

/** Nothing about the window itself reaches the aggregate — only its identity. */
function createWindow(): NativeWindow {
return {} as unknown as NativeWindow;
}

describe('activity lifecycle aggregate', () => {
let app: AndroidApplication;
let order: string[];

beforeEach(() => {
installApplicationEventBus();
app = new AndroidApplication();

order = [];
for (const eventName of ['resume', 'suspend']) {
app.on(eventName, () => order.push(eventName));
}
});

afterEach(() => {
vi.restoreAllMocks();
installApplicationEventBus();
});

it('raises each app-level event once as a single window moves through the lifecycle', () => {
const window = createWindow();

app._setWindowActive(window, true);
expect(order).toEqual(['resume']);
expect(app.suspended).toBe(false);

app._setWindowActive(window, false);
expect(order).toEqual(['resume', 'suspend']);
expect(app.suspended).toBe(true);
});

it('raises only on the first window in and the last window out', () => {
const first = createWindow();
const second = createWindow();

app._setWindowActive(first, true);
app._setWindowActive(second, true);
expect(order).toEqual(['resume']);

// The window the user switched away from resigns while the other is still on screen.
app._setWindowActive(first, false);
expect(order).toEqual(['resume']);
expect(app.suspended).toBe(false);

app._setWindowActive(second, false);
expect(order).toEqual(['resume', 'suspend']);
expect(app.suspended).toBe(true);
});

it('ignores repeated calls for the same window', () => {
const window = createWindow();

app._setWindowActive(window, true);
app._setWindowActive(window, true);
expect(order).toEqual(['resume']);

app._setWindowActive(window, false);
app._setWindowActive(window, false);
expect(order).toEqual(['resume', 'suspend']);
});

it('ignores a window resigning that never became active', () => {
const window = createWindow();
const other = createWindow();

app._setWindowActive(window, false);
expect(order).toEqual([]);

app._setWindowActive(window, true);
app._setWindowActive(other, false);
expect(order).toEqual(['resume']);
});

it('lets an activity with no registered window speak for the app while no window does', () => {
app._setWindowActive(undefined, true);
expect(order).toEqual(['resume']);

app._setWindowActive(undefined, false);
expect(order).toEqual(['resume', 'suspend']);
});

it('does not let an activity with no registered window suspend an app another window holds active', () => {
const window = createWindow();

app._setWindowActive(window, true);
app._setWindowActive(undefined, false);

expect(order).toEqual(['resume']);
expect(app.suspended).toBe(false);
});

it('carries the activity on the app-level event', () => {
const activity = {} as androidx.appcompat.app.AppCompatActivity;
const received: any[] = [];
app.on('resume', (args) => received.push(args));

app._setWindowActive(createWindow(), true, activity);

expect(received).toHaveLength(1);
expect(received[0].activity).toBe(activity);
expect(received[0].android).toBe(activity);
});
});
59 changes: 53 additions & 6 deletions packages/core/application/application.android.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ function initNativeScriptLifecycleCallbacks() {

const nativeWindow = Application.android._getWindowForActivity(activity);
if (nativeWindow) {
// Android always pauses before destroying, so the window has normally left the
// active aggregate already; this only keeps a dropped pause from stranding it
// there and wedging the app as never-suspended.
Application.android._setWindowActive(nativeWindow, false, activity);

// A destroyed activity only ends the window session when it is finishing —
// otherwise Android is recreating it and the same window is reused.
const isClosing = activity.isFinishing();
Expand Down Expand Up @@ -195,15 +200,14 @@ function initNativeScriptLifecycleCallbacks() {
@profile
public onActivityPaused(activity: androidx.appcompat.app.AppCompatActivity): void {
// console.log('NativeScriptLifecycleCallbacks onActivityPaused');
const nativeWindow = Application.android._getWindowForActivity(activity);

// `onActivityCreated` registers a window for every activity in the process, so the
// window's role cannot tell a NativeScript activity from a third-party one.
if ('isNativeScriptActivity' in activity) {
Application.setSuspended(true, {
// todo: deprecate event.android in favor of event.activity
android: activity,
activity,
});
Application.android._setWindowActive(nativeWindow, false, activity);
}

const nativeWindow = Application.android._getWindowForActivity(activity);
if (nativeWindow) {
nativeWindow._notifyEvent(NativeWindowEvents.deactivate);
// Emit activityPaused on NativeWindow
Expand Down Expand Up @@ -518,6 +522,11 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp
private lifecycleCallbacks: NativeScriptLifecycleCallbacks;
private componentCallbacks: NativeScriptComponentCallbacks;

// Windows currently active. Membership, not a count, so a repeated or missing lifecycle
// callback cannot drift the aggregate. The registry owns window lifetime, so nothing here
// outlives the window itself.
private _activeWindows = new Set<NativeWindow>();

init(nativeApp: android.app.Application): void {
if (this.nativeApp === nativeApp) {
return;
Expand Down Expand Up @@ -709,6 +718,44 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp
return this._windows.find((nw) => nw.android?.activity === activity) as AndroidNativeWindow | undefined;
}

/**
* @internal - Feeds a window's active state into the application-level 'resume'/'suspend'
* events, which describe the app as a whole: they are raised when the first window becomes
* active and when the last active one resigns. Callers decide which activities may speak
* for the app; every window reaching here participates.
*/
_setWindowActive(nativeWindow: NativeWindow | undefined, active: boolean, activity?: androidx.appcompat.app.AppCompatActivity): void {
if (this._trackWindowActive(nativeWindow, active)) {
this.setSuspended(!active, {
// todo: deprecate event.android in favor of event.activity
android: activity,
activity,
});
}
}

/**
* @returns whether the set flipped between empty and non-empty, which is the only point
* at which app-level state changes.
*/
private _trackWindowActive(nativeWindow: NativeWindow | undefined, active: boolean): boolean {
const wasPopulated = this._activeWindows.size > 0;

// An activity with no registered window cannot join the aggregate, so it only speaks
// for the app while no window holds the state.
if (!nativeWindow) {
return !wasPopulated;
}

if (active) {
this._activeWindows.add(nativeWindow);
} else {
this._activeWindows.delete(nativeWindow);
}

return wasPopulated !== this._activeWindows.size > 0;
}

// --- Multi-window support ---

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/core/application/application.d.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ export class AndroidApplication extends ApplicationCommon {
*/
_getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined;

/**
* @internal - Feeds a window's active state into the application-level 'resume'/'suspend'
* events, raised when the first window becomes active and when the last active one resigns.
*/
_setWindowActive(nativeWindow: NativeWindow | undefined, active: boolean, activity?: androidx.appcompat.app.AppCompatActivity): void;

/**
* Opens a new window by launching the start activity into its own task.
*
Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL