diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml
index 8a5fcdd3d0f6..2296d1bac9a9 100644
--- a/WordPress/src/main/AndroidManifest.xml
+++ b/WordPress/src/main/AndroidManifest.xml
@@ -213,6 +213,11 @@
android:label="@string/help_buttons_screen_title"
android:theme="@style/WordPress.NoActionBar" />
+
+
>,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DeviceInfoScreen(onNavigateBack: () -> Unit) {
+ val context = LocalContext.current
+ val sections = buildDeviceInfoSections()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.device_info_title)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ stringResource(R.string.back)
+ )
+ }
+ },
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ ) {
+ sections.forEach { section ->
+ SectionHeader(title = section.title)
+ section.entries.forEach { (label, value) ->
+ DeviceInfoRow(label = label, value = value)
+ }
+ }
+ Button(
+ onClick = {
+ val text = sections.joinToString("\n\n") { s ->
+ s.title + "\n" + s.entries.joinToString(
+ "\n"
+ ) { "${it.first}: ${it.second}" }
+ }
+ val clipboard = context.getSystemService(
+ ClipboardManager::class.java
+ )
+ clipboard.setPrimaryClip(
+ ClipData.newPlainText(
+ context.getString(
+ R.string.device_info_title
+ ),
+ text
+ )
+ )
+ Toast.makeText(
+ context,
+ R.string.device_info_copied,
+ Toast.LENGTH_SHORT
+ ).show()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(
+ R.string.copy_to_clipboard
+ )
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(title: String) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(
+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp
+ ),
+ )
+}
+
+@Composable
+private fun buildDeviceInfoSections(): List {
+ val einkValue = if (EinkDeviceDetector.isEinkDevice()) {
+ stringResource(R.string.yes)
+ } else {
+ stringResource(R.string.no)
+ }
+ return listOf(
+ DeviceInfoSection(
+ title = stringResource(R.string.device_info_section_application),
+ entries = listOf(
+ stringResource(R.string.device_info_app_version)
+ to WordPress.versionName,
+ ),
+ ),
+ DeviceInfoSection(
+ title = stringResource(R.string.device_info_section_device),
+ entries = listOf(
+ stringResource(R.string.device_info_manufacturer)
+ to Build.MANUFACTURER,
+ stringResource(R.string.device_info_brand)
+ to Build.BRAND,
+ stringResource(R.string.device_info_model)
+ to Build.MODEL,
+ stringResource(R.string.device_info_device)
+ to Build.DEVICE,
+ stringResource(R.string.device_info_product)
+ to Build.PRODUCT,
+ stringResource(R.string.device_info_eink_detected)
+ to einkValue,
+ ),
+ ),
+ DeviceInfoSection(
+ title = stringResource(R.string.device_info_section_android),
+ entries = listOf(
+ stringResource(R.string.device_info_android_version)
+ to Build.VERSION.RELEASE,
+ stringResource(R.string.device_info_sdk_level)
+ to Build.VERSION.SDK_INT.toString(),
+ ),
+ ),
+ )
+}
+
+@Composable
+private fun DeviceInfoRow(label: String, value: String) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ HorizontalDivider()
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun DeviceInfoRowPreview() {
+ AppThemeM3 {
+ DeviceInfoRow(
+ label = "App version",
+ value = "24.5-rc-1"
+ )
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt
index af0eb05068f1..896d361e60bd 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt
@@ -131,6 +131,9 @@ class HelpActivity : BaseAppCompatActivity() {
logsButton.setOnClickListener { v ->
startActivity(Intent(v.context, AppLogViewerActivity::class.java))
}
+ deviceInfoButton.setOnClickListener { v ->
+ startActivity(Intent(v.context, DeviceInfoActivity::class.java))
+ }
if (originFromExtras == Origin.JETPACK_MIGRATION_HELP) {
configureForJetpackMigrationHelp()
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppThemeM3.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppThemeM3.kt
index ecd4c1daa128..065d48442419 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppThemeM3.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppThemeM3.kt
@@ -12,7 +12,9 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalInspectionMode
import org.wordpress.android.BuildConfig
+import org.wordpress.android.ui.prefs.AppPrefs
private val localColors = staticCompositionLocalOf { extraPaletteJPLight }
@@ -20,9 +22,10 @@ private val localColors = staticCompositionLocalOf { extraPaletteJPLight }
fun AppThemeM3(
isDarkTheme: Boolean = isSystemInDarkTheme(),
isJetpackApp: Boolean = BuildConfig.IS_JETPACK_APP,
+ isEinkMode: Boolean = if (LocalInspectionMode.current) false else AppPrefs.isEinkModeEnabled(),
content: @Composable () -> Unit
) {
- AppThemeM3WithoutBackground(isDarkTheme, isJetpackApp) {
+ AppThemeM3WithoutBackground(isDarkTheme, isJetpackApp, isEinkMode) {
ContentInSurfaceM3(content)
}
}
@@ -31,17 +34,21 @@ fun AppThemeM3(
fun AppThemeM3WithoutBackground(
isDarkTheme: Boolean = isSystemInDarkTheme(),
isJetpackApp: Boolean = BuildConfig.IS_JETPACK_APP,
+ isEinkMode: Boolean = if (LocalInspectionMode.current) false else AppPrefs.isEinkModeEnabled(),
content: @Composable () -> Unit
) {
+ val effectiveIsDark = if (isEinkMode) false else isDarkTheme
val extraColors = getExtraColors(
- isDarkTheme = isDarkTheme,
- isJetpackApp = isJetpackApp
+ isDarkTheme = effectiveIsDark,
+ isJetpackApp = isJetpackApp,
+ isEinkMode = isEinkMode,
)
CompositionLocalProvider(localColors provides extraColors) {
MaterialTheme(
colorScheme = getColorScheme(
- isDarkTheme = isDarkTheme,
- isJetpackApp = isJetpackApp
+ isDarkTheme = effectiveIsDark,
+ isJetpackApp = isJetpackApp,
+ isEinkMode = isEinkMode,
),
content = content
)
@@ -49,17 +56,24 @@ fun AppThemeM3WithoutBackground(
}
/**
- * This theme should *only* be used in the context of the Editor (e.g. Post Settings).
+ * This theme should *only* be used in the context of the Editor
+ * (e.g. Post Settings).
* More info: https://github.com/wordpress-mobile/gutenberg-mobile/issues/4889
*/
@Composable
fun AppThemeM3Editor(
isDarkTheme: Boolean = isSystemInDarkTheme(),
isJetpackApp: Boolean = BuildConfig.IS_JETPACK_APP,
+ isEinkMode: Boolean = if (LocalInspectionMode.current) false else AppPrefs.isEinkModeEnabled(),
content: @Composable () -> Unit
) {
+ val effectiveIsDark = if (isEinkMode) false else isDarkTheme
androidx.compose.material3.MaterialTheme(
- colorScheme = getColorScheme(isDarkTheme = isDarkTheme, isJetpackApp = isJetpackApp),
+ colorScheme = getColorScheme(
+ isDarkTheme = effectiveIsDark,
+ isJetpackApp = isJetpackApp,
+ isEinkMode = isEinkMode,
+ ),
content = content
)
}
@@ -70,8 +84,11 @@ fun AppThemeM3Editor(
@Suppress("SameParameterValue")
private fun getColorScheme(
isDarkTheme: Boolean,
- isJetpackApp: Boolean
+ isJetpackApp: Boolean,
+ isEinkMode: Boolean = false,
): ColorScheme {
+ if (isEinkMode) return colorSchemeEink
+
return if (isJetpackApp) {
if (isDarkTheme) {
colorSchemeJPDark
@@ -85,6 +102,31 @@ private fun getColorScheme(
}
}
+// E-ink displays are grayscale with limited shade range (typically
+// 16 levels). Error color is the same as onSurface because there
+// aren't enough distinct shades to differentiate them — error
+// states rely on icons, borders, and layout rather than color alone.
+private val colorSchemeEink = lightColorScheme(
+ primary = AppColor.Black,
+ secondary = AppColor.Gray50,
+ background = AppColor.White,
+ surface = AppColor.White,
+ error = AppColor.Black,
+ onPrimary = AppColor.White,
+ onSecondary = AppColor.White,
+ onBackground = AppColor.Black,
+ onSurface = AppColor.Black,
+ onError = AppColor.White,
+ primaryContainer = AppColor.Gray10,
+ onPrimaryContainer = AppColor.Black,
+ secondaryContainer = AppColor.Gray10,
+ onSecondaryContainer = AppColor.Black,
+ outline = AppColor.Gray50,
+ outlineVariant = AppColor.Gray30,
+ surfaceVariant = AppColor.Gray10,
+ onSurfaceVariant = AppColor.Black,
+)
+
private val colorSchemeJPLight = lightColorScheme(
primary = AppColor.JetpackGreen50,
secondary = AppColor.JetpackGreen30,
@@ -142,8 +184,11 @@ private val colorSchemeWPDark = darkColorScheme(
@Suppress("SameParameterValue")
private fun getExtraColors(
isDarkTheme: Boolean,
- isJetpackApp: Boolean
+ isJetpackApp: Boolean,
+ isEinkMode: Boolean = false,
): ExtraColors {
+ if (isEinkMode) return extraPaletteEink
+
return if (isJetpackApp) {
if (isDarkTheme) {
extraPaletteJPDark
@@ -157,6 +202,13 @@ private fun getExtraColors(
}
}
+private val extraPaletteEink = ExtraColors(
+ success = AppColor.Black,
+ warning = AppColor.Gray50,
+ neutral = AppColor.Gray50,
+ ghost = AppColor.Black,
+)
+
private val extraPaletteJPLight = ExtraColors(
success = AppColor.JetpackGreen50,
warning = AppColor.Orange50,
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt
index f868b4135e12..465a98216935 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt
@@ -12,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.wordpress.android.support.SupportWebViewActivity
+import org.wordpress.android.ui.accounts.DeviceInfoActivity
import org.wordpress.android.ui.accounts.applicationpassword.ApplicationPasswordsListActivity
import org.wordpress.android.ui.blaze.blazecampaigns.BlazeCampaignParentActivity
import org.wordpress.android.ui.bloggingprompts.promptslist.BloggingPromptsListActivity
@@ -39,6 +40,7 @@ import org.wordpress.android.ui.reader.ReaderSubsActivity
import org.wordpress.android.ui.selfhostedusers.SelfHostedUsersActivity
import org.wordpress.android.ui.sitemonitor.SiteMonitorParentActivity
import org.wordpress.android.ui.subscribers.SubscribersActivity
+import org.wordpress.android.ui.prefs.AppPrefs
import org.wordpress.android.ui.taxonomies.TermsDataViewActivity
/**
@@ -49,6 +51,13 @@ open class BaseAppCompatActivity : AppCompatActivity() {
@Override
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ // Disable animations for e-ink devices
+ if (AppPrefs.isEinkModeEnabled()) {
+ window.setWindowAnimations(0)
+ disableActivityTransition()
+ }
+
// apply insets for Android 15+ edge-to-edge
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
!isExcludedActivity(this)
@@ -57,6 +66,23 @@ open class BaseAppCompatActivity : AppCompatActivity() {
}
}
+ override fun finish() {
+ super.finish()
+ if (AppPrefs.isEinkModeEnabled()) {
+ disableActivityTransition()
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun disableActivityTransition() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, 0, 0)
+ overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
+ } else {
+ overridePendingTransition(0, 0)
+ }
+ }
+
@RequiresApi(Build.VERSION_CODES.R)
private fun applyInsetOffsets() {
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, insets ->
@@ -89,6 +115,7 @@ private val excludedActivities = listOf(
BlazeCampaignParentActivity::class.java.name,
BloggingPromptsListActivity::class.java.name,
DebugSharedPreferenceFlagsActivity::class.java.name,
+ DeviceInfoActivity::class.java.name,
DomainManagementActivity::class.java.name,
EditJetpackSocialShareMessageActivity::class.java.name,
ExperimentalFeaturesActivity::class.java.name,
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
index 0e040cde4f00..7c50dec3a6f8 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
@@ -27,6 +27,7 @@
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import com.google.android.play.core.install.model.AppUpdateType;
@@ -135,6 +136,7 @@
import org.wordpress.android.util.AniUtils;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
+import org.wordpress.android.util.EinkDeviceDetector;
import org.wordpress.android.util.AuthenticationDialogUtils;
import org.wordpress.android.util.BuildConfigWrapper;
import org.wordpress.android.util.DeviceUtils;
@@ -410,6 +412,8 @@ && getIntent().getExtras().getBoolean(ARG_CONTINUE_JETPACK_CONNECT, false)) {
checkTrackAnalyticsEvent();
}
+ showEinkPromptIfNeeded();
+
// Ensure deep linking activities are enabled.They may have been disabled elsewhere and failed to get re-enabled
enableDeepLinkingComponentsIfNeeded();
@@ -549,6 +553,39 @@ private void showBloggingPromptsOnboarding() {
);
}
+ private void showEinkPromptIfNeeded() {
+ if (AppPrefs.isEinkAutoDetectDone()
+ || !EinkDeviceDetector.INSTANCE.isEinkDevice()) {
+ return;
+ }
+ AnalyticsTracker.track(Stat.EINK_PROMPT_SHOWN);
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.eink_prompt_title)
+ .setMessage(R.string.eink_prompt_message)
+ .setCancelable(false)
+ .setPositiveButton(R.string.eink_prompt_enable, (dialog, which) -> {
+ AppPrefs.setEinkAutoDetectDone(true);
+ AppPrefs.setEinkModeEnabled(true);
+ AnalyticsTracker.track(Stat.EINK_PROMPT_ACCEPTED);
+ Toast.makeText(
+ this,
+ R.string.eink_enabled_message,
+ Toast.LENGTH_LONG
+ ).show();
+ recreate();
+ })
+ .setNegativeButton(R.string.eink_prompt_no_thanks, (dialog, which) -> {
+ AppPrefs.setEinkAutoDetectDone(true);
+ AnalyticsTracker.track(Stat.EINK_PROMPT_DISMISSED);
+ Toast.makeText(
+ this,
+ R.string.eink_declined_message,
+ Toast.LENGTH_LONG
+ ).show();
+ })
+ .show();
+ }
+
private void checkDismissNotification() {
final Intent intent = getIntent();
if (intent != null && intent.hasExtra(ARG_DISMISS_NOTIFICATION)) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
index 967107aec596..fe8345355d9b 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
@@ -309,6 +309,12 @@ public enum UndeletablePrefKey implements PrefKey {
// These preferences persist across logout/login cycles.
IS_TRACK_NETWORK_REQUESTS_ENABLED,
TRACK_NETWORK_REQUESTS_RETENTION_PERIOD,
+
+ // Indicates if e-ink mode is enabled (grayscale theme, no animations)
+ EINK_MODE_ENABLED,
+
+ // Indicates if e-ink auto-detection has already run on this device
+ EINK_AUTO_DETECT_DONE,
}
static SharedPreferences prefs() {
@@ -1817,4 +1823,20 @@ public static int getTrackNetworkRequestsRetentionPeriod() {
public static void setTrackNetworkRequestsRetentionPeriod(int period) {
setInt(UndeletablePrefKey.TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, period);
}
+
+ public static boolean isEinkModeEnabled() {
+ return getBoolean(UndeletablePrefKey.EINK_MODE_ENABLED, false);
+ }
+
+ public static void setEinkModeEnabled(boolean enabled) {
+ setBoolean(UndeletablePrefKey.EINK_MODE_ENABLED, enabled);
+ }
+
+ public static boolean isEinkAutoDetectDone() {
+ return getBoolean(UndeletablePrefKey.EINK_AUTO_DETECT_DONE, false);
+ }
+
+ public static void setEinkAutoDetectDone(boolean done) {
+ setBoolean(UndeletablePrefKey.EINK_AUTO_DETECT_DONE, done);
+ }
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
index 2706800222e8..0b75beb47231 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
@@ -513,6 +513,14 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra
get() = AppPrefs.getTrackNetworkRequestsRetentionPeriod()
set(value) = AppPrefs.setTrackNetworkRequestsRetentionPeriod(value)
+ var isEinkModeEnabled: Boolean
+ get() = AppPrefs.isEinkModeEnabled()
+ set(enabled) = AppPrefs.setEinkModeEnabled(enabled)
+
+ var isEinkAutoDetectDone: Boolean
+ get() = AppPrefs.isEinkAutoDetectDone()
+ set(done) = AppPrefs.setEinkAutoDetectDone(done)
+
companion object {
private const val LIGHT_MODE_ID = 0
private const val DARK_MODE_ID = 1
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppSettingsFragment.java
index 2bc319f1c6c2..57c45bfeec94 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppSettingsFragment.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppSettingsFragment.java
@@ -80,6 +80,7 @@ public class AppSettingsFragment extends PreferenceFragment
private WPPreference mLanguagePreference;
private ListPreference mAppThemePreference;
private ListPreference mInitialScreenPreference;
+ private WPSwitchPreference mEinkModePref;
// This Device settings
private WPSwitchPreference mOptimizedImage;
@@ -142,6 +143,10 @@ public boolean onPreferenceChange(Preference preference, Object newValue) {
mAppThemePreference = (ListPreference) findPreference(getString(R.string.pref_key_app_theme));
mAppThemePreference.setOnPreferenceChangeListener(this);
+ mEinkModePref = (WPSwitchPreference) findPreference(getString(R.string.pref_key_eink_mode));
+ mEinkModePref.setChecked(AppPrefs.isEinkModeEnabled());
+ mEinkModePref.setOnPreferenceChangeListener(this);
+
findPreference(getString(R.string.pref_key_language))
.setOnPreferenceClickListener(this);
findPreference(getString(R.string.pref_key_device_settings))
@@ -468,6 +473,11 @@ public boolean onPreferenceChange(Preference preference, Object newValue) {
.singletonMap(TRACK_STYLE, (String) newValue));
// restart activity to make sure changes are applied to PreferenceScreen
getActivity().recreate();
+ } else if (preference == mEinkModePref) {
+ AppPrefs.setEinkModeEnabled((Boolean) newValue);
+ AnalyticsTracker.track(Stat.APP_SETTINGS_EINK_MODE_CHANGED, Collections
+ .singletonMap(TRACK_ENABLED, newValue));
+ getActivity().recreate();
} else if (preference == mReportCrashPref) {
AnalyticsTracker.track(Stat.PRIVACY_SETTINGS_REPORT_CRASHES_TOGGLED, Collections
.singletonMap(TRACK_ENABLED, newValue));
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPSwitchPreference.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPSwitchPreference.java
index 38deb75e8337..092a1f4c1903 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPSwitchPreference.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPSwitchPreference.java
@@ -69,6 +69,7 @@ protected void onBindView(@NonNull View view) {
} else {
ViewCompat.setPaddingRelative(titleView, mStartOffset, 0, 0, 0);
}
+
}
// style custom switch preference
diff --git a/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java b/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java
index c2c8e609f9f9..535967876a09 100644
--- a/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java
+++ b/WordPress/src/main/java/org/wordpress/android/util/AniUtils.java
@@ -15,6 +15,8 @@
import android.view.animation.LinearInterpolator;
import android.view.animation.TranslateAnimation;
+import org.wordpress.android.ui.prefs.AppPrefs;
+
public class AniUtils {
public enum Duration {
SHORT,
@@ -41,6 +43,16 @@ private AniUtils() {
throw new AssertionError();
}
+ private static boolean isAnimationDisabled() {
+ return AppPrefs.isEinkModeEnabled();
+ }
+
+ private static ObjectAnimator noopAnimator(View target) {
+ ObjectAnimator noop = ObjectAnimator.ofFloat(target, View.ALPHA, target.getAlpha());
+ noop.setDuration(0);
+ return noop;
+ }
+
public static void startAnimation(View target, int aniResId) {
startAnimation(target, aniResId, null);
}
@@ -49,6 +61,12 @@ public static void startAnimation(View target, int aniResId, AnimationListener l
if (target == null) {
return;
}
+ if (isAnimationDisabled()) {
+ if (listener != null) {
+ listener.onAnimationEnd(null);
+ }
+ return;
+ }
Animation animation = AnimationUtils.loadAnimation(target.getContext(), aniResId);
if (animation != null) {
@@ -82,6 +100,10 @@ private static void animateBar(View view,
if (view == null || view.getVisibility() == newVisibility) {
return;
}
+ if (isAnimationDisabled()) {
+ view.setVisibility(newVisibility);
+ return;
+ }
float fromY;
float toY;
@@ -113,6 +135,11 @@ private static void animateBar(View view,
}
public static ObjectAnimator getFadeInAnim(final View target, Duration duration) {
+ if (isAnimationDisabled()) {
+ target.setAlpha(1.0f);
+ target.setVisibility(View.VISIBLE);
+ return noopAnimator(target);
+ }
ObjectAnimator fadeIn = ObjectAnimator.ofFloat(target, View.ALPHA, 0.0f, 1.0f);
fadeIn.setDuration(duration.toMillis(target.getContext()));
fadeIn.setInterpolator(new LinearInterpolator());
@@ -126,6 +153,11 @@ public void onAnimationStart(Animator animation) {
}
public static ObjectAnimator getFadeOutAnim(final View target, Duration duration, final int endVisibility) {
+ if (isAnimationDisabled()) {
+ target.setAlpha(0.0f);
+ target.setVisibility(endVisibility);
+ return noopAnimator(target);
+ }
ObjectAnimator fadeOut = ObjectAnimator.ofFloat(target, View.ALPHA, 1.0f, 0.0f);
fadeOut.setDuration(duration.toMillis(target.getContext()));
fadeOut.setInterpolator(new LinearInterpolator());
@@ -139,9 +171,13 @@ public void onAnimationEnd(Animator animation) {
}
public static void fadeIn(final View target, Duration duration) {
- if (target != null && duration != null) {
- getFadeInAnim(target, duration).start();
+ if (target == null || duration == null) return;
+ if (isAnimationDisabled()) {
+ target.setAlpha(1.0f);
+ target.setVisibility(View.VISIBLE);
+ return;
}
+ getFadeInAnim(target, duration).start();
}
public static void fadeOut(final View target, Duration duration) {
@@ -149,15 +185,24 @@ public static void fadeOut(final View target, Duration duration) {
}
public static void fadeOut(final View target, Duration duration, int endVisibility) {
- if (target != null && duration != null) {
- getFadeOutAnim(target, duration, endVisibility).start();
+ if (target == null || duration == null) return;
+ if (isAnimationDisabled()) {
+ target.setAlpha(0.0f);
+ target.setVisibility(endVisibility);
+ return;
}
+ getFadeOutAnim(target, duration, endVisibility).start();
}
public static void scale(final View target, float scaleStart, float scaleEnd, Duration duration) {
if (target == null || duration == null) {
return;
}
+ if (isAnimationDisabled()) {
+ target.setScaleX(scaleEnd);
+ target.setScaleY(scaleEnd);
+ return;
+ }
PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, scaleStart, scaleEnd);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleStart, scaleEnd);
@@ -173,6 +218,12 @@ public static void scaleIn(final View target, Duration duration) {
if (target == null || duration == null) {
return;
}
+ if (isAnimationDisabled()) {
+ target.setScaleX(1f);
+ target.setScaleY(1f);
+ target.setVisibility(View.VISIBLE);
+ return;
+ }
PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0f, 1f);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0f, 1f);
@@ -202,6 +253,13 @@ public static void scaleOut(final View target,
if (target == null || duration == null) {
return;
}
+ if (isAnimationDisabled()) {
+ target.setVisibility(endVisibility);
+ if (endListener != null) {
+ endListener.onAnimationEnd();
+ }
+ return;
+ }
PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0f);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0f);
diff --git a/WordPress/src/main/java/org/wordpress/android/util/AppThemeUtils.kt b/WordPress/src/main/java/org/wordpress/android/util/AppThemeUtils.kt
index f5ba782b0544..711f487cdf31 100644
--- a/WordPress/src/main/java/org/wordpress/android/util/AppThemeUtils.kt
+++ b/WordPress/src/main/java/org/wordpress/android/util/AppThemeUtils.kt
@@ -6,6 +6,7 @@ import android.text.TextUtils
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager
import org.wordpress.android.R
+import org.wordpress.android.ui.prefs.AppPrefs
class AppThemeUtils {
companion object {
@@ -13,6 +14,14 @@ class AppThemeUtils {
@JvmStatic
@JvmOverloads
fun setAppTheme(context: Context, newTheme: String? = null) {
+ // E-ink mode always forces light theme
+ if (AppPrefs.isEinkModeEnabled()) {
+ AppCompatDelegate.setDefaultNightMode(
+ AppCompatDelegate.MODE_NIGHT_NO
+ )
+ return
+ }
+
val themeName = if (TextUtils.isEmpty(newTheme)) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
sharedPreferences
diff --git a/WordPress/src/main/java/org/wordpress/android/util/EinkDeviceDetector.kt b/WordPress/src/main/java/org/wordpress/android/util/EinkDeviceDetector.kt
new file mode 100644
index 000000000000..d8f7243e57f3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/util/EinkDeviceDetector.kt
@@ -0,0 +1,93 @@
+package org.wordpress.android.util
+
+import android.os.Build
+
+/**
+ * Detects whether the current device is an e-ink device by checking
+ * Build properties against a list of known e-ink manufacturers, brands,
+ * and model patterns. The device list is sourced from KOReader's
+ * DeviceInfo.kt (https://github.com/koreader/android-luajit-launcher).
+ */
+object EinkDeviceDetector {
+ private val EINK_MANUFACTURERS = setOf(
+ "onyx",
+ "boyue",
+ "pocketbook",
+ "bigme",
+ "hanvon",
+ "hyread",
+ "dasung",
+ "topjoy",
+ "moan",
+ "artatech", // InkBook
+ "energy sistem",
+ "crema",
+ "tolino",
+ "viwoods",
+ "supernote",
+ "ratta", // Supernote parent company
+ "meebook",
+ "haoqing", // Meebook manufacturer
+ "barnesandnoble",
+ "remarkable",
+ )
+
+ private val EINK_BRANDS = setOf(
+ "onyx",
+ "boox",
+ "boyue",
+ "pocketbook",
+ "kobo",
+ "bigme",
+ "hanvon",
+ "hyread",
+ "dasung",
+ "topjoy",
+ "moan",
+ "likebook",
+ "tolino",
+ "viwoods",
+ "aipaper reader",
+ "supernote",
+ "meebook",
+ "inkbook",
+ "nook",
+ )
+
+ // Model patterns for dual-use manufacturers that also make
+ // non-e-ink devices. Paired with their manufacturer to
+ // prevent false positives (e.g. Huawei Nova matching "nova").
+ //
+ // Note: Amazon Kindle e-readers run custom Linux, not Android,
+ // so they can't install this app. The "kindle" pattern here is
+ // defensive — Fire tablets (Android) use "KF*" model codes and
+ // won't match.
+ private val EINK_MODELS_BY_MANUFACTURER = mapOf(
+ "amazon" to setOf("kindle"),
+ "hisense" to setOf("a5pro", "a5 pro", "a7cc"),
+ "xiaomi" to setOf("xiaomi_reader"),
+ )
+
+ fun isEinkDevice(): Boolean = isEinkDevice(
+ manufacturer = Build.MANUFACTURER,
+ brand = Build.BRAND,
+ model = Build.MODEL,
+ )
+
+ internal fun isEinkDevice(
+ manufacturer: String,
+ brand: String,
+ model: String,
+ ): Boolean {
+ val mfr = manufacturer.lowercase().trim()
+ val br = brand.lowercase().trim()
+ val mdl = model.lowercase().trim()
+
+ if (mfr in EINK_MANUFACTURERS) return true
+ if (br in EINK_BRANDS) return true
+
+ val patterns = EINK_MODELS_BY_MANUFACTURER[mfr]
+ ?: return false
+ return patterns.any { mdl.contains(it) }
+ }
+}
diff --git a/WordPress/src/main/res/layout/help_activity.xml b/WordPress/src/main/res/layout/help_activity.xml
index 7469bef0e6ff..f63d6b25837f 100644
--- a/WordPress/src/main/res/layout/help_activity.xml
+++ b/WordPress/src/main/res/layout/help_activity.xml
@@ -275,6 +275,11 @@
style="@style/HelpActivitySingleText"
android:text="@string/logs_button" />
+
+
wp_pref_app_experimental_section
wp_pref_language
wp_pref_app_theme
+ wp_pref_eink_mode
wp_pref_whats_new
wp_pref_taxonomies
wp_pref_notification_blogs
diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml
index 6b2bdd01205d..ae8823ee0fc3 100644
--- a/WordPress/src/main/res/values/strings.xml
+++ b/WordPress/src/main/res/values/strings.xml
@@ -963,6 +963,16 @@
Current language:
+
+ E-ink mode (Beta)
+ Optimizes the display for e-ink screens with a grayscale theme and no animations
+ E-ink display detected
+ It looks like you might be using an e-reader. Would you like to enable e-ink mode? This optimizes the app with a grayscale theme and no animations.
+ Enable
+ No thanks
+ E-ink mode enabled. You can turn it off in Me \u2192 App Settings.
+ No problem! You can enable e-ink mode anytime in Me \u2192 App Settings.
+
Experimental Features
Disable experimental block editor
@@ -2139,6 +2149,21 @@
Version %s
+ Device Info
+ Manufacturer
+ Brand
+ Model
+ Device
+ Product
+ Android Version
+ SDK Level
+ Matches known E-reader list
+ App Version
+ Device info copied to clipboard
+ Copy to Clipboard
+ Application
+ Device
+ Android
Version conflict
diff --git a/WordPress/src/main/res/xml/app_settings.xml b/WordPress/src/main/res/xml/app_settings.xml
index 87c72b1fdfee..e5a55de9a2c2 100644
--- a/WordPress/src/main/res/xml/app_settings.xml
+++ b/WordPress/src/main/res/xml/app_settings.xml
@@ -17,6 +17,12 @@
android:summary="%s"
android:title="@string/app_theme" />
+
+
+ assertThat(device.isDetectedAsEink())
+ .describedAs(device.label())
+ .isTrue()
+ }
+ }
+
+ @Test
+ fun `non-e-ink devices are not detected`() {
+ val nonEinkDevices = listOf(
+ // Amazon Fire tablets (Android) use KF* model codes
+ Device("Amazon", "Amazon", "Fire HD 10"),
+ Device("Amazon", "Amazon", "KFTRWI"),
+ Device("Amazon", "Amazon", "KFRAPWI"),
+ Device("Hisense", "Hisense", "H60 5G"),
+ Device("Xiaomi", "Xiaomi", "Redmi Note 12"),
+ Device("HUAWEI", "HUAWEI", "Nova 12 Pro"),
+ Device("samsung", "samsung", "SM-S928B"),
+ Device("Google", "google", "Pixel 9 Pro"),
+ Device("SomeOEM", "SomeBrand", "PagePlus Pro"),
+ Device("SomeOEM", "SomeBrand", "Vision X1"),
+ Device("SomeOEM", "SomeBrand", "Era 5G"),
+ Device("", "", ""),
+ )
+
+ nonEinkDevices.forEach { device ->
+ assertThat(device.isDetectedAsEink())
+ .describedAs(device.label())
+ .isFalse()
+ }
+ }
+
+ private data class Device(
+ val manufacturer: String,
+ val brand: String,
+ val model: String,
+ ) {
+ fun isDetectedAsEink(): Boolean =
+ EinkDeviceDetector.isEinkDevice(manufacturer, brand, model)
+
+ fun label(): String =
+ "$manufacturer / $brand / $model"
+ }
+}
diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java
index 6ecaef5b2a47..d844530ede91 100644
--- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java
+++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java
@@ -881,6 +881,10 @@ public enum Stat {
SITE_SWITCHER_DISMISSED,
SETTINGS_DID_CHANGE,
APP_SETTINGS_APPEARANCE_CHANGED,
+ APP_SETTINGS_EINK_MODE_CHANGED,
+ EINK_PROMPT_SHOWN,
+ EINK_PROMPT_ACCEPTED,
+ EINK_PROMPT_DISMISSED,
APP_SETTINGS_PRIVACY_SETTINGS_TAPPED,
APP_SETTINGS_OPEN_DEVICE_SETTINGS_TAPPED,
APP_SETTINGS_MAX_IMAGE_SIZE_CHANGED,