Skip to content
Merged
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ The result is an Android reader that sits somewhere between an ebook app, a spee
### Personalization

- Reader font size and text brightness controls
- Multiple reader themes including Light, Sepia, Dark, Nord, Cyberpunk, and Forest
- App-wide themes including Light, Sepia, Dark, Nord, Cyberpunk, and Forest
- A visual theme studio with coordinated colour harmonies, gradient colour controls, and eight offline font choices
- Reader, RSVP, and interface previews using your reading settings, with visible feedback when colours are adjusted for readability
- Up to 24 named themes with colours and fonts, duplication, renaming, deletion, and undo; collection edits are saved with **Apply theme**
- Editable copies of built-in palettes, separate colour/font resets, and **Revert to applied**; custom reading contrast also accounts for highlights, selection, and focused words
- RSVP font family, font weight, brightness, and positioning controls
- Focus mode settings, including optional status bar hiding and Do Not Disturb integration
- Persistent preferences powered by DataStore
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.kairo.reader.data.preferences

import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.kairo.reader.core.model.CustomTheme
import com.kairo.reader.core.model.ReaderTheme
import com.kairo.reader.core.model.RsvpFontFamily
import com.kairo.reader.core.model.SavedTheme
import com.kairo.reader.core.model.ThemeDesign
import com.kairo.reader.core.model.encode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ThemePreferencesDeviceTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val codec = RsvpProfileJsonCodec()
private val mapper = UserPreferencesMapper(context, PrefKeys, codec, RsvpConfigPreferenceCodec(PrefKeys, codec))

@Test
fun customThemeAndAllFontChoicesLoadFromStoredPreferences() {
val theme = CustomTheme(background = 0xFF172C29.toInt(), primary = 0xFFEDC892.toInt())
RsvpFontFamily.entries.forEach { font ->
val saved = listOf(SavedTheme("one", "Reading", ThemeDesign(custom = theme, readerFont = font)))
val stored = mutablePreferencesOf(
PrefKeys.customTheme to theme.encode(),
PrefKeys.savedThemes to ThemeLibraryCodec.encode(saved),
PrefKeys.readerTheme to ReaderTheme.CUSTOM.name,
PrefKeys.readerFontFamily to font.name,
PrefKeys.interfaceFontFamily to font.name,
PrefKeys.rsvpFontFamily to font.name,
)
val loaded = mapper.map(stored)
assertEquals(theme, loaded.customTheme)
assertEquals(saved, loaded.savedThemes)
assertEquals(ReaderTheme.CUSTOM, loaded.readerTheme)
assertEquals(font, loaded.readerFontFamily)
assertEquals(font, loaded.interfaceFontFamily)
assertEquals(font, loaded.rsvpFontFamily)
}
}

@Test
fun existingAndUnknownPreferencesKeepOriginalFontsAndBuiltInTheme() {
val defaults = mapper.map(emptyPreferences())
assertEquals(RsvpFontFamily.MERRIWEATHER, defaults.readerFontFamily)
assertEquals(RsvpFontFamily.SYSTEM_SANS, defaults.interfaceFontFamily)
assertEquals(RsvpFontFamily.INTER, defaults.rsvpFontFamily)
val stored = mutablePreferencesOf(
PrefKeys.readerTheme to ReaderTheme.NORD.name,
PrefKeys.readerFontFamily to "REMOVED",
PrefKeys.interfaceFontFamily to "REMOVED",
PrefKeys.customTheme to "corrupt",
)
val loaded = mapper.map(stored)
assertEquals(ReaderTheme.NORD, loaded.readerTheme)
assertEquals(defaults.readerFontFamily, loaded.readerFontFamily)
assertEquals(defaults.interfaceFontFamily, loaded.interfaceFontFamily)
assertEquals(CustomTheme(), loaded.customTheme)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.kairo.reader.ui.reader

import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.text.TextLayoutResult
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.TestActivity
import com.kairo.reader.core.model.BookId
import com.kairo.reader.core.model.CustomTheme
import com.kairo.reader.core.model.HighlightColor
import com.kairo.reader.core.model.ReaderTheme
import com.kairo.reader.core.model.SavedAnnotation
import com.kairo.reader.core.model.SavedAnnotationKind
import com.kairo.reader.core.model.TimedReadingMode
import com.kairo.reader.core.model.Token
import com.kairo.reader.core.model.TokenType
import com.kairo.reader.ui.saved.displayColor
import com.kairo.reader.ui.theme.KairoTheme
import com.kairo.reader.ui.theme.colorContrast
import com.kairo.reader.ui.theme.materialColorScheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ReaderThemeContrastDeviceTest {
@get:Rule val rule = createAndroidComposeRule<TestActivity>()

@Test
fun renderedFocusedLinkWithOverlappingHighlightsUsesProtectedText() {
val custom = CustomTheme(background = 0xFF747474.toInt())
val saved = SavedAnnotation(
"test", BookId("preview"), 0, 0, 0, "Remember", "", HighlightColor.YELLOW,
SavedAnnotationKind.HIGHLIGHT, 0L, 0L
)
rule.setContent {
KairoTheme(readerTheme = ReaderTheme.CUSTOM, customTheme = custom) {
ParagraphText(
ParagraphTextState(
Paragraph(listOf(Token("Remember", TokenType.WORD, linkChapterIndex = 1)), 0),
0,
18f,
0.88f,
TimedReadingMode.RSVP,
savedAnnotations = listOf(saved),
selectionRange = 0..0,
searchMatchRange = 0..0
),
ParagraphTextActions({}, {}),
)
}
}
val results = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Remember").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(results) }
rule.runOnIdle {
val text = results.single().layoutInput.text
val foreground = text.spanStyles.last().item.color
val scheme = custom.materialColorScheme()
val background = readerSpanBackground(
scheme.background,
listOf(
HighlightColor.YELLOW.displayColor().copy(alpha = 0.18f),
scheme.tertiary.copy(alpha = 0.18f),
scheme.primary.copy(alpha = 0.22f),
scheme.primary.copy(alpha = 0.16f),
)
)
assertTrue(colorContrast(foreground, background) >= 4.5f)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ class SettingsHomeScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<TestActivity>()

@Test
fun themeRowOpensDedicatedStudio() {
var opened = false
composeRule.setContent {
KairoTheme { SettingsHomeScreen(actions = settingsHomeActions().copy(onOpenTheme = { opened = true })) }
}
composeRule.onNodeWithText(composeRule.activity.getString(R.string.theme_settings_title))
.performScrollTo().assertHasClickAction().performClick()
composeRule.runOnIdle { assertTrue(opened) }
}

@Test
fun checkForUpdatesRow_requestsUpdateCheck() {
var updateCheckRequested = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class SettingsSearchDeviceTest {
val index = buildSettingsSearchIndex(composeRule.activity.resources)
assertEquals(settingsSearchEntries.size, index.size)
assertTrue(index.all { it.title.isNotBlank() && it.description.isNotBlank() && it.location.isNotBlank() })
assertTrue(searchSettings(index, "Nord").any { it.entry.page == SettingsSearchPage.READER })
assertTrue(searchSettings(index, "Nord").any { it.entry.page == SettingsSearchPage.THEME })
}

@Test
Expand Down Expand Up @@ -86,7 +86,7 @@ class SettingsSearchDeviceTest {
composeRule.onNodeWithContentDescription(text(R.string.action_back)).performClick()
composeRule.onNodeWithText(text(target.descriptionRes)).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.settings_search_hint)).performTextReplacement("Nord")
composeRule.onNodeWithText(text(R.string.reader_theme_title)).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.theme_palette)).assertIsDisplayed()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package com.kairo.reader.ui.settings

import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.R
import com.kairo.reader.TestActivity
import com.kairo.reader.core.model.CustomTheme
import com.kairo.reader.core.model.ReaderTheme
import com.kairo.reader.core.model.RsvpFontFamily
import com.kairo.reader.core.model.SavedTheme
import com.kairo.reader.core.model.ThemeDesign
import com.kairo.reader.core.model.UserPreferences
import com.kairo.reader.ui.theme.KairoTheme
import java.io.File
import java.io.IOException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ThemeLibraryDeviceTest {
@get:Rule val rule = createAndroidComposeRule<TestActivity>()

@Test
fun savedDesignAndUndoSurviveRecreationThenApplyTogether() {
var saved: List<SavedTheme>? = null
val restoration = StateRestorationTester(rule)
restoration.setContent {
KairoTheme {
ThemeSettingsScreen(
UserPreferences(readerFontFamily = RsvpFontFamily.LORA),
onApply = { _, themes -> saved = themes },
onBack = {}
)
}
}
node(R.string.theme_save_new).performScrollTo().performClick()
node(R.string.theme_name).performTextReplacement("Evening")
node(R.string.theme_done).performClick()
restoration.emulateSavedInstanceStateRestore()
rule.onNodeWithText("Evening").performScrollTo().assertIsDisplayed()
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_manage_named, "Evening")).performClick()
node(R.string.theme_delete).performClick()
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_undo)).performClick()
rule.onNodeWithText("Evening").performScrollTo().assertIsDisplayed()
capture("theme-library")
node(R.string.theme_apply).performClick()
rule.runOnIdle {
assertEquals("Evening", saved?.single()?.name)
assertEquals(RsvpFontFamily.LORA, saved?.single()?.design?.readerFont)
}
}

@Test
fun duplicateRenameAndColourResetKeepTheOriginalAndItsFonts() {
val original = SavedTheme(
"first",
"Original",
ThemeDesign(
custom = CustomTheme(background = 0xFF172C29.toInt()),
readerFont = RsvpFontFamily.LORA
)
)
var result: List<SavedTheme>? = null
var appearance: ThemeDesign? = null
rule.setContent {
KairoTheme {
ThemeSettingsScreen(UserPreferences(savedThemes = listOf(original)), { design, saved ->
appearance = design
result = saved
}, {})
}
}
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_manage_named, "Original")).performScrollTo().performClick()
node(R.string.theme_duplicate).performClick()
node(R.string.theme_name).performTextReplacement("Second")
node(R.string.theme_done).performClick()
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_manage_named, "Second")).performScrollTo().performClick()
node(R.string.theme_rename).performClick()
node(R.string.theme_name).performTextReplacement("Renamed")
node(R.string.theme_done).performClick()
rule.onNodeWithText("Renamed").performScrollTo().performClick()
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_reset_options)).performClick()
node(R.string.theme_reset_colours).performClick()
node(R.string.theme_apply).performClick()
rule.runOnIdle {
assertEquals(listOf("Original", "Renamed"), result?.map { it.name })
assertEquals(original, result?.first())
assertEquals(CustomTheme(), appearance?.custom)
assertEquals(RsvpFontFamily.LORA, appearance?.readerFont)
}
}

@Test
fun undoRestoresColourFieldAndPreviewTabsWork() {
rule.setContent {
KairoTheme {
ThemeSettingsScreen(UserPreferences(readerTheme = ReaderTheme.CUSTOM), { _, _ -> }, {})
}
}
node(R.string.theme_hex).performScrollTo().performTextReplacement("#172C29")
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_undo)).performClick()
node(R.string.theme_hex).performScrollTo().assertTextContains("#F4EFE4")
rule.onNodeWithTag("theme-preview-RSVP").performClick()
node(R.string.theme_preview_paused).performScrollTo().assertIsDisplayed()
capture("theme-rsvp-preview")
rule.onNodeWithTag("theme-preview-INTERFACE").performScrollTo().performClick()
node(R.string.theme_preview_continue).performScrollTo().assertIsDisplayed()
capture("theme-interface-preview")
}

@Test
fun failedSaveKeepsDraftOpenAndCanBeRetried() {
var fail = true
var closed = false
var applied: ThemeDesign? = null
rule.setContent {
KairoTheme {
ThemeSettingsScreen(UserPreferences(readerTheme = ReaderTheme.CUSTOM), { design, _ ->
if (fail) throw IOException("Test storage error")
applied = design
}, { closed = true })
}
}
node(R.string.theme_hex).performScrollTo().performTextReplacement("#172C29")
node(R.string.theme_apply).performClick()
node(R.string.theme_save_error).assertIsDisplayed()
rule.runOnIdle {
assertFalse(closed)
fail = false
}
node(R.string.theme_apply).performClick()
rule.runOnIdle { assertEquals(0xFF172C29.toInt(), applied?.custom?.background) }
}

@Test
fun invalidColourCannotBeAppliedFromAnotherTabAndRevertClearsIt() {
val restoration = StateRestorationTester(rule)
restoration.setContent {
KairoTheme { ThemeSettingsScreen(UserPreferences(readerTheme = ReaderTheme.CUSTOM), { _, _ -> }, {}) }
}
node(R.string.theme_hex).performScrollTo().performTextReplacement("#12")
node(R.string.theme_tab_fonts).performClick()
restoration.emulateSavedInstanceStateRestore()
node(R.string.theme_apply).assertIsNotEnabled()
rule.onNodeWithContentDescription(rule.activity.getString(R.string.theme_reset_options)).performClick()
node(R.string.theme_revert).performClick()
node(R.string.theme_apply).assertIsEnabled()
node(R.string.theme_tab_colours).performClick()
node(R.string.theme_hex).performScrollTo().assertTextContains("#F4EFE4")
}

private fun capture(name: String) {
val bitmap = rule.onRoot().captureToImage().asAndroidBitmap()
File(rule.activity.cacheDir, "$name.png").outputStream().use {
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)
}
}

private fun node(id: Int) = rule.onNodeWithText(rule.activity.getString(id))
}
Loading