Skip to content

Add support for high contrast and color inversion on Android#182263

Merged
auto-submit[bot] merged 15 commits into
flutter:masterfrom
xxxOVALxxx:support_accessibility_features
Apr 29, 2026
Merged

Add support for high contrast and color inversion on Android#182263
auto-submit[bot] merged 15 commits into
flutter:masterfrom
xxxOVALxxx:support_accessibility_features

Conversation

@xxxOVALxxx

@xxxOVALxxx xxxOVALxxx commented Feb 11, 2026

Copy link
Copy Markdown
Contributor

This PR implements Android support for two previously unsupported accessibility features in AccessibilityBridge

Part of #168288

On Android, unlike iOS, color contrast can range from -1.0 to 1.0. The current implementation of AccessibilityBridge only allows communication using bitmasks. In the future, this implementation will need to be changed to allow other types of data to be sent (And probably modifications to the corresponding ColorScheme).

In the video, you can also see that color inversion is enabled with a delay (about 10 seconds). I tested it on all my devices and created an empty project on Jetpack Compose for testing, and it turned on with a delay everywhere. The only thing I found was that older versions of the Android API do not have this problem. I have not determined exactly which version this problem starts with, but API 28 and 24 do not have this problem.

output.webm
Code Example

import 'package:flutter/material.dart';

void main() {
  runApp(const AccessibilityTestApp());
}

class AccessibilityTestApp extends StatelessWidget {
  const AccessibilityTestApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      highContrastTheme: ThemeData(
        colorScheme: const ColorScheme.highContrastLight(
          primary: Colors.black,
          secondary: Colors.red,
        ),
      ),
      home: const TestScreen(),
    );
  }
}

class TestScreen extends StatelessWidget {
  const TestScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    final bool isHighContrast = mediaQuery.highContrast;
    final bool isInverted = mediaQuery.invertColors;

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.primaryContainer,
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(20.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              _StatusCard(
                title: 'High Contrast',
                isActive: isHighContrast,
                activeColor: Colors.green,
                icon: Icons.contrast,
              ),

              const SizedBox(height: 20),

              _StatusCard(
                title: 'Invert Colors',
                isActive: isInverted,
                activeColor: Colors.green,
                icon: Icons.invert_colors,
              ),

              const SizedBox(height: 20),

              Image.network('https://picsum.photos/400/200', fit: BoxFit.cover),

              const SizedBox(height: 20),

              ElevatedButton(onPressed: () {}, child: const Text('Button')),
            ],
          ),
        ),
      ),
    );
  }
}

class _StatusCard extends StatelessWidget {
  final String title;
  final bool isActive;
  final Color activeColor;
  final IconData icon;

  const _StatusCard({
    required this.title,
    required this.isActive,
    required this.activeColor,
    required this.icon,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListPadding(
        child: ListTile(
          leading: Icon(
            icon,
            color: isActive ? activeColor : Colors.grey,
            size: 32,
          ),
          title: Text(
            title,
            style: const TextStyle(fontWeight: FontWeight.bold),
          ),
          trailing: Icon(
            isActive ? Icons.check_circle : Icons.remove_circle_outline,
            color: isActive ? activeColor : Colors.grey,
          ),
        ),
      ),
    );
  }
}

class ListPadding extends StatelessWidget {
  final Widget child;
  const ListPadding({super.key, required this.child});
  @override
  Widget build(BuildContext context) =>
      Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: child);
}

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

@xxxOVALxxx xxxOVALxxx requested a review from a team as a code owner February 11, 2026 22:50
@github-actions github-actions Bot added platform-android Android applications specifically framework flutter/packages/flutter repository. See also f: labels. engine flutter/engine related. See also e: labels. f: material design flutter/packages/flutter/material repository. team-android Owned by Android platform team labels Feb 11, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds support for high contrast and color inversion accessibility features on Android. The implementation in AccessibilityBridge.java is well-structured, introducing a new observer pattern for handling accessibility feature changes, which significantly improves code organization and maintainability. The changes are well-tested, covering different API levels and scenarios. I've added a few suggestions to make the tests even more precise. Overall, this is a high-quality contribution.

@reidbaker reidbaker added the fyi-accessibility For the attention of Framework Accessibility team label Feb 17, 2026

@chunhtai chunhtai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reusing AccessibilityFeature is fine for now, but I think we do need a property for this setting sooner or later

}

float uiContrast = uiModeManager.getContrast();
// 0.0 (standard), 0.5 (medium), 1.0 (high)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the value a slider in android setting?

looks like we need a new API in framework to accomodate this, something like PlatformDispatcher.colorContrastScale.

I assume the 1.0 is the same as highcontrast in iOS?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that in the future we should move the contrast value into a separate API. I’ll leave a TODO.

The contrast value can range from -1.0 to 1.0 link I don’t fully understand how to obtain a value below zero. Possibly this will be added in future versions of Android.

In stock Android, the contrast selection is represented by three values. However, in different Android skins, the way the value is selected may be different

Screenshot 2026-02-18 at 15 40 37

On iOS, contrast is represented as a boolean value

@xxxOVALxxx xxxOVALxxx Feb 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we already have highContrastTheme. In the case of a floating contrast value, we will need to modify this slightly.

Maybe we could do this:
highContrastTheme: (double contrast) => ThemeData()

Or merge theme and highContrastTheme into a single parameter:
theme: (double contrast) => ThemeData()

Or come up with a more elegant solution that doesn't break the existing API and takes negative contrast values ​​into account

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably set the contrast to -1 via adb, but I think that would correlate to 'low contrast' - which android doesn't seem to do right now. OEMs could probably set it differently.

For now, since we only have the on/off switch for high contrast, this seems like the way to go. Please create an issue to track the todo (if you haven't already and put that in the comment instead of your username).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the advice. I created an issue and updated the TODO: #182863

mboetger
mboetger previously approved these changes Mar 3, 2026

@mboetger mboetger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

chunhtai
chunhtai previously approved these changes Mar 3, 2026

@chunhtai chunhtai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jesswrd jesswrd added the autosubmit Merge PR when tree becomes green via auto submit App label Mar 10, 2026
@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Mar 10, 2026
@auto-submit

auto-submit Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/182263, because The base commit of the PR is older than 7 days and can not be merged. Please merge the latest changes from the main into this branch and resubmit the PR.

@xxxOVALxxx xxxOVALxxx requested review from a team, jtmcdole and loic-sharma as code owners March 10, 2026 21:40
@github-actions github-actions Bot added a: tests "flutter test", flutter_test, or one of our tests a: text input Entering text in a text field or keyboard related problems labels Mar 10, 2026
@auto-submit

auto-submit Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/182263, because The base commit of the PR is older than 7 days and can not be merged. Please merge the latest changes from the main into this branch and resubmit the PR.

@dkwingsmt

Copy link
Copy Markdown
Contributor

Technically this PR is blocked by code freeze (#184093) since it modifies material, but the changes are doc only. Should we keep this PR blocked, or revert the doc changes, or give it a freeze exempt? @justinmc @Piinks

@Piinks

Piinks commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Yes we need to respect the code freeze so we do not end up in a state where the new material and cupertino packages are missing changes.

@dkwingsmt

dkwingsmt commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

I can understand. So unless the doc change part can be reasonably excluded (which I'll leave it to the reviewers @chunhtai and @mboetger ), we'll have to hold landing this PR until after the decoupling. For now I'll assume that it's required and go ahead to convert this PR to a draft and apply a label so that we can quickly find it when the freeze is lifted. In case that we've decided it's ok to exclude the doc changes in Material, we can always convert it back and resume landing.

Anyway, @xxxOVALxxx thank you for your contribution and thanks for understanding while we prepare for the decoupling!

@mboetger

mboetger commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

I'd be ok removing the material comment change.

@dkwingsmt

Copy link
Copy Markdown
Contributor

Great! @xxxOVALxxx If you're ok as well, can you remove the material widget change?

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for high contrast and color inversion accessibility features on Android (API 34+). It refactors the AccessibilityBridge to use a modular observer pattern for monitoring system settings and updates documentation across the engine and framework. The review feedback suggests simplifying setting retrieval using API overloads, using epsilon for floating-point comparisons, and optimizing IPC calls by checking for flag changes before notifying the framework.

Comment on lines +501 to +508
protected boolean isFeatureEnabled() {
try {
return Settings.Secure.getInt(contentResolver, settingKey) == 1;
} catch (Settings.SettingNotFoundException e) {
Log.d(TAG, "Setting not found: " + settingKey + ", using default: " + defaultValue);
return defaultValue == 1;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic can be simplified by using the Settings.Secure.getInt overload that accepts a default value. This makes the code more concise and avoids the overhead of an explicit try-catch block for SettingNotFoundException.

    @Override
    protected boolean isFeatureEnabled() {
      return Settings.Secure.getInt(contentResolver, settingKey, defaultValue) == 1;
    }

Comment on lines +526 to +529
protected boolean isFeatureEnabled() {
float value = Settings.Global.getFloat(contentResolver, settingKey, defaultValue);
return value == enabledValue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Comparing floating-point numbers directly with == can be unreliable due to precision issues. It is generally safer to check if the difference between the values is within a very small threshold (epsilon).

    @Override
    protected boolean isFeatureEnabled() {
      float value = Settings.Global.getFloat(contentResolver, settingKey, defaultValue);
      return Math.abs(value - enabledValue) < 1e-6;
    }

Comment on lines +757 to 764
private void updateAccessibilityFeature(AccessibilityFeature feature, boolean enabled) {
if (enabled) {
accessibilityFeatureFlags |= feature.value;
} else {
accessibilityFeatureFlags &= ~AccessibilityFeature.BOLD_TEXT.value;
accessibilityFeatureFlags &= ~feature.value;
}
sendLatestAccessibilityFlagsToFlutter();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The updateAccessibilityFeature method currently sends the latest accessibility flags to Flutter every time it is called. During initialization, this results in multiple redundant IPC calls as each feature (animations, invert colors, high contrast, bold text) is initialized. Consider checking if the flag value has actually changed before triggering the update to Flutter.

  private void updateAccessibilityFeature(AccessibilityFeature feature, boolean enabled) {
    int oldFlags = accessibilityFeatureFlags;
    if (enabled) {
      accessibilityFeatureFlags |= feature.value;
    } else {
      accessibilityFeatureFlags &= ~feature.value;
    }
    if (oldFlags != accessibilityFeatureFlags) {
      sendLatestAccessibilityFlagsToFlutter();
    }
  }

@xxxOVALxxx

Copy link
Copy Markdown
Contributor Author

Done

@chunhtai chunhtai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@auto-submit

auto-submit Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/182263, because - The status or check suite Mac mac_ios_engine_ddm has failed. Please fix the issues identified (or deflake) before re-applying this label.

@justinmc

Copy link
Copy Markdown
Contributor

Looks like there are Google test failures here.

@reidbaker

Copy link
Copy Markdown
Contributor

@justinmc fyi @camsim99 went to make a g3 fix for this and it quickly grew beyond the time she had allocated. We might need to change this pr behavior in order to actually get it landed or maybe there are superpowers someplace to approve a lot of scuba diffs.

@camsim99

Copy link
Copy Markdown
Contributor

I assume go/lssc is the path forward here, I just don't have the bandwidth currently.

@chunhtai

Copy link
Copy Markdown
Contributor

There should be a fusion view that let you triage all scubas diff at once. also scuba doesn't need g3 fix, they will auto generate upon g3 roll

@chunhtai

Copy link
Copy Markdown
Contributor

also looking at the code change, I won't expect scuba change, do you have link the the failures? the frob entry seems to be dead https://frob.corp.google.com/#/pr/182263

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. framework flutter/packages/flutter repository. See also f: labels. fyi-accessibility For the attention of Framework Accessibility team platform-android Android applications specifically team-android Owned by Android platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants