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,