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
20 changes: 20 additions & 0 deletions change-logs/1.22.0/changesets/android-window-follows-app-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
type: fixed
area: ui
platforms:
- android
pull_request: 4446
rollout: full
---

## zh-CN

深色模式下,在 App 内打开的页面(网页、Artifacts、更新说明)以及系统对话框和选择器现在也是深色的,跟随 App 的外观设置,而不再总是浅色。

## en

In dark mode, pages opened inside the app (web pages, Artifacts, release notes) and system dialogs and pickers are now dark too, following the app's appearance setting instead of always showing light.

## ja

ダークモードでは、アプリ内で開くページ(Web ページ、Artifacts、リリースノート)やシステムのダイアログ・ピッカーもダークで表示されるようになり、アプリの外観設定に従います(常にライトで表示されることはなくなりました)。
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import build.hands.update.HandsCrash
import build.raft.app.shared.AndroidAppScopeRealtimeOwner
import build.raft.app.shared.AndroidRealtimeKeepAliveController
import build.raft.app.shared.RaftAndroidHostHooks
import build.raft.app.shared.RaftAndroidNightMode
import build.raft.app.shared.RaftBuildStamp
import build.raft.app.shared.ProcessAndroidAttachmentMediaStoreCleanup
import build.raft.app.shared.ProcessAndroidAttachmentPublicDownloads
Expand Down Expand Up @@ -81,6 +82,9 @@ class RaftApplication : Application() {
// Settings > Appearance > Theme, applied before any page composes.
RaftAppAppearanceRuntime.onSystemDarkChanged(isAndroidSystemDark(resources.configuration.uiMode))
RaftAppAppearanceRuntime.ensureLoaded()
// The window theme follows that choice (DayNight Theme.Raft): WebViews, system dialogs,
// pickers and bar icons match the app, not just the OS.
RaftAndroidNightMode.install(this)
// The font picker is gone (the design system fixes the faces); drop its stored choice.
raftClearLegacyFontPreference()
installAndroidAppScopePlatformFileResolver(this)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package build.raft.app.shared

import android.app.Activity
import android.app.Application
import android.app.UiModeManager
import android.content.ComponentName
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.annotation.RequiresApi
import build.raft.app.RaftAppAppearance
import build.raft.app.data.RaftAppAppearanceRuntime
import java.util.WeakHashMap

/**
* The window theme follows the app's own light/dark choice (Settings > Appearance: Light, Dark or
* System), not just the OS. Theme.Raft is a day/night theme (values/ and values-night/
* Base.Theme.Raft), so putting the choice into the configuration's night bits makes everything the
* platform draws from the theme match the app: WebView `prefers-color-scheme`
* (`android:isLightTheme`), AlertDialogs, pickers, the window background and the first frame's bar
* icons. One source of truth: [RaftAppAppearanceRuntime]; System follows the device.
*
* - **API 31+**: [UiModeManager.setApplicationNightMode] (`MODE_NIGHT_NO` / `YES`, `AUTO` for
* System, which removes the override). The system persists it per package and applies it to the
* whole process (Activities, the application context, the system splash of the next cold
* start). Hosts handle uiMode in place (manifest), so a switch arrives as onConfigurationChanged
* a moment later; Activities that do not handle it are recreated by the system.
* - **API 29-30** (no per-app night mode): each Activity's resources get the night bits of the
* choice before onCreate ([Application.ActivityLifecycleCallbacks.onActivityPreCreated]) and the
* theme is rebased, so the window, its theme and every view inflated from it start in the app's
* mode. A switch in Settings updates every live Activity in place (no recreation, so the open
* tab, pushed pages and scroll positions stay): resources, theme rebase, the view tree's
* onConfigurationChanged and the Activity's own. An Activity that does not handle uiMode is
* recreated, as the system would. A system configuration change can rebuild the resources from
* the OS setting; the night bits are put back in onConfigurationChanged (before the view tree
* reads them) and when an Activity starts. The application context keeps the OS mode on these
* versions, and the system's own starting window (before the process runs) cannot follow the
* app choice.
*
* The Activity configuration therefore carries the resolved app mode: the hosts' "system dark"
* report ([RaftAppAppearanceRuntime.onSystemDarkChanged]) reads it, which under System is the OS
* setting and under Light/Dark only agrees with the choice.
*/
internal object RaftAndroidNightMode {
private const val LOG_TAG = "RaftNightMode"

private var installed = false

/** API 29-30: the live Activities, updated in place on a switch. Main thread. */
private val activities = WeakHashMap<Activity, Unit>()

private val handlesUiModeByClass = HashMap<Class<*>, Boolean>()

/** Main thread, after the stored choice is loaded ([RaftAppAppearanceRuntime.ensureLoaded]). */
fun install(application: Application) {
if (installed) return
installed = true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
application.registerActivityLifecycleCallbacks(LegacyActivityCallbacks)
}
// Called now with the stored choice and after every change (Settings > Appearance).
RaftAppAppearanceRuntime.setHostListener { onAppearanceChanged(application) }
}

private fun onAppearanceChanged(application: Application) {
val appearance = RaftAppAppearanceRuntime.appearance
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
applyApplicationNightMode(application, appearance)
} else {
activities.keys.toList().forEach { activity -> reconcile(activity, notify = true) }
}
}

@RequiresApi(Build.VERSION_CODES.S)
private fun applyApplicationNightMode(application: Application, appearance: RaftAppAppearance) {
runCatching {
application.getSystemService(UiModeManager::class.java)
?.setApplicationNightMode(raftApplicationNightMode(appearance))
}.onFailure { error ->
Log.w(LOG_TAG, "set_application_night_mode failed errorType=${error::class.simpleName}")
}
}

/**
* API 29-30: gives [activity]'s resources the night bits of the app choice and rebases its
* theme. With [notify], an Activity already showing content is told: its view tree and its
* onConfigurationChanged (or a recreation when it does not handle uiMode). Idempotent.
*/
private fun reconcile(activity: Activity, notify: Boolean) {
if (activity.isDestroyed) return
val resources = activity.resources
val current = resources.configuration
val nightBits = raftNightBits(RaftAppAppearanceRuntime.appearance, Resources.getSystem().configuration.uiMode)
val uiMode = raftUiModeWithNight(current.uiMode, nightBits)
if (uiMode == current.uiMode) return
if (notify && !handlesUiMode(activity)) {
// A recreated Activity gets the night bits in onActivityPreCreated.
activity.recreate()
return
}
val config = Configuration(current).apply { this.uiMode = uiMode }
// Deprecated, but the only in-place path on API 29-30 (AppCompat's night mode does the
// same); these versions no longer change.
@Suppress("DEPRECATION")
resources.updateConfiguration(config, resources.displayMetrics)
activity.theme.rebase()
if (notify) {
// Compose's LocalConfiguration (isSystemInDarkTheme) and WebViews follow the views'
// onConfigurationChanged; the host repaints its window in its own.
activity.window?.peekDecorView()?.dispatchConfigurationChanged(Configuration(config))
activity.onConfigurationChanged(Configuration(config))
}
Log.i(LOG_TAG, "activity_night_mode_applied night=${isAndroidSystemDark(uiMode)} notify=$notify")
}

private fun handlesUiMode(activity: Activity): Boolean =
handlesUiModeByClass.getOrPut(activity.javaClass) {
// By class, not componentName: an activity-alias (pre-rename Slock* names) is
// resolved to the declared Activity, whose configChanges count.
runCatching {
val info = activity.packageManager.getActivityInfo(ComponentName(activity, activity.javaClass), 0)
(info.configChanges and ActivityInfo.CONFIG_UI_MODE) != 0
}.getOrDefault(true)
}

private object LegacyActivityCallbacks : Application.ActivityLifecycleCallbacks {
override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) {
activities[activity] = Unit
reconcile(activity, notify = false)
// Runs inside ComponentActivity.onConfigurationChanged, before the host's own code
// and before the view tree reads the resources' configuration.
(activity as? ComponentActivity)?.addOnConfigurationChangedListener {
reconcile(activity, notify = false)
}
}

override fun onActivityStarted(activity: Activity) {
reconcile(activity, notify = true)
}

override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity)
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit

override fun onActivityResumed(activity: Activity) = Unit

override fun onActivityPaused(activity: Activity) = Unit

override fun onActivityStopped(activity: Activity) = Unit

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
}
}

/**
* The [Configuration.UI_MODE_NIGHT_MASK] bits for [appearance]: Light and Dark pin them, System
* takes the OS setting's from [systemUiMode] (undefined counts as light).
*/
internal fun raftNightBits(appearance: RaftAppAppearance, systemUiMode: Int): Int =
when (appearance) {
RaftAppAppearance.Light -> Configuration.UI_MODE_NIGHT_NO
RaftAppAppearance.Dark -> Configuration.UI_MODE_NIGHT_YES
RaftAppAppearance.System ->
if (isAndroidSystemDark(systemUiMode)) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}

/** [uiMode] with its night bits replaced by [nightBits]; the UI mode type stays. */
internal fun raftUiModeWithNight(uiMode: Int, nightBits: Int): Int =
(uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or (nightBits and Configuration.UI_MODE_NIGHT_MASK)

/** API 31+ [UiModeManager.setApplicationNightMode] value: `AUTO` (no override) follows the device. */
internal fun raftApplicationNightMode(appearance: RaftAppAppearance): Int =
when (appearance) {
RaftAppAppearance.Light -> UiModeManager.MODE_NIGHT_NO
RaftAppAppearance.Dark -> UiModeManager.MODE_NIGHT_YES
RaftAppAppearance.System -> UiModeManager.MODE_NIGHT_AUTO
}
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,8 @@ internal open class RaftCmpActivity :
)
}
onBackPressedDispatcher.addCallback(this, hostSystemBackCallback)
// Seed the OS light/dark setting before anything is drawn. Later OS switches arrive
// Seed the light/dark mode before anything is drawn: the resources carry the app's own
// choice (RaftAndroidNightMode; the OS setting under System). Later switches arrive
// through onConfigurationChanged (uiMode is a handled config change, see the manifest).
RaftAppAppearanceRuntime.onSystemDarkChanged(androidSystemDark())
RaftAppAppearanceRuntime.ensureLoaded()
Expand Down Expand Up @@ -1426,15 +1427,18 @@ internal open class RaftCmpActivity :
}

/**
* uiMode is a handled config change (manifest), so an OS light/dark switch no longer
* recreates the host: the page keeps its tab, pushed pages and scroll positions. Compose
* follows the new [Configuration] by itself (isSystemInDarkTheme in RaftTheme); report the
* setting to the process-wide theme too, so native host surfaces (window canvas, bar icons)
* switch on the same frame under System appearance.
* uiMode is a handled config change (manifest), so a light/dark switch (the OS's, or the
* app's own choice, which RaftAndroidNightMode puts into the configuration) does not recreate
* the host: the page keeps its tab, pushed pages and scroll positions. Compose follows the new
* [Configuration] by itself; report the mode to the process-wide theme too, so native host
* surfaces (window canvas, bar icons) switch on the same frame under System appearance.
* Read from the resources after super: on API 29-30 RaftAndroidNightMode puts the app
* choice's night bits back there (a listener of super) when the system rebuilt them from the
* OS setting.
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (RaftAppAppearanceRuntime.onSystemDarkChanged(isAndroidSystemDark(newConfig.uiMode))) {
if (RaftAppAppearanceRuntime.onSystemDarkChanged(androidSystemDark())) {
applyHostCanvasColor(RaftAppAppearanceRuntime.canvasArgb)
// Bar icons: the page reports them on its next frame (onSystemBarsResolved), from
// what it paints under the bars; the mode alone would be wrong on a dark light-mode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ class RaftWidgetServerConfigureActivity : Activity() {
TextView(this).apply {
this.text = text
textSize = sizeSp
setTextColor(getColor(android.R.color.black))
// The window follows the app's light/dark choice: the theme's primary text, not black.
val attrs = obtainStyledAttributes(intArrayOf(android.R.attr.textColorPrimary))
setTextColor(attrs.getColorStateList(0))
attrs.recycle()
if (bold) {
typeface = Typeface.DEFAULT_BOLD
}
Expand Down
6 changes: 4 additions & 2 deletions compose/androidApp/src/main/res/values-night/colors.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- raft-design dark `layer-canvas`: the window and splash behind the first frame when the
OS is dark. RaftCmpActivity repaints it from the app's own theme choice in onCreate. -->
<!-- raft-design dark `layer-canvas`: the window and splash behind the first frame in the
dark mode, which follows the app's own choice (RaftAndroidNightMode; the OS under
System), so a dark cold start has no light flash. RaftCmpActivity then paints the
resolved canvas of the server theme in onCreate. -->
<color name="raft_window_background">#FF1B1B19</color>
</resources>
15 changes: 15 additions & 0 deletions compose/androidApp/src/main/res/values-night/themes.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Night twin of values/themes.xml Base.Theme.Raft: picked whenever the configuration is
night, which RaftAndroidNightMode makes follow the app's own choice (Settings >
Appearance: Dark, or System on a dark device). The dark Material parent makes
android:isLightTheme false, so WebViews report prefers-color-scheme: dark, and
AlertDialogs and pickers render dark. Theme.Raft (values/) adds the shared items on
top; the window background is night-qualified (values-night/colors.xml). -->
<style name="Base.Theme.Raft" parent="android:Theme.Material.NoActionBar">
<!-- Light bar icons on the dark canvas from the first frame (dialog windows too);
RaftDialogWindowPolicy and the page's onSystemBarsResolved still refine them. -->
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>
30 changes: 21 additions & 9 deletions compose/androidApp/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Theme.Raft" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@color/raft_window_background</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">@color/raft_accent_pink</item>
<item name="android:textColorHighlight">@color/raft_accent_pink</item>
<item name="android:forceDarkAllowed">false</item>
<!-- The window theme follows the app's own light/dark choice (Settings > Appearance), not
just the OS: RaftAndroidNightMode puts that choice into the configuration's night bits
(API 31+ UiModeManager.setApplicationNightMode, API 29-30 each Activity's resources), so
this light base and its values-night twin (the dark Material theme) are picked per mode.
Everything the platform draws from the theme then matches the app: WebView
prefers-color-scheme (android:isLightTheme), AlertDialogs, pickers, the window background
and the first frame's bar icons. Mode-specific items live here and in
values-night/themes.xml; shared items stay on Theme.Raft below. -->
<style name="Base.Theme.Raft" parent="android:Theme.Material.Light.NoActionBar">
<!-- Dark bar icons from the first frame. Compose Dialog windows (menus, sheets,
popovers, all opened through designsystem RaftWindowDialog) inherit this theme;
without it they were added with light icons and RaftDialogWindowPolicy only set
them a frame later, so the status bar icons blinked white-on-white whenever a
menu opened in the light theme. RaftDialogWindowPolicy then sets them from
RaftTheme.isDark (light icons in the dark theme). -->
RaftTheme.isDark (light icons in the dark theme, values-night). -->
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>

<style name="Theme.Raft" parent="Base.Theme.Raft">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@color/raft_window_background</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">@color/raft_accent_pink</item>
<item name="android:textColorHighlight">@color/raft_accent_pink</item>
<!-- The app draws its own dark theme; the platform must never invert it. -->
<item name="android:forceDarkAllowed">false</item>
</style>

<style name="Theme.Raft.Starting" parent="Theme.SplashScreen">
<item name="android:windowBackground">@color/raft_window_background</item>
<item name="windowSplashScreenBackground">@color/raft_window_background</item>
Expand Down
Loading
Loading