| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,139 @@ | |||
| 1 | + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | ||
| 2 | + | ||
| 3 | + // `application.android.ts` registers deferred event wiring while it loads, through a hook the | ||
| 4 | + // `NativeScriptGlobals` stub in `vitest.setup.ts` does not carry. Hoisted so it lands before | ||
| 5 | + // the import below. | ||
| 6 | + vi.hoisted(() => { | ||
| 7 | + (globalThis as any).NativeScriptGlobals.addEventWiring = () => {}; | ||
| 8 | + }); | ||
| 9 | + | ||
| 10 | + import { Observable } from '../data/observable'; | ||
| 11 | + import { AndroidApplication } from './application.android'; | ||
| 12 | + import type { NativeWindow } from '../native-window/native-window-common'; | ||
| 13 | + | ||
| 14 | + /** | ||
| 15 | + * `vitest.setup.ts` installs a `NativeScriptGlobals` whose event bus methods are no-ops, and | ||
| 16 | + * `ApplicationCommon` binds them per instance. Swapping in a real Observable before each | ||
| 17 | + * Application is constructed is what makes its events observable at all. | ||
| 18 | + */ | ||
| 19 | + function installApplicationEventBus(): Observable { | ||
| 20 | + const events = new Observable(); | ||
| 21 | + const bus = (global.NativeScriptGlobals as any).events; | ||
| 22 | + | ||
| 23 | + bus.on = events.on.bind(events); | ||
| 24 | + bus.once = events.once.bind(events); | ||
| 25 | + bus.off = events.off.bind(events); | ||
| 26 | + bus.notify = events.notify.bind(events); | ||
| 27 | + bus.hasListeners = events.hasListeners.bind(events); | ||
| 28 | + | ||
| 29 | + return events; | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + /** Nothing about the window itself reaches the aggregate — only its identity. */ | ||
| 33 | + function createWindow(): NativeWindow { | ||
| 34 | + return {} as unknown as NativeWindow; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + describe('activity lifecycle aggregate', () => { | ||
| 38 | + let app: AndroidApplication; | ||
| 39 | + let order: string[]; | ||
| 40 | + | ||
| 41 | + beforeEach(() => { | ||
| 42 | + installApplicationEventBus(); | ||
| 43 | + app = new AndroidApplication(); | ||
| 44 | + | ||
| 45 | + order = []; | ||
| 46 | + for (const eventName of ['resume', 'suspend']) { | ||
| 47 | + app.on(eventName, () => order.push(eventName)); | ||
| 48 | + } | ||
| 49 | + }); | ||
| 50 | + | ||
| 51 | + afterEach(() => { | ||
| 52 | + vi.restoreAllMocks(); | ||
| 53 | + installApplicationEventBus(); | ||
| 54 | + }); | ||
| 55 | + | ||
| 56 | + it('raises each app-level event once as a single window moves through the lifecycle', () => { | ||
| 57 | + const window = createWindow(); | ||
| 58 | + | ||
| 59 | + app._setWindowActive(window, true); | ||
| 60 | + expect(order).toEqual(['resume']); | ||
| 61 | + expect(app.suspended).toBe(false); | ||
| 62 | + | ||
| 63 | + app._setWindowActive(window, false); | ||
| 64 | + expect(order).toEqual(['resume', 'suspend']); | ||
| 65 | + expect(app.suspended).toBe(true); | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + it('raises only on the first window in and the last window out', () => { | ||
| 69 | + const first = createWindow(); | ||
| 70 | + const second = createWindow(); | ||
| 71 | + | ||
| 72 | + app._setWindowActive(first, true); | ||
| 73 | + app._setWindowActive(second, true); | ||
| 74 | + expect(order).toEqual(['resume']); | ||
| 75 | + | ||
| 76 | + // The window the user switched away from resigns while the other is still on screen. | ||
| 77 | + app._setWindowActive(first, false); | ||
| 78 | + expect(order).toEqual(['resume']); | ||
| 79 | + expect(app.suspended).toBe(false); | ||
| 80 | + | ||
| 81 | + app._setWindowActive(second, false); | ||
| 82 | + expect(order).toEqual(['resume', 'suspend']); | ||
| 83 | + expect(app.suspended).toBe(true); | ||
| 84 | + }); | ||
| 85 | + | ||
| 86 | + it('ignores repeated calls for the same window', () => { | ||
| 87 | + const window = createWindow(); | ||
| 88 | + | ||
| 89 | + app._setWindowActive(window, true); | ||
| 90 | + app._setWindowActive(window, true); | ||
| 91 | + expect(order).toEqual(['resume']); | ||
| 92 | + | ||
| 93 | + app._setWindowActive(window, false); | ||
| 94 | + app._setWindowActive(window, false); | ||
| 95 | + expect(order).toEqual(['resume', 'suspend']); | ||
| 96 | + }); | ||
| 97 | + | ||
| 98 | + it('ignores a window resigning that never became active', () => { | ||
| 99 | + const window = createWindow(); | ||
| 100 | + const other = createWindow(); | ||
| 101 | + | ||
| 102 | + app._setWindowActive(window, false); | ||
| 103 | + expect(order).toEqual([]); | ||
| 104 | + | ||
| 105 | + app._setWindowActive(window, true); | ||
| 106 | + app._setWindowActive(other, false); | ||
| 107 | + expect(order).toEqual(['resume']); | ||
| 108 | + }); | ||
| 109 | + | ||
| 110 | + it('lets an activity with no registered window speak for the app while no window does', () => { | ||
| 111 | + app._setWindowActive(undefined, true); | ||
| 112 | + expect(order).toEqual(['resume']); | ||
| 113 | + | ||
| 114 | + app._setWindowActive(undefined, false); | ||
| 115 | + expect(order).toEqual(['resume', 'suspend']); | ||
| 116 | + }); | ||
| 117 | + | ||
| 118 | + it('does not let an activity with no registered window suspend an app another window holds active', () => { | ||
| 119 | + const window = createWindow(); | ||
| 120 | + | ||
| 121 | + app._setWindowActive(window, true); | ||
| 122 | + app._setWindowActive(undefined, false); | ||
| 123 | + | ||
| 124 | + expect(order).toEqual(['resume']); | ||
| 125 | + expect(app.suspended).toBe(false); | ||
| 126 | + }); | ||
| 127 | + | ||
| 128 | + it('carries the activity on the app-level event', () => { | ||
| 129 | + const activity = {} as androidx.appcompat.app.AppCompatActivity; | ||
| 130 | + const received: any[] = []; | ||
| 131 | + app.on('resume', (args) => received.push(args)); | ||
| 132 | + | ||
| 133 | + app._setWindowActive(createWindow(), true, activity); | ||
| 134 | + | ||
| 135 | + expect(received).toHaveLength(1); | ||
| 136 | + expect(received[0].activity).toBe(activity); | ||
| 137 | + expect(received[0].android).toBe(activity); | ||
| 138 | + }); | ||
| 139 | + }); | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -148,6 +148,11 @@ function initNativeScriptLifecycleCallbacks() { | |||
| 148 | 148 | ||
| 149 | 149 | const nativeWindow = Application.android._getWindowForActivity(activity); | |
| 150 | 150 | if (nativeWindow) { | |
| 151 | + // Android always pauses before destroying, so the window has normally left the | ||
| 152 | + // active aggregate already; this only keeps a dropped pause from stranding it | ||
| 153 | + // there and wedging the app as never-suspended. | ||
| 154 | + Application.android._setWindowActive(nativeWindow, false, activity); | ||
| 155 | + | ||
| 151 | 156 | // A destroyed activity only ends the window session when it is finishing — | |
| 152 | 157 | // otherwise Android is recreating it and the same window is reused. | |
| 153 | 158 | const isClosing = activity.isFinishing(); | |
@@ -195,15 +200,14 @@ function initNativeScriptLifecycleCallbacks() { | |||
| 195 | 200 | @profile | |
| 196 | 201 | public onActivityPaused(activity: androidx.appcompat.app.AppCompatActivity): void { | |
| 197 | 202 | // console.log('NativeScriptLifecycleCallbacks onActivityPaused'); | |
| 203 | + const nativeWindow = Application.android._getWindowForActivity(activity); | ||
| 204 | + | ||
| 205 | + // `onActivityCreated` registers a window for every activity in the process, so the | ||
| 206 | + // window's role cannot tell a NativeScript activity from a third-party one. | ||
| 198 | 207 | if ('isNativeScriptActivity' in activity) { | |
| 199 | - Application.setSuspended(true, { | ||
| 200 | - // todo: deprecate event.android in favor of event.activity | ||
| 201 | - android: activity, | ||
| 202 | - activity, | ||
| 203 | - }); | ||
| 208 | + Application.android._setWindowActive(nativeWindow, false, activity); | ||
| 204 | 209 | } | |
| 205 | 210 | ||
| 206 | - const nativeWindow = Application.android._getWindowForActivity(activity); | ||
| 207 | 211 | if (nativeWindow) { | |
| 208 | 212 | nativeWindow._notifyEvent(NativeWindowEvents.deactivate); | |
| 209 | 213 | // Emit activityPaused on NativeWindow | |
@@ -518,6 +522,11 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp | |||
| 518 | 522 | private lifecycleCallbacks: NativeScriptLifecycleCallbacks; | |
| 519 | 523 | private componentCallbacks: NativeScriptComponentCallbacks; | |
| 520 | 524 | ||
| 525 | + // Windows currently active. Membership, not a count, so a repeated or missing lifecycle | ||
| 526 | + // callback cannot drift the aggregate. The registry owns window lifetime, so nothing here | ||
| 527 | + // outlives the window itself. | ||
| 528 | + private _activeWindows = new Set<NativeWindow>(); | ||
| 529 | + | ||
| 521 | 530 | init(nativeApp: android.app.Application): void { | |
| 522 | 531 | if (this.nativeApp === nativeApp) { | |
| 523 | 532 | return; | |
@@ -709,6 +718,44 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp | |||
| 709 | 718 | return this._windows.find((nw) => nw.android?.activity === activity) as AndroidNativeWindow | undefined; | |
| 710 | 719 | } | |
| 711 | 720 | ||
| 721 | + /** | ||
| 722 | + * @internal - Feeds a window's active state into the application-level 'resume'/'suspend' | ||
| 723 | + * events, which describe the app as a whole: they are raised when the first window becomes | ||
| 724 | + * active and when the last active one resigns. Callers decide which activities may speak | ||
| 725 | + * for the app; every window reaching here participates. | ||
| 726 | + */ | ||
| 727 | + _setWindowActive(nativeWindow: NativeWindow | undefined, active: boolean, activity?: androidx.appcompat.app.AppCompatActivity): void { | ||
| 728 | + if (this._trackWindowActive(nativeWindow, active)) { | ||
| 729 | + this.setSuspended(!active, { | ||
| 730 | + // todo: deprecate event.android in favor of event.activity | ||
| 731 | + android: activity, | ||
| 732 | + activity, | ||
| 733 | + }); | ||
| 734 | + } | ||
| 735 | + } | ||
| 736 | + | ||
| 737 | + /** | ||
| 738 | + * @returns whether the set flipped between empty and non-empty, which is the only point | ||
| 739 | + * at which app-level state changes. | ||
| 740 | + */ | ||
| 741 | + private _trackWindowActive(nativeWindow: NativeWindow | undefined, active: boolean): boolean { | ||
| 742 | + const wasPopulated = this._activeWindows.size > 0; | ||
| 743 | + | ||
| 744 | + // An activity with no registered window cannot join the aggregate, so it only speaks | ||
| 745 | + // for the app while no window holds the state. | ||
| 746 | + if (!nativeWindow) { | ||
| 747 | + return !wasPopulated; | ||
| 748 | + } | ||
| 749 | + | ||
| 750 | + if (active) { | ||
| 751 | + this._activeWindows.add(nativeWindow); | ||
| 752 | + } else { | ||
| 753 | + this._activeWindows.delete(nativeWindow); | ||
| 754 | + } | ||
| 755 | + | ||
| 756 | + return wasPopulated !== this._activeWindows.size > 0; | ||
| 757 | + } | ||
| 758 | + | ||
| 712 | 759 | // --- Multi-window support --- | |
| 713 | 760 | ||
| 714 | 761 | /** | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -155,6 +155,12 @@ export class AndroidApplication extends ApplicationCommon { | |||
| 155 | 155 | */ | |
| 156 | 156 | _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined; | |
| 157 | 157 | ||
| 158 | + /** | ||
| 159 | + * @internal - Feeds a window's active state into the application-level 'resume'/'suspend' | ||
| 160 | + * events, raised when the first window becomes active and when the last active one resigns. | ||
| 161 | + */ | ||
| 162 | + _setWindowActive(nativeWindow: NativeWindow | undefined, active: boolean, activity?: androidx.appcompat.app.AppCompatActivity): void; | ||
| 163 | + | ||
| 158 | 164 | /** | |
| 159 | 165 | * Opens a new window by launching the start activity into its own task. | |
| 160 | 166 | * | |
| Back | FazBrowse Home | New Git URL |
0 commit comments