diff --git a/packages/playground/ios/SparklingGoTests/Service/SPKThemePreferenceTests.swift b/packages/playground/ios/SparklingGoTests/Service/SPKThemePreferenceTests.swift new file mode 100644 index 00000000..f92fbca3 --- /dev/null +++ b/packages/playground/ios/SparklingGoTests/Service/SPKThemePreferenceTests.swift @@ -0,0 +1,165 @@ +// Copyright 2025 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import SparklingMethod +import Testing +import UIKit + +@testable import Sparkling + +@Suite(.serialized) +@MainActor +struct SPKThemePreferenceTests { + @Test func defaultsToFollowSystemAndPersistsPreference() { + let userDefaults = self.makeUserDefaults() + let manager = SPKThemePreferenceManager(userDefaults: userDefaults) + + #expect(manager.preference == .followSystem) + + manager.setPreference(.dark) + + #expect(manager.preference == .dark) + #expect(userDefaults.string(forKey: SPKThemePreferenceManager.userDefaultsKey) == SPKThemePreference.dark.rawValue) + } + + @Test func defaultGlobalPropsInjectPersistedPreference() { + self.resetSharedPreference() + defer { self.resetSharedPreference() } + + SPKThemePreferenceManager.shared.setPreference(.light) + + let globalProps = SPKGlobalPropsUtils.defaultGlobalProps() + + #expect(globalProps[SPKThemePreferenceManager.globalPropsKey] as? String == SPKThemePreference.light.rawValue) + } + + @Test func defaultGlobalPropsInjectSystemTheme() { + let globalProps = SPKGlobalPropsUtils.defaultGlobalProps() + + #expect(globalProps["theme"] as? String == SPKGlobalPropsUtils.systemTheme()) + #expect(SPKGlobalPropsUtils.systemTheme(for: .light) == "light") + #expect(SPKGlobalPropsUtils.systemTheme(for: .dark) == "dark") + #expect(SPKGlobalPropsUtils.systemTheme(for: .unspecified) == "light") + } + + @Test func themeMethodIsGloballyAutoDiscovered() { + let methodName = SPKSetThemePreferenceMethod.methodName() + MethodRegistry.global.unregister(methodName: methodName) + defer { MethodRegistry.global.unregister(methodName: methodName) } + + MethodRegistry.autoRegisterGlobalMethods() + + #expect(MethodRegistry.global.respondTo(methodName: methodName)) + #expect(MethodRegistry.global.method(forName: methodName) is SPKSetThemePreferenceMethod) + } + + @Test func broadcastsPreferenceToAllActiveContainers() { + let manager = SPKThemePreferenceManager(userDefaults: self.makeUserDefaults()) + let firstContainer = RecordingWrapperView(containerID: "theme-container-1") + let secondContainer = RecordingWrapperView(containerID: "theme-container-2") + manager.register(firstContainer) + manager.register(secondContainer) + + manager.setPreference(.dark) + + let expected = [SPKThemePreferenceManager.globalPropsKey: SPKThemePreference.dark.rawValue] + #expect(firstContainer.globalPropsUpdates == [expected]) + #expect(secondContainer.globalPropsUpdates == [expected]) + } + + @Test func setThemePreferenceMethodPersistsAndReturnsNormalizedPreference() { + self.resetSharedPreference() + defer { self.resetSharedPreference() } + + let paramModel = SPKSetThemePreferenceParamModel() + paramModel.preference = " DARK " + var status: MethodStatus? + var result: SPKSetThemePreferenceResultModel? + + SPKSetThemePreferenceMethod().call(withParamModel: paramModel) { callbackStatus, callbackResult in + status = callbackStatus + result = callbackResult as? SPKSetThemePreferenceResultModel + } + + #expect(SPKSetThemePreferenceMethod.methodName() == "sparkling.setThemePreference") + #expect(status?.code == .succeeded) + #expect(result?.preference == SPKThemePreference.dark.rawValue) + #expect(SPKThemePreferenceManager.shared.preference == .dark) + } + + @Test func setThemePreferenceMethodRejectsInvalidPreference() { + self.resetSharedPreference() + defer { self.resetSharedPreference() } + + let paramModel = SPKSetThemePreferenceParamModel() + paramModel.preference = "sepia" + var status: MethodStatus? + var result: SPKMethodModel? + + SPKSetThemePreferenceMethod().call(withParamModel: paramModel) { callbackStatus, callbackResult in + status = callbackStatus + result = callbackResult + } + + #expect(status?.code == .invalidInputParameter) + #expect(result == nil) + #expect(SPKThemePreferenceManager.shared.preference == .followSystem) + } + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "SPKThemePreferenceTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName)! + userDefaults.removePersistentDomain(forName: suiteName) + return userDefaults + } + + private func resetSharedPreference() { + UserDefaults.standard.removeObject(forKey: SPKThemePreferenceManager.userDefaultsKey) + } +} + +@MainActor +private final class RecordingWrapperView: UIView, SPKWrapperViewProtocol { + let containerID: String + var context: SPKHybridContext? + var loadState: SPKLoadState = .SPKLoadStateNotLoad + var rawView: UIView? { self } + var params: SPKHybridParams? + weak var lifeCycleDelegate: SPKWrapperViewLifecycleProtocol? + var anyMethodPipe: Any? + var estimatedProgress: Float = 0 + var globalPropsUpdates: [[String: String]] = [] + + init(containerID: String) { + self.containerID = containerID + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func load() {} + + func reload(_ context: SPKHybridContext?) { + self.context = context + } + + func send(event event: String, params: [String: Any]?, callback: ((Any?) -> Void)?) {} + + func config(withParams params: SPKHybridParams?) { + self.params = params + } + + func onshow(params: [AnyHashable: Any]) {} + + func onHide(params: [AnyHashable: Any]) {} + + func update(withGlobalProps globalProps: Any?) { + guard let globalProps = globalProps as? [String: String] else { return } + self.globalPropsUpdates.append(globalProps) + } + + func config(withGlobalProps globalProps: Any?) {} +} diff --git a/packages/playground/src/lib/theme.tsx b/packages/playground/src/lib/theme.tsx index 35e713e3..4fd01476 100644 --- a/packages/playground/src/lib/theme.tsx +++ b/packages/playground/src/lib/theme.tsx @@ -1,6 +1,15 @@ -import { createContext, useContext, useState, useCallback } from '@lynx-js/react' +// Copyright 2025 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. -export type ThemePreference = 'Auto' | 'Light' | 'Dark' +import { createContext, useContext, useState, useCallback, useEffect } from '@lynx-js/react' +import _pipe, { type EventCallback, type PipeResponse } from 'sparkling-method' +import { parseThemePreference, type ThemePreference } from './themePreference.js' + +type SparklingPipe = typeof import('sparkling-method').default +const pipe = _pipe as unknown as SparklingPipe + +export type { ThemePreference } from './themePreference.js' export type ResolvedTheme = 'light' | 'dark' interface ThemeContextValue { @@ -26,23 +35,32 @@ function resolveTheme(preference: ThemePreference): ResolvedTheme { } function getInitialPreference(): ThemePreference { - const gp = (lynx.__globalProps || {}) as Record - // The SDK puts force_theme_style in queryItems (nested), not as top-level preferredTheme. - // Check both locations for robustness. - const raw = gp.preferredTheme - || (gp.queryItems as Record)?.force_theme_style - || 'Auto' - const lower = String(raw).toLowerCase() - if (lower === 'light') return 'Light' - if (lower === 'dark') return 'Dark' - return 'Auto' + return parseThemePreference(lynx.__globalProps) } export function ThemeProvider(props: { children: any }) { const [preference, setPreferenceState] = useState(getInitialPreference) + useEffect(() => { + const handleGlobalPropsUpdated: EventCallback = () => { + setPreferenceState(getInitialPreference()) + } + const listener = pipe.on('globalPropsUpdated', handleGlobalPropsUpdated) + return () => pipe.off('globalPropsUpdated', listener) + }, []) + const setPreference = useCallback((pref: ThemePreference) => { - setPreferenceState(pref) + pipe.call('sparkling.setThemePreference', { + preference: pref === 'Auto' ? 'follow-system' : pref.toLowerCase(), + }, (response: unknown) => { + const result = response as PipeResponse<{ preference?: string }> + if (result.code === 1) { + setPreferenceState(parseThemePreference({ + ...lynx.__globalProps, + preferredTheme: result.data?.preference, + })) + } + }) }, []) const resolved = resolveTheme(preference) diff --git a/packages/playground/src/lib/themePreference.test.ts b/packages/playground/src/lib/themePreference.test.ts new file mode 100644 index 00000000..adaa0f07 --- /dev/null +++ b/packages/playground/src/lib/themePreference.test.ts @@ -0,0 +1,24 @@ +// Copyright 2025 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { describe, expect, it } from 'vitest' +import { parseThemePreference } from './themePreference.js' + +describe('parseThemePreference', () => { + it('prefers force_theme_style over persisted preferredTheme', () => { + expect(parseThemePreference({ + preferredTheme: 'dark', + queryItems: { force_theme_style: 'light' }, + })).toBe('Light') + }) + + it('uses persisted preferredTheme when no force override exists', () => { + expect(parseThemePreference({ preferredTheme: 'dark' })).toBe('Dark') + }) + + it('maps follow-system and missing values to Auto', () => { + expect(parseThemePreference({ preferredTheme: 'follow-system' })).toBe('Auto') + expect(parseThemePreference()).toBe('Auto') + }) +}) diff --git a/packages/playground/src/lib/themePreference.ts b/packages/playground/src/lib/themePreference.ts new file mode 100644 index 00000000..9bb7e25d --- /dev/null +++ b/packages/playground/src/lib/themePreference.ts @@ -0,0 +1,24 @@ +// Copyright 2025 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export type ThemePreference = 'Auto' | 'Light' | 'Dark' + +interface ThemeGlobalProps { + preferredTheme?: unknown + queryItems?: Record | null +} + +function normalizeThemePreference(value: unknown): ThemePreference | undefined { + const normalized = String(value ?? '').toLowerCase() + if (normalized === 'light') return 'Light' + if (normalized === 'dark') return 'Dark' + if (normalized === 'follow-system' || normalized === 'auto') return 'Auto' + return undefined +} + +export function parseThemePreference(globalProps?: ThemeGlobalProps | null): ThemePreference { + return normalizeThemePreference(globalProps?.queryItems?.force_theme_style) + ?? normalizeThemePreference(globalProps?.preferredTheme) + ?? 'Auto' +} diff --git a/packages/playground/src/pages/main/App.tsx b/packages/playground/src/pages/main/App.tsx index 16fb041a..69730e5c 100644 --- a/packages/playground/src/pages/main/App.tsx +++ b/packages/playground/src/pages/main/App.tsx @@ -191,19 +191,12 @@ function HomePage(props: { showPage: boolean; topInset: number }) { const handleItemTap = (bundle: string, title: string) => { 'background only' - // Pass the current resolved theme so sub-pages inherit the user's choice - const dark = resolved === 'dark' navigate({ path: bundle, options: { params: { title, hide_nav_bar: 0, - container_bg_color: dark ? '#000000' : '#f0f2f5', - nav_bar_color: dark ? '#000000' : '#ffffff', - title_color: dark ? '#FFFFFF' : '#000000', - loading_bg_color: dark ? '#000000' : '#f0f2f5', - force_theme_style: resolved, }, }, }, () => {}) diff --git a/packages/playground/src/pages/nav-chain/App.tsx b/packages/playground/src/pages/nav-chain/App.tsx index ecd6c29b..7786aa5f 100644 --- a/packages/playground/src/pages/nav-chain/App.tsx +++ b/packages/playground/src/pages/nav-chain/App.tsx @@ -29,10 +29,6 @@ function NavChainContent() { params: { title: `Stack Level ${depth + 1}`, hide_nav_bar: 0, - container_bg_color: isDark ? '#000000' : '#f0f2f5', - nav_bar_color: isDark ? '#000000' : '#ffffff', - title_color: isDark ? '#FFFFFF' : '#000000', - force_theme_style: resolved, depth: String(depth + 1), from_depth: String(depth), }, diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/HybridKit.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/HybridKit.kt index 05f83553..82e0c8ab 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/HybridKit.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/HybridKit.kt @@ -13,6 +13,9 @@ import com.tiktok.sparkling.hybridkit.config.SparklingHybridConfig import com.tiktok.sparkling.hybridkit.lynx.HybridLynxKit import com.tiktok.sparkling.hybridkit.scheme.HybridSchemeParam import com.tiktok.sparkling.hybridkit.service.HybridActivityStackManager +import com.tiktok.sparkling.hybridkit.theme.AbsSetThemePreferenceMethod +import com.tiktok.sparkling.hybridkit.theme.SetThemePreferenceMethod +import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager import com.tiktok.sparkling.method.runtime.depend.BridgeBaseRuntime object HybridKit { @@ -24,6 +27,12 @@ object HybridKit { HybridActivityStackManager.init(application) this.application = application BridgeBaseRuntime.applicationContext = application.applicationContext + SparklingBridgeManager.registerIDLMethod( + AbsSetThemePreferenceMethod.METHOD_NAME, + clazz = SetThemePreferenceMethod::class.java, + ) { + SetThemePreferenceMethod() + } } /** diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/theme/SetThemePreferenceMethod.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/theme/SetThemePreferenceMethod.kt new file mode 100644 index 00000000..47cae23f --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/theme/SetThemePreferenceMethod.kt @@ -0,0 +1,94 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +package com.tiktok.sparkling.hybridkit.theme + +import com.tiktok.sparkling.method.registry.core.BridgePlatformType +import com.tiktok.sparkling.method.registry.core.IDLBridgeMethod +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodName +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamField +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodParamModel +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodResultModel +import com.tiktok.sparkling.method.registry.core.annotation.IDLMethodStringEnum +import com.tiktok.sparkling.method.registry.core.base.AbsSparklingIDLMethod +import com.tiktok.sparkling.method.registry.core.model.idl.CompletionBlock +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseParamModel +import com.tiktok.sparkling.method.registry.core.model.idl.IDLMethodBaseResultModel +import com.tiktok.sparkling.method.registry.core.utils.createXModel + +abstract class AbsSetThemePreferenceMethod : + AbsSparklingIDLMethod< + AbsSetThemePreferenceMethod.ParamModel, + AbsSetThemePreferenceMethod.ResultModel, + >() { + @IDLMethodName( + name = METHOD_NAME, + params = ["preference"], + results = ["preference"], + ) + final override val name: String = METHOD_NAME + + @IDLMethodParamModel + interface ParamModel : IDLMethodBaseParamModel { + @get:IDLMethodStringEnum( + ThemePreference.FOLLOW_SYSTEM_VALUE, + ThemePreference.LIGHT_VALUE, + ThemePreference.DARK_VALUE, + ) + @get:IDLMethodParamField( + required = true, + isGetter = true, + keyPath = "preference", + isEnum = true, + ) + val preference: String + } + + @IDLMethodResultModel + interface ResultModel : IDLMethodBaseResultModel { + @get:IDLMethodParamField( + required = true, + isGetter = true, + keyPath = "preference", + ) + @set:IDLMethodParamField( + required = true, + isGetter = false, + keyPath = "preference", + ) + var preference: String + } + + companion object { + const val METHOD_NAME = "sparkling.setThemePreference" + } +} + +class SetThemePreferenceMethod : AbsSetThemePreferenceMethod() { + override fun handle( + params: ParamModel, + callback: CompletionBlock, + type: BridgePlatformType, + ) { + val preference = + ThemePreference.fromValue(params.preference) + ?: return callback.onFailure( + IDLBridgeMethod.INVALID_PARAM, + "preference must be follow-system, light, or dark", + ) + val context = + getSDKContext()?.context + ?: return callback.onFailure( + IDLBridgeMethod.FAIL, + "Context not provided in host", + ) + + ThemePreferenceManager.setPreference(context, preference) + callback.onSuccess( + ResultModel::class.java + .createXModel(getSDKContext()?.containerID) + .apply { this.preference = preference.value }, + ) + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/theme/ThemePreference.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/theme/ThemePreference.kt new file mode 100644 index 00000000..fc7b1995 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/theme/ThemePreference.kt @@ -0,0 +1,75 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +package com.tiktok.sparkling.hybridkit.theme + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.tiktok.sparkling.hybridkit.KitViewManager +import com.tiktok.sparkling.hybridkit.utils.GlobalPropsUtils + +enum class ThemePreference( + val value: String, +) { + FOLLOW_SYSTEM("follow-system"), + LIGHT("light"), + DARK("dark"), + ; + + companion object { + const val FOLLOW_SYSTEM_VALUE = "follow-system" + const val LIGHT_VALUE = "light" + const val DARK_VALUE = "dark" + + fun fromValue(value: String?): ThemePreference? = + values().firstOrNull { + it.value == value?.trim()?.lowercase() + } + } +} + +object ThemePreferenceManager { + const val GLOBAL_PROPS_KEY = "preferredTheme" + + internal const val PREFERENCES_NAME = "com.tiktok.sparkling.theme" + internal const val PREFERENCE_KEY = "theme_preference" + + @JvmStatic + fun getPreference(context: Context): ThemePreference { + val value = + context.applicationContext + .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .getString(PREFERENCE_KEY, null) + return ThemePreference.fromValue(value) ?: ThemePreference.FOLLOW_SYSTEM + } + + @JvmStatic + fun setPreference( + context: Context, + preference: ThemePreference, + ) { + context.applicationContext + .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .putString(PREFERENCE_KEY, preference.value) + .apply() + + val update = + Runnable { + val globalProps = mapOf(GLOBAL_PROPS_KEY to preference.value) + KitViewManager.getKitViews().forEach { (containerId, kitView) -> + GlobalPropsUtils.instance.setUnstableProps(containerId, globalProps) + runCatching { + kitView.updateGlobalPropsByIncrement(globalProps) + } + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + update.run() + } else { + Handler(Looper.getMainLooper()).post(update) + } + } +} diff --git a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/utils/GlobalPropsUtils.kt b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/utils/GlobalPropsUtils.kt index ee91b71d..7476eef2 100644 --- a/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/utils/GlobalPropsUtils.kt +++ b/packages/sparkling-sdk/android/sparkling/src/main/java/com/tiktok/sparkling/hybridkit/utils/GlobalPropsUtils.kt @@ -12,6 +12,7 @@ import com.tiktok.sparkling.hybridkit.base.HybridLoadSession import com.tiktok.sparkling.hybridkit.base.Theme import com.tiktok.sparkling.hybridkit.config.RuntimeInfo import com.tiktok.sparkling.hybridkit.scheme.SparklingUriParser +import com.tiktok.sparkling.hybridkit.theme.ThemePreferenceManager import androidx.core.net.toUri import java.util.concurrent.ConcurrentHashMap @@ -232,6 +233,12 @@ class GlobalPropsUtils { put("screenOrientation", if (isPortrait) "Portrait" else "Landscape") put("orientation", if (isPortrait) 0 else 1) put("theme", if (hybridContext.getTheme(context) == Theme.DARK) "dark" else "light") + context?.let { + put( + ThemePreferenceManager.GLOBAL_PROPS_KEY, + ThemePreferenceManager.getPreference(it).value, + ) + } } putAll(unstableMap) } diff --git a/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/theme/ThemePreferenceTest.kt b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/theme/ThemePreferenceTest.kt new file mode 100644 index 00000000..e8178109 --- /dev/null +++ b/packages/sparkling-sdk/android/sparkling/src/test/java/com/tiktok/sparkling/hybridkit/theme/ThemePreferenceTest.kt @@ -0,0 +1,242 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +package com.tiktok.sparkling.hybridkit.theme + +import android.app.Application +import android.content.Context +import android.view.View +import com.tiktok.sparkling.hybridkit.HybridContext +import com.tiktok.sparkling.hybridkit.HybridEnvironment +import com.tiktok.sparkling.hybridkit.HybridKit +import com.tiktok.sparkling.hybridkit.KitViewManager +import com.tiktok.sparkling.hybridkit.base.IKitView +import com.tiktok.sparkling.hybridkit.config.RuntimeInfo +import com.tiktok.sparkling.hybridkit.utils.GlobalPropsUtils +import com.tiktok.sparkling.method.registry.core.BridgePlatformType +import com.tiktok.sparkling.method.registry.core.IBridgeContext +import com.tiktok.sparkling.method.registry.core.IDLBridgeMethod +import com.tiktok.sparkling.method.registry.core.SparklingBridgeManager +import com.tiktok.sparkling.method.registry.core.model.idl.CompletionBlock +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config( + sdk = [33], + packageName = "com.tiktok.sparkling", +) +class ThemePreferenceTest { + private lateinit var application: Application + private val trackedContainerIds = mutableListOf() + + @Before + fun setUp() { + application = RuntimeEnvironment.getApplication() + HybridEnvironment.instance.context = application + application + .getSharedPreferences(ThemePreferenceManager.PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @After + fun tearDown() { + trackedContainerIds.forEach { + KitViewManager.removeKitView(it) + GlobalPropsUtils.instance.flushGlobalProps(it) + } + application + .getSharedPreferences(ThemePreferenceManager.PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun defaultsToFollowSystemAndPersistsPreference() { + assertEquals( + ThemePreference.FOLLOW_SYSTEM, + ThemePreferenceManager.getPreference(application), + ) + + ThemePreferenceManager.setPreference(application, ThemePreference.DARK) + + assertEquals( + ThemePreference.DARK, + ThemePreferenceManager.getPreference(application), + ) + } + + @Test + fun injectsPersistedPreferenceIntoNewContainerGlobalProps() { + ThemePreferenceManager.setPreference(application, ThemePreference.LIGHT) + val hybridContext = HybridContext() + trackedContainerIds += hybridContext.containerId + + GlobalPropsUtils.instance.init(hybridContext, application) + + assertEquals( + ThemePreference.LIGHT.value, + GlobalPropsUtils.instance + .getGlobalProps(hybridContext.containerId)[ThemePreferenceManager.GLOBAL_PROPS_KEY], + ) + assertNotNull( + GlobalPropsUtils.instance + .getGlobalProps(hybridContext.containerId)[RuntimeInfo.QUERY_ITEMS], + ) + } + + @Test + fun broadcastsPreferenceAndUpdatesTrackedGlobalProps() { + val firstView = RecordingKitView(application, "theme-container-1") + val secondView = RecordingKitView(application, "theme-container-2") + listOf(firstView, secondView).forEach { + trackedContainerIds += it.hybridContext.containerId + GlobalPropsUtils.instance.init(it.hybridContext, application) + KitViewManager.addKitView(it) + } + + ThemePreferenceManager.setPreference(application, ThemePreference.DARK) + shadowOf(application.mainLooper).idle() + + listOf(firstView, secondView).forEach { + assertEquals( + mapOf(ThemePreferenceManager.GLOBAL_PROPS_KEY to ThemePreference.DARK.value), + it.lastGlobalPropsUpdate, + ) + assertEquals( + ThemePreference.DARK.value, + GlobalPropsUtils.instance + .getGlobalProps(it.hybridContext.containerId)[ThemePreferenceManager.GLOBAL_PROPS_KEY], + ) + } + } + + @Test + fun builtInMethodPersistsAndReturnsNormalizedPreference() { + val bridgeContext = + mockk(relaxed = true) { + every { context } returns application + every { containerID } returns "source-container" + } + val params = + mockk { + every { preference } returns " DARK " + } + val callback = ResultRecorder() + val method = SetThemePreferenceMethod().apply { setBridgeContext(bridgeContext) } + + method.handle(params, callback, BridgePlatformType.LYNX) + + assertNull(callback.failureCode) + assertEquals(ThemePreference.DARK.value, callback.successResult?.preference) + assertEquals( + ThemePreference.DARK, + ThemePreferenceManager.getPreference(application), + ) + } + + @Test + fun builtInMethodRejectsInvalidPreference() { + val bridgeContext = + mockk(relaxed = true) { + every { context } returns application + } + val params = + mockk { + every { preference } returns "sepia" + } + val callback = ResultRecorder() + val method = SetThemePreferenceMethod().apply { setBridgeContext(bridgeContext) } + + method.handle(params, callback, BridgePlatformType.LYNX) + + assertEquals(IDLBridgeMethod.INVALID_PARAM, callback.failureCode) + assertNull(callback.successResult) + } + + @Test + fun hybridKitRegistersBuiltInThemeMethod() { + HybridKit.init(application) + + val methodProvider = + SparklingBridgeManager.findIDLMethodProvider( + BridgePlatformType.LYNX, + AbsSetThemePreferenceMethod.METHOD_NAME, + ) + + assertNotNull(methodProvider) + assertEquals(AbsSetThemePreferenceMethod.METHOD_NAME, methodProvider?.provideMethod()?.name) + } + + private class RecordingKitView( + context: Context, + containerId: String, + ) : IKitView { + override var hybridContext = HybridContext().apply { this.containerId = containerId } + private val view = View(context) + var lastGlobalPropsUpdate: Map? = null + + override fun realView(): View = view + + override fun load() = Unit + + override fun load(uri: String) = Unit + + override fun reload() = Unit + + override fun updateGlobalPropsByIncrement(data: Map) { + lastGlobalPropsUpdate = data + } + + override fun onShow() = Unit + + override fun onHide() = Unit + + override fun destroy(clearContext: Boolean) = Unit + + override fun hasDestroyed(): Boolean = false + + override fun getGlobalProps(): MutableMap? = null + + override fun getScheme(): String? = null + + override fun onLoadSuccess() = Unit + } + + private class ResultRecorder : CompletionBlock { + var successResult: AbsSetThemePreferenceMethod.ResultModel? = null + var failureCode: Int? = null + + override fun onSuccess( + result: AbsSetThemePreferenceMethod.ResultModel, + msg: String, + ) { + successResult = result + } + + override fun onFailure( + code: Int, + msg: String, + data: AbsSetThemePreferenceMethod.ResultModel?, + ) { + failureCode = code + } + + override fun onRawSuccess(data: AbsSetThemePreferenceMethod.ResultModel?) = Unit + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKGlobalPropsUtils.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKGlobalPropsUtils.swift index ff925870..d149b38d 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKGlobalPropsUtils.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKGlobalPropsUtils.swift @@ -83,9 +83,19 @@ open class SPKGlobalPropsUtils: NSObject { "isAppBackground": UIApplication.shared.applicationState == .background, "screenOrientation": self.screenOrientationString(), "deviceModel": UIDevice.spk.hwModel?.lowercased() ?? "", + "theme": self.systemTheme(), + SPKThemePreferenceManager.globalPropsKey: SPKThemePreferenceManager.shared.preference.rawValue, ] } + static func systemTheme(for style: UIUserInterfaceStyle? = nil) -> String { + let resolvedStyle = + style + ?? UIApplication.spk.mainWindow?.traitCollection.userInterfaceStyle + ?? UIScreen.main.traitCollection.userInterfaceStyle + return resolvedStyle == .dark ? "dark" : "light" + } + /// Returns the current screen orientation as a string representation. /// /// This method converts the current status bar orientation to a human-readable diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKSetThemePreferenceMethod.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKSetThemePreferenceMethod.swift new file mode 100644 index 00000000..d50dff10 --- /dev/null +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKSetThemePreferenceMethod.swift @@ -0,0 +1,65 @@ +// Copyright 2025 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import SparklingMethod + +@objc(SPKSetThemePreferenceMethod) +public final class SPKSetThemePreferenceMethod: PipeMethod { + public override var methodName: String { + Self.methodName() + } + + public override class func methodName() -> String { + "sparkling.setThemePreference" + } + + @objc public override var paramsModelClass: AnyClass { + SPKSetThemePreferenceParamModel.self + } + + @objc public override var resultModelClass: AnyClass { + SPKSetThemePreferenceResultModel.self + } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let paramModel = paramModel as? SPKSetThemePreferenceParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + guard let preference = SPKThemePreference(value: paramModel.preference) else { + completionHandler.handleCompletion( + status: .invalidParameter(message: "preference must be follow-system, light, or dark"), + result: nil) + return + } + + SPKThemePreferenceManager.shared.setPreference(preference) + let result = SPKSetThemePreferenceResultModel() + result.preference = preference.rawValue + completionHandler.handleCompletion(status: .succeeded(), result: result) + } +} + +@objc(SPKSetThemePreferenceParamModel) +public final class SPKSetThemePreferenceParamModel: SPKMethodModel { + @objc public var preference: String? + + public override class func requiredKeyPaths() -> Set? { + ["preference"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + ["preference": "preference"] + } +} + +@objc(SPKSetThemePreferenceResultModel) +public final class SPKSetThemePreferenceResultModel: SPKMethodModel { + @objc public var preference: String? + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + ["preference": "preference"] + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKThemePreference.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKThemePreference.swift new file mode 100644 index 00000000..a31955e8 --- /dev/null +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/Base/SPKThemePreference.swift @@ -0,0 +1,72 @@ +// Copyright 2025 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +public enum SPKThemePreference: String { + case followSystem = "follow-system" + case light + case dark + + public init?(value: String?) { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return nil + } + self.init(rawValue: value) + } +} + +public final class SPKThemePreferenceManager { + public static let shared = SPKThemePreferenceManager() + public static let globalPropsKey = "preferredTheme" + + static let userDefaultsKey = "com.tiktok.sparkling.theme.preference" + + private let userDefaults: UserDefaults + private let containers = NSHashTable.weakObjects() + private let lock = NSLock() + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + public var preference: SPKThemePreference { + SPKThemePreference(value: self.userDefaults.string(forKey: Self.userDefaultsKey)) ?? .followSystem + } + + public func setPreference(_ preference: SPKThemePreference) { + self.userDefaults.set(preference.rawValue, forKey: Self.userDefaultsKey) + + let update = { [weak self] in + guard let self else { return } + let globalProps = [Self.globalPropsKey: preference.rawValue] + for container in self.activeContainers() { + container.update(withGlobalProps: globalProps) + } + } + if Thread.isMainThread { + update() + } else { + DispatchQueue.main.async(execute: update) + } + } + + func register(_ container: SPKWrapperViewProtocol) { + self.lock.lock() + self.containers.add(container) + self.lock.unlock() + } + + func unregister(_ container: SPKWrapperViewProtocol) { + self.lock.lock() + self.containers.remove(container) + self.lock.unlock() + } + + private func activeContainers() -> [SPKWrapperViewProtocol] { + self.lock.lock() + defer { self.lock.unlock() } + return self.containers.allObjects.compactMap { $0 as? SPKWrapperViewProtocol } + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift index ac80ee8c..2e2343ae 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift @@ -204,9 +204,11 @@ open class SPKWrapperLynxView: LynxView, SPKWrapperLynxViewProtocol { self.triggerLayout() NotificationCenter.default.post(name: SPKWrapperLynxView.didCreateNotification, object: self) + SPKThemePreferenceManager.shared.register(self) } deinit { + SPKThemePreferenceManager.shared.unregister(self) NotificationCenter.default.post(name: SPKWrapperLynxView.willDestroyNotification, object: self) }