Skip to content

Commit cf551cf

Browse files
jkmasselclaude
andcommitted
Add e-ink mode with device detection, grayscale theme, and settings
Adds e-ink support for Android e-readers (Onyx Boox, Kobo, etc.): - Device detection using known manufacturer, brand, and model lists - Grayscale Compose color scheme with forced light mode - Animation disabling across all AniUtils methods and activity transitions - Settings toggle in App Settings (marked Beta) - Auto-detect prompt shown on first launch for detected e-ink devices - Analytics tracking for prompt and settings interactions - Preferences stored via AppPrefs (EINK_MODE_ENABLED, EINK_AUTO_DETECT_DONE) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5c837fb commit cf551cf

15 files changed

Lines changed: 446 additions & 13 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppThemeM3.kt

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@ import androidx.compose.runtime.CompositionLocalProvider
1212
import androidx.compose.runtime.ReadOnlyComposable
1313
import androidx.compose.runtime.staticCompositionLocalOf
1414
import androidx.compose.ui.graphics.Color
15+
import androidx.compose.ui.platform.LocalInspectionMode
1516
import org.wordpress.android.BuildConfig
17+
import org.wordpress.android.ui.prefs.AppPrefs
1618

1719
private val localColors = staticCompositionLocalOf { extraPaletteJPLight }
1820

1921
@Composable
2022
fun AppThemeM3(
2123
isDarkTheme: Boolean = isSystemInDarkTheme(),
2224
isJetpackApp: Boolean = BuildConfig.IS_JETPACK_APP,
25+
isEinkMode: Boolean = if (LocalInspectionMode.current) false else AppPrefs.isEinkModeEnabled(),
2326
content: @Composable () -> Unit
2427
) {
25-
AppThemeM3WithoutBackground(isDarkTheme, isJetpackApp) {
28+
AppThemeM3WithoutBackground(isDarkTheme, isJetpackApp, isEinkMode) {
2629
ContentInSurfaceM3(content)
2730
}
2831
}
@@ -31,35 +34,46 @@ fun AppThemeM3(
3134
fun AppThemeM3WithoutBackground(
3235
isDarkTheme: Boolean = isSystemInDarkTheme(),
3336
isJetpackApp: Boolean = BuildConfig.IS_JETPACK_APP,
37+
isEinkMode: Boolean = if (LocalInspectionMode.current) false else AppPrefs.isEinkModeEnabled(),
3438
content: @Composable () -> Unit
3539
) {
40+
val effectiveIsDark = if (isEinkMode) false else isDarkTheme
3641
val extraColors = getExtraColors(
37-
isDarkTheme = isDarkTheme,
38-
isJetpackApp = isJetpackApp
42+
isDarkTheme = effectiveIsDark,
43+
isJetpackApp = isJetpackApp,
44+
isEinkMode = isEinkMode,
3945
)
4046
CompositionLocalProvider(localColors provides extraColors) {
4147
MaterialTheme(
4248
colorScheme = getColorScheme(
43-
isDarkTheme = isDarkTheme,
44-
isJetpackApp = isJetpackApp
49+
isDarkTheme = effectiveIsDark,
50+
isJetpackApp = isJetpackApp,
51+
isEinkMode = isEinkMode,
4552
),
4653
content = content
4754
)
4855
}
4956
}
5057

5158
/**
52-
* This theme should *only* be used in the context of the Editor (e.g. Post Settings).
59+
* This theme should *only* be used in the context of the Editor
60+
* (e.g. Post Settings).
5361
* More info: https://github.com/wordpress-mobile/gutenberg-mobile/issues/4889
5462
*/
5563
@Composable
5664
fun AppThemeM3Editor(
5765
isDarkTheme: Boolean = isSystemInDarkTheme(),
5866
isJetpackApp: Boolean = BuildConfig.IS_JETPACK_APP,
67+
isEinkMode: Boolean = if (LocalInspectionMode.current) false else AppPrefs.isEinkModeEnabled(),
5968
content: @Composable () -> Unit
6069
) {
70+
val effectiveIsDark = if (isEinkMode) false else isDarkTheme
6171
androidx.compose.material3.MaterialTheme(
62-
colorScheme = getColorScheme(isDarkTheme = isDarkTheme, isJetpackApp = isJetpackApp),
72+
colorScheme = getColorScheme(
73+
isDarkTheme = effectiveIsDark,
74+
isJetpackApp = isJetpackApp,
75+
isEinkMode = isEinkMode,
76+
),
6377
content = content
6478
)
6579
}
@@ -70,8 +84,11 @@ fun AppThemeM3Editor(
7084
@Suppress("SameParameterValue")
7185
private fun getColorScheme(
7286
isDarkTheme: Boolean,
73-
isJetpackApp: Boolean
87+
isJetpackApp: Boolean,
88+
isEinkMode: Boolean = false,
7489
): ColorScheme {
90+
if (isEinkMode) return colorSchemeEink
91+
7592
return if (isJetpackApp) {
7693
if (isDarkTheme) {
7794
colorSchemeJPDark
@@ -85,6 +102,31 @@ private fun getColorScheme(
85102
}
86103
}
87104

105+
// E-ink displays are grayscale with limited shade range (typically
106+
// 16 levels). Error color is the same as onSurface because there
107+
// aren't enough distinct shades to differentiate them — error
108+
// states rely on icons, borders, and layout rather than color alone.
109+
private val colorSchemeEink = lightColorScheme(
110+
primary = AppColor.Black,
111+
secondary = AppColor.Gray50,
112+
background = AppColor.White,
113+
surface = AppColor.White,
114+
error = AppColor.Black,
115+
onPrimary = AppColor.White,
116+
onSecondary = AppColor.White,
117+
onBackground = AppColor.Black,
118+
onSurface = AppColor.Black,
119+
onError = AppColor.White,
120+
primaryContainer = AppColor.Gray10,
121+
onPrimaryContainer = AppColor.Black,
122+
secondaryContainer = AppColor.Gray10,
123+
onSecondaryContainer = AppColor.Black,
124+
outline = AppColor.Gray50,
125+
outlineVariant = AppColor.Gray30,
126+
surfaceVariant = AppColor.Gray10,
127+
onSurfaceVariant = AppColor.Black,
128+
)
129+
88130
private val colorSchemeJPLight = lightColorScheme(
89131
primary = AppColor.JetpackGreen50,
90132
secondary = AppColor.JetpackGreen30,
@@ -142,8 +184,11 @@ private val colorSchemeWPDark = darkColorScheme(
142184
@Suppress("SameParameterValue")
143185
private fun getExtraColors(
144186
isDarkTheme: Boolean,
145-
isJetpackApp: Boolean
187+
isJetpackApp: Boolean,
188+
isEinkMode: Boolean = false,
146189
): ExtraColors {
190+
if (isEinkMode) return extraPaletteEink
191+
147192
return if (isJetpackApp) {
148193
if (isDarkTheme) {
149194
extraPaletteJPDark
@@ -157,6 +202,13 @@ private fun getExtraColors(
157202
}
158203
}
159204

205+
private val extraPaletteEink = ExtraColors(
206+
success = AppColor.Black,
207+
warning = AppColor.Gray50,
208+
neutral = AppColor.Gray50,
209+
ghost = AppColor.Black,
210+
)
211+
160212
private val extraPaletteJPLight = ExtraColors(
161213
success = AppColor.JetpackGreen50,
162214
warning = AppColor.Orange50,

WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity
1212
import androidx.core.view.ViewCompat
1313
import androidx.core.view.WindowInsetsCompat
1414
import org.wordpress.android.support.SupportWebViewActivity
15+
import org.wordpress.android.ui.accounts.DeviceInfoActivity
1516
import org.wordpress.android.ui.accounts.applicationpassword.ApplicationPasswordsListActivity
1617
import org.wordpress.android.ui.blaze.blazecampaigns.BlazeCampaignParentActivity
1718
import org.wordpress.android.ui.bloggingprompts.promptslist.BloggingPromptsListActivity
@@ -39,6 +40,7 @@ import org.wordpress.android.ui.reader.ReaderSubsActivity
3940
import org.wordpress.android.ui.selfhostedusers.SelfHostedUsersActivity
4041
import org.wordpress.android.ui.sitemonitor.SiteMonitorParentActivity
4142
import org.wordpress.android.ui.subscribers.SubscribersActivity
43+
import org.wordpress.android.ui.prefs.AppPrefs
4244
import org.wordpress.android.ui.taxonomies.TermsDataViewActivity
4345

4446
/**
@@ -49,6 +51,13 @@ open class BaseAppCompatActivity : AppCompatActivity() {
4951
@Override
5052
override fun onCreate(savedInstanceState: Bundle?) {
5153
super.onCreate(savedInstanceState)
54+
55+
// Disable animations for e-ink devices
56+
if (AppPrefs.isEinkModeEnabled()) {
57+
window.setWindowAnimations(0)
58+
disableActivityTransition()
59+
}
60+
5261
// apply insets for Android 15+ edge-to-edge
5362
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
5463
!isExcludedActivity(this)
@@ -57,6 +66,23 @@ open class BaseAppCompatActivity : AppCompatActivity() {
5766
}
5867
}
5968

69+
override fun finish() {
70+
super.finish()
71+
if (AppPrefs.isEinkModeEnabled()) {
72+
disableActivityTransition()
73+
}
74+
}
75+
76+
@Suppress("DEPRECATION")
77+
private fun disableActivityTransition() {
78+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
79+
overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, 0, 0)
80+
overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
81+
} else {
82+
overridePendingTransition(0, 0)
83+
}
84+
}
85+
6086
@RequiresApi(Build.VERSION_CODES.R)
6187
private fun applyInsetOffsets() {
6288
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, insets ->
@@ -89,6 +115,7 @@ private val excludedActivities = listOf(
89115
BlazeCampaignParentActivity::class.java.name,
90116
BloggingPromptsListActivity::class.java.name,
91117
DebugSharedPreferenceFlagsActivity::class.java.name,
118+
DeviceInfoActivity::class.java.name,
92119
DomainManagementActivity::class.java.name,
93120
EditJetpackSocialShareMessageActivity::class.java.name,
94121
ExperimentalFeaturesActivity::class.java.name,

WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import com.google.android.gms.common.ConnectionResult;
2929
import com.google.android.gms.common.GoogleApiAvailability;
30+
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
3031
import com.google.android.material.floatingactionbutton.FloatingActionButton;
3132
import com.google.android.material.snackbar.Snackbar;
3233
import com.google.android.play.core.install.model.AppUpdateType;
@@ -135,6 +136,7 @@
135136
import org.wordpress.android.util.AniUtils;
136137
import org.wordpress.android.util.AppLog;
137138
import org.wordpress.android.util.AppLog.T;
139+
import org.wordpress.android.util.EinkDeviceDetector;
138140
import org.wordpress.android.util.AuthenticationDialogUtils;
139141
import org.wordpress.android.util.BuildConfigWrapper;
140142
import org.wordpress.android.util.DeviceUtils;
@@ -410,6 +412,8 @@ && getIntent().getExtras().getBoolean(ARG_CONTINUE_JETPACK_CONNECT, false)) {
410412
checkTrackAnalyticsEvent();
411413
}
412414

415+
showEinkPromptIfNeeded();
416+
413417
// Ensure deep linking activities are enabled.They may have been disabled elsewhere and failed to get re-enabled
414418
enableDeepLinkingComponentsIfNeeded();
415419

@@ -549,6 +553,39 @@ private void showBloggingPromptsOnboarding() {
549553
);
550554
}
551555

556+
private void showEinkPromptIfNeeded() {
557+
if (AppPrefs.isEinkAutoDetectDone()
558+
|| !EinkDeviceDetector.INSTANCE.isEinkDevice()) {
559+
return;
560+
}
561+
AnalyticsTracker.track(Stat.EINK_PROMPT_SHOWN);
562+
new MaterialAlertDialogBuilder(this)
563+
.setTitle(R.string.eink_prompt_title)
564+
.setMessage(R.string.eink_prompt_message)
565+
.setCancelable(false)
566+
.setPositiveButton(R.string.eink_prompt_enable, (dialog, which) -> {
567+
AppPrefs.setEinkAutoDetectDone(true);
568+
AppPrefs.setEinkModeEnabled(true);
569+
AnalyticsTracker.track(Stat.EINK_PROMPT_ACCEPTED);
570+
Toast.makeText(
571+
this,
572+
R.string.eink_enabled_message,
573+
Toast.LENGTH_LONG
574+
).show();
575+
recreate();
576+
})
577+
.setNegativeButton(R.string.eink_prompt_no_thanks, (dialog, which) -> {
578+
AppPrefs.setEinkAutoDetectDone(true);
579+
AnalyticsTracker.track(Stat.EINK_PROMPT_DISMISSED);
580+
Toast.makeText(
581+
this,
582+
R.string.eink_declined_message,
583+
Toast.LENGTH_LONG
584+
).show();
585+
})
586+
.show();
587+
}
588+
552589
private void checkDismissNotification() {
553590
final Intent intent = getIntent();
554591
if (intent != null && intent.hasExtra(ARG_DISMISS_NOTIFICATION)) {

WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,12 @@ public enum UndeletablePrefKey implements PrefKey {
309309
// These preferences persist across logout/login cycles.
310310
IS_TRACK_NETWORK_REQUESTS_ENABLED,
311311
TRACK_NETWORK_REQUESTS_RETENTION_PERIOD,
312+
313+
// Indicates if e-ink mode is enabled (grayscale theme, no animations)
314+
EINK_MODE_ENABLED,
315+
316+
// Indicates if e-ink auto-detection has already run on this device
317+
EINK_AUTO_DETECT_DONE,
312318
}
313319

314320
static SharedPreferences prefs() {
@@ -1817,4 +1823,20 @@ public static int getTrackNetworkRequestsRetentionPeriod() {
18171823
public static void setTrackNetworkRequestsRetentionPeriod(int period) {
18181824
setInt(UndeletablePrefKey.TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, period);
18191825
}
1826+
1827+
public static boolean isEinkModeEnabled() {
1828+
return getBoolean(UndeletablePrefKey.EINK_MODE_ENABLED, false);
1829+
}
1830+
1831+
public static void setEinkModeEnabled(boolean enabled) {
1832+
setBoolean(UndeletablePrefKey.EINK_MODE_ENABLED, enabled);
1833+
}
1834+
1835+
public static boolean isEinkAutoDetectDone() {
1836+
return getBoolean(UndeletablePrefKey.EINK_AUTO_DETECT_DONE, false);
1837+
}
1838+
1839+
public static void setEinkAutoDetectDone(boolean done) {
1840+
setBoolean(UndeletablePrefKey.EINK_AUTO_DETECT_DONE, done);
1841+
}
18201842
}

WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,14 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra
513513
get() = AppPrefs.getTrackNetworkRequestsRetentionPeriod()
514514
set(value) = AppPrefs.setTrackNetworkRequestsRetentionPeriod(value)
515515

516+
var isEinkModeEnabled: Boolean
517+
get() = AppPrefs.isEinkModeEnabled()
518+
set(enabled) = AppPrefs.setEinkModeEnabled(enabled)
519+
520+
var isEinkAutoDetectDone: Boolean
521+
get() = AppPrefs.isEinkAutoDetectDone()
522+
set(done) = AppPrefs.setEinkAutoDetectDone(done)
523+
516524
companion object {
517525
private const val LIGHT_MODE_ID = 0
518526
private const val DARK_MODE_ID = 1

WordPress/src/main/java/org/wordpress/android/ui/prefs/AppSettingsFragment.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ public class AppSettingsFragment extends PreferenceFragment
8080
private WPPreference mLanguagePreference;
8181
private ListPreference mAppThemePreference;
8282
private ListPreference mInitialScreenPreference;
83+
private WPSwitchPreference mEinkModePref;
8384

8485
// This Device settings
8586
private WPSwitchPreference mOptimizedImage;
@@ -142,6 +143,10 @@ public boolean onPreferenceChange(Preference preference, Object newValue) {
142143
mAppThemePreference = (ListPreference) findPreference(getString(R.string.pref_key_app_theme));
143144
mAppThemePreference.setOnPreferenceChangeListener(this);
144145

146+
mEinkModePref = (WPSwitchPreference) findPreference(getString(R.string.pref_key_eink_mode));
147+
mEinkModePref.setChecked(AppPrefs.isEinkModeEnabled());
148+
mEinkModePref.setOnPreferenceChangeListener(this);
149+
145150
findPreference(getString(R.string.pref_key_language))
146151
.setOnPreferenceClickListener(this);
147152
findPreference(getString(R.string.pref_key_device_settings))
@@ -468,6 +473,11 @@ public boolean onPreferenceChange(Preference preference, Object newValue) {
468473
.singletonMap(TRACK_STYLE, (String) newValue));
469474
// restart activity to make sure changes are applied to PreferenceScreen
470475
getActivity().recreate();
476+
} else if (preference == mEinkModePref) {
477+
AppPrefs.setEinkModeEnabled((Boolean) newValue);
478+
AnalyticsTracker.track(Stat.APP_SETTINGS_EINK_MODE_CHANGED, Collections
479+
.singletonMap(TRACK_ENABLED, newValue));
480+
getActivity().recreate();
471481
} else if (preference == mReportCrashPref) {
472482
AnalyticsTracker.track(Stat.PRIVACY_SETTINGS_REPORT_CRASHES_TOGGLED, Collections
473483
.singletonMap(TRACK_ENABLED, newValue));

WordPress/src/main/java/org/wordpress/android/ui/prefs/WPSwitchPreference.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ protected void onBindView(@NonNull View view) {
6969
} else {
7070
ViewCompat.setPaddingRelative(titleView, mStartOffset, 0, 0, 0);
7171
}
72+
7273
}
7374

7475
// style custom switch preference

0 commit comments

Comments
 (0)