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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/core/application/application-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,9 @@ export class ApplicationCommon {
return;
}
this._systemAppearance = value;
this.systemAppearanceChanged(this.getRootView(), value);
if (this.autoSystemAppearanceChanged) {
this.systemAppearanceChanged(this.getRootView(), value);
}
this.notify(<SystemAppearanceChangedEventData>{
eventName: this.systemAppearanceChangedEvent,
android: this.android,
Expand Down Expand Up @@ -1060,7 +1062,7 @@ export class ApplicationCommon {
* @param newSystemAppearance the new appearance change
*/
systemAppearanceChanged(rootView: View, newSystemAppearance: 'dark' | 'light'): void {
if (!rootView || !this.autoSystemAppearanceChanged) {
if (!rootView) {
return;
}

Expand Down
16 changes: 7 additions & 9 deletions packages/core/application/application.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,9 +850,11 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp
}

getSystemAppearance(): 'light' | 'dark' {
const resources = this.context.getResources();
const configuration = resources.getConfiguration();
return this.getSystemAppearanceValue(configuration);
return this.getSystemAppearanceValue(this.getConfiguration());
}

private getConfiguration(): android.content.res.Configuration {
return (this.context ?? this.getNativeApplication()).getResources().getConfiguration();
}

// https://developer.android.com/guide/topics/ui/look-and-feel/darktheme#configuration_changes
Expand All @@ -869,9 +871,7 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp
}

getLayoutDirection(): CoreTypes.LayoutDirectionType {
const resources = this.context.getResources();
const configuration = resources.getConfiguration();
return this.getLayoutDirectionValue(configuration);
return this.getLayoutDirectionValue(this.getConfiguration());
}

private getLayoutDirectionValue(configuration: android.content.res.Configuration): CoreTypes.LayoutDirectionType {
Expand All @@ -886,9 +886,7 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp
}

getOrientation() {
const resources = this.context.getResources();
const configuration = <android.content.res.Configuration>resources.getConfiguration();
return this.getOrientationValue(configuration);
return this.getOrientationValue(this.getConfiguration());
}

private getOrientationValue(configuration: android.content.res.Configuration): 'portrait' | 'landscape' | 'unknown' {
Expand Down
139 changes: 139 additions & 0 deletions packages/core/application/system-appearance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

vi.hoisted(() => {
(globalThis as any).NativeScriptGlobals.addEventWiring = () => {};
});

import { CoreTypes } from '../core-types';
import { Observable } from '../data/observable';
import type { View } from '../ui/core/view';
import { ApplicationCommon } from './application-common';
import { AndroidApplication } from './application.android';
import { setNativeApp } from './helpers-common';

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;
}

function createRootView() {
return {
cssClasses: new Set<string>(),
_onCssStateChange: vi.fn(),
_getRootModalViews: () => [],
};
}

class TestApplication extends ApplicationCommon {
rootView = createRootView();

getRootView(): View {
return this.rootView as unknown as View;
}

getOrientation(): 'portrait' | 'landscape' | 'unknown' {
return 'portrait';
}

getSystemAppearance(): 'dark' | 'light' | null {
return 'light';
}

getLayoutDirection(): CoreTypes.LayoutDirectionType | null {
return CoreTypes.LayoutDirection.ltr;
}

changeSystemAppearance(value: 'dark' | 'light') {
this.setSystemAppearance(value);
}
}

describe('system appearance', () => {
let app: TestApplication;

beforeEach(() => {
installApplicationEventBus();
app = new TestApplication();
});

afterEach(() => {
app.setAutoSystemAppearanceChanged(true);
installApplicationEventBus();
});

it('applies the appearance an app sets while automatic changes are off', () => {
app.setAutoSystemAppearanceChanged(false);

app.systemAppearanceChanged(app.getRootView(), 'dark');

expect(app.rootView.cssClasses.has('ns-dark')).toBe(true);
expect(app.rootView._onCssStateChange).toHaveBeenCalled();
});

it('leaves the classes alone when the system changes while automatic changes are off', () => {
const changes: string[] = [];
app.on(app.systemAppearanceChangedEvent, (args: any) => changes.push(args.newValue));
app.setAutoSystemAppearanceChanged(false);

app.changeSystemAppearance('dark');

expect(app.rootView.cssClasses.has('ns-dark')).toBe(false);
expect(changes).toEqual(['dark']);
});

it('applies system changes while automatic changes are on', () => {
app.changeSystemAppearance('dark');

expect(app.rootView.cssClasses.has('ns-dark')).toBe(true);
});
});

describe('Android application traits before the app has started', () => {
const UI_MODE_NIGHT_YES = 0x20;
let globals: Record<string, any>;

beforeEach(() => {
installApplicationEventBus();
globals = { android: (global as any).android };
(global as any).android = {
...globals.android,
content: {
...globals.android?.content,
res: {
Configuration: {
UI_MODE_NIGHT_MASK: 0x30,
UI_MODE_NIGHT_UNDEFINED: 0x00,
UI_MODE_NIGHT_NO: 0x10,
UI_MODE_NIGHT_YES,
ORIENTATION_PORTRAIT: 1,
ORIENTATION_LANDSCAPE: 2,
},
},
},
};
const configuration = { uiMode: 0x01 | UI_MODE_NIGHT_YES, orientation: 2 };
setNativeApp({ getResources: () => ({ getConfiguration: () => configuration }) } as any);
});

afterEach(() => {
(global as any).android = globals.android;
setNativeApp(undefined);
installApplicationEventBus();
});

it('reads them from the native application', () => {
const app = new AndroidApplication();

expect(app.context).toBeUndefined();
expect(app.systemAppearance()).toBe('dark');
expect(app.orientation()).toBe('landscape');
});
});
69 changes: 69 additions & 0 deletions packages/core/utils/edge-to-edge.android.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

import { Color } from '../color';
import { setDarkModeHandler, setNavigationBarColor, setStatusBarColor, refreshEdgeToEdge } from './native-helper-for-android';

describe('Android edge-to-edge setters', () => {
let globals: Record<string, any>;
let enableEdgeToEdge: ReturnType<typeof vi.fn>;

class HandleDarkMode {
constructor(readonly implementation: { onHandle(bar: number, resources: any): boolean }) {}
}

beforeEach(() => {
globals = { android: (global as any).android, org: (global as any).org, java: (global as any).java };
enableEdgeToEdge = vi.fn();
(global as any).android = { ...globals.android, view: { ...globals.android?.view, Window: class Window {} } };
(global as any).org = { nativescript: { widgets: { Utils: { enableEdgeToEdge, HandleDarkMode } } } };
(global as any).java = { lang: { Integer: { valueOf: (value: number) => value } } };
});

afterEach(() => {
Object.assign(global, globals);
});

function lastHandler(): (bar: number) => boolean {
const args = enableEdgeToEdge.mock.calls.at(-1);
const handler = args.at(-1);
return handler instanceof HandleDarkMode ? (bar) => handler.implementation.onHandle(bar, null) : null;
}

function lastColors(): number[] {
return enableEdgeToEdge.mock.calls.at(-1).slice(1, 5);
}

it('uses the latest dark mode handler', () => {
const activity = {} as any;

setDarkModeHandler({ activity, handler: () => false });
setDarkModeHandler({ activity, handler: () => true });
expect(lastHandler()(0)).toBe(true);

refreshEdgeToEdge(activity);
expect(lastHandler()(0)).toBe(true);
});

it('keeps the settings of the activity it was given', () => {
const activity = {} as any;

setStatusBarColor({ activity, lightColor: new Color('#ff0000') });
setNavigationBarColor({ activity, darkColor: new Color('#0000ff') });
refreshEdgeToEdge(activity);

const [statusBarLight, , , navigationBarDark] = lastColors();
expect(statusBarLight).toBe(new Color('#ff0000').android);
expect(navigationBarDark).toBe(new Color('#0000ff').android);
});

it('replaces colors set before and keeps the ones not given', () => {
const activity = {} as any;

setStatusBarColor({ activity, lightColor: new Color('#ff0000'), darkColor: new Color('#00ff00') });
setStatusBarColor({ activity, lightColor: new Color('#0000ff') });

const [statusBarLight, statusBarDark] = lastColors();
expect(statusBarLight).toBe(new Color('#0000ff').android);
expect(statusBarDark).toBe(new Color('#00ff00').android);
});
});
16 changes: 8 additions & 8 deletions packages/core/utils/native-helper-for-android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,9 @@ export function setStatusBarColor(options?: { activity?: androidx.appcompat.app.
statusBarLight: DefaultStatusBarLight,
statusBarDark: DefaultStatusBarDark,
};
existingColors.statusBarLight ??= statusBarLightColor;
existingColors.statusBarDark ??= statusBarDarkColor;
systemColors.set(getCurrentActivity(), existingColors);
existingColors.statusBarLight = statusBarLightColor ?? existingColors.statusBarLight;
existingColors.statusBarDark = statusBarDarkColor ?? existingColors.statusBarDark;
systemColors.set(activity, existingColors);

setEnableEdgeToEdge(activity, existingColors);
}
Expand All @@ -363,9 +363,9 @@ export function setNavigationBarColor(options?: { activity?: androidx.appcompat.
statusBarLight: DefaultStatusBarLight,
statusBarDark: DefaultStatusBarDark,
};
existingColors.navigationBarLight ??= navigationBarLightColor;
existingColors.navigationBarDark ??= navigationBarDarkColor;
systemColors.set(getCurrentActivity(), existingColors);
existingColors.navigationBarLight = navigationBarLightColor ?? existingColors.navigationBarLight;
existingColors.navigationBarDark = navigationBarDarkColor ?? existingColors.navigationBarDark;
systemColors.set(activity, existingColors);

setEnableEdgeToEdge(activity, existingColors);
}
Expand All @@ -382,9 +382,9 @@ export function setDarkModeHandler(options?: { activity?: androidx.appcompat.app
statusBarDark: DefaultStatusBarDark,
};

existingColors.handler ??= darkModeHandler;
existingColors.handler = darkModeHandler;

systemColors.set(getCurrentActivity(), existingColors);
systemColors.set(activity, existingColors);

setEnableEdgeToEdge(activity, existingColors);
}
Expand Down
Loading