diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad851aa8..15fe6a34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -135,6 +135,7 @@ android { targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { + buildConfig = true compose = true } packaging { diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/WhatsNewDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/WhatsNewDeviceTest.kt new file mode 100644 index 00000000..69cf293b --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/WhatsNewDeviceTest.kt @@ -0,0 +1,90 @@ +package com.kairo.reader.ui.settings + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.Density +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.R +import com.kairo.reader.TestActivity +import com.kairo.reader.ui.theme.KairoTheme +import com.kairo.reader.ui.tutorial.StartingTutorialOverlay +import com.kairo.reader.ui.tutorial.StartingTutorialOverlayState +import com.kairo.reader.ui.updates.ChangelogScreen +import com.kairo.reader.ui.updates.ReleaseInvitationDialog +import com.kairo.reader.ui.updates.releaseNotes +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WhatsNewDeviceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun invitationOffersTourChangelogAndSkip() { + var action = "" + composeRule.setContent { + KairoTheme { + ReleaseInvitationDialog( + version = "1.4.0", + hasTour = true, + onSkip = { action = "skip" }, + onStartTour = { action = "tour" }, + onViewChangelog = { action = "changelog" }, + ) + } + } + composeRule.onNodeWithText(text(R.string.whats_new_take_tour)).performClick() + composeRule.runOnIdle { assertEquals("tour", action) } + composeRule.onNodeWithText(text(R.string.whats_new_view_changelog)).performClick() + composeRule.runOnIdle { assertEquals("changelog", action) } + composeRule.onNodeWithText(text(R.string.action_skip)).performClick() + composeRule.runOnIdle { assertEquals("skip", action) } + } + + @Test + fun changelogCanReplayTourAndReachOlderVersions() { + var tours = 0 + composeRule.setContent { + KairoTheme { ChangelogScreen(onBack = {}, onStartTour = { tours++ }) } + } + composeRule.onNodeWithText(text(R.string.whats_new_take_tour)).performClick() + composeRule.runOnIdle { assertEquals(1, tours) } + composeRule.onNodeWithTag("changelog").performScrollToNode( + hasText(composeRule.activity.getString(R.string.whats_new_version, "1.0.8")) + ) + } + + @Test + fun tourActionsRemainReachableAtLargeFontSize() { + var action = "" + composeRule.setContent { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale = 2f)) { + KairoTheme { + StartingTutorialOverlay( + state = StartingTutorialOverlayState(releaseNotes.first().tour.first(), 0, 4) { action = "changelog" }, + targetBounds = null, + onNext = { action = "next" }, + onPrevious = {}, + onSkip = { action = "skip" }, + ) + } + } + } + composeRule.onNodeWithText(text(R.string.whats_new_view_changelog)).performScrollTo().performClick() + composeRule.runOnIdle { assertEquals("changelog", action) } + composeRule.onNodeWithText(text(R.string.action_skip)).performScrollTo().performClick() + composeRule.runOnIdle { assertEquals("skip", action) } + } + + private fun text(id: Int) = composeRule.activity.getString(id) +} diff --git a/app/src/main/java/com/kairo/reader/core/model/Models.kt b/app/src/main/java/com/kairo/reader/core/model/Models.kt index cbe152ee..083e69ee 100644 --- a/app/src/main/java/com/kairo/reader/core/model/Models.kt +++ b/app/src/main/java/com/kairo/reader/core/model/Models.kt @@ -334,6 +334,7 @@ data class UserPreferences( val rsvpSelectedProfileId: String = RsvpProfileIds.builtIn(RsvpProfile.BALANCED), val rsvpCustomProfiles: List = emptyList(), val hasSeenStartingTutorial: Boolean = false, + val lastSeenReleaseVersionCode: Int = 0, val readerFontSizeSp: Float = 18f, val readerTheme: ReaderTheme = ReaderTheme.LIGHT, val customTheme: CustomTheme = CustomTheme(), diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt index 5ea7e814..6cc890cc 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt @@ -13,6 +13,7 @@ internal object PrefKeys { val rsvpPresetVersion = intPreferencesKey("rsvp_preset_version") val rsvpProfile = stringPreferencesKey("rsvp_profile") val customRsvpProfilesJson = stringPreferencesKey("custom_rsvp_profiles_json") + val lastSeenReleaseVersionCode = intPreferencesKey("last_seen_release_version_code") val hasSeenStartingTutorial = booleanPreferencesKey("has_seen_starting_tutorial") val minWordMs = longPreferencesKey("min_word_ms") val longWordMinMs = longPreferencesKey("long_word_min_ms") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt index c1362846..cdbbd03f 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepository.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.Flow interface PreferencesRepository { val preferences: Flow + suspend fun acknowledgeRelease(versionCode: Int) + suspend fun updateHasSeenStartingTutorial(seen: Boolean) suspend fun updateRsvpConfig(updater: (RsvpConfig) -> RsvpConfig) diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt index 71415290..10cd5000 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt @@ -201,6 +201,12 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo } } + override suspend fun acknowledgeRelease(versionCode: Int) { + context.dataStore.edit { prefs -> + prefs[keys.lastSeenReleaseVersionCode] = maxOf(prefs[keys.lastSeenReleaseVersionCode] ?: 0, versionCode) + } + } + override suspend fun updateHasSeenStartingTutorial(seen: Boolean) { context.dataStore.edit { prefs -> prefs[keys.hasSeenStartingTutorial] = seen } } @@ -443,7 +449,11 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo } override suspend fun reset() { - context.dataStore.edit { it.clear() } + context.dataStore.edit { prefs -> + val seenRelease = prefs[keys.lastSeenReleaseVersionCode] + prefs.clear() + if (seenRelease != null) prefs[keys.lastSeenReleaseVersionCode] = seenRelease + } } } diff --git a/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt b/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt index 8cffbc6b..bb9717d2 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/UserPreferencesMapper.kt @@ -56,6 +56,7 @@ internal class UserPreferencesMapper( defaults: UserPreferences, ): UserPreferences = copy( + lastSeenReleaseVersionCode = prefs[keys.lastSeenReleaseVersionCode] ?: 0, hasSeenStartingTutorial = prefs.readOrDefault( keys.hasSeenStartingTutorial, diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/KairoNavHost.kt b/app/src/main/java/com/kairo/reader/ui/navigation/KairoNavHost.kt index e5e3beba..05fc3ddb 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/KairoNavHost.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/KairoNavHost.kt @@ -11,12 +11,14 @@ import com.kairo.reader.KairoApplication import com.kairo.reader.R import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.data.books.SharedTextImport +import com.kairo.reader.ui.export.NoteExportPhase import com.kairo.reader.ui.export.NoteExportProgressDialog import com.kairo.reader.ui.export.rememberNoteExportCoordinator import com.kairo.reader.ui.importing.rememberImportCoordinator import com.kairo.reader.ui.tutorial.rememberStartingTutorialCoordinator import com.kairo.reader.ui.updates.InAppUpdateCheckResult import com.kairo.reader.ui.updates.InAppUpdateUiBindings +import com.kairo.reader.ui.updates.WhatsNewPrompt @Suppress("FunctionNaming") @Composable @@ -74,6 +76,7 @@ internal fun KairoNavHost( externalArticleUrl = externalArticleUrl, externalSharedText = externalSharedText, isImporting = importCoordinator.state.isImporting, + onViewChangelog = { navController.navigate(KairoRoutes.SETTINGS_CHANGELOG) }, ) KairoNavChrome( prefs = prefs, @@ -144,6 +147,7 @@ internal fun KairoNavHost( } }, onOpenStartingTutorial = tutorialCoordinator.start, + onOpenReleaseTour = tutorialCoordinator.startReleaseTour, onTutorialNext = tutorialCoordinator.next, onTutorialPrevious = tutorialCoordinator.previous, onTutorialSkip = tutorialCoordinator.skip, @@ -152,6 +156,21 @@ internal fun KairoNavHost( ) } } + WhatsNewPrompt( + preferences = prefs, + repository = container.preferencesRepository, + canShow = (currentRoute == KairoRoutes.LIBRARY || currentRoute == KairoRoutes.LIBRARY_WITH_TAB) && + !tutorialCoordinator.active && + !importCoordinator.state.isImporting && + externalImportUri == null && + externalArticleUrl == null && + externalSharedText == null && + inAppUpdateUi.prompt == null && + noteExportUi.state.phase == NoteExportPhase.IDLE && + noteExportUi.state.sheetOrigin == null, + onStartTour = tutorialCoordinator.startReleaseTour, + onViewChangelog = { navController.navigate(KairoRoutes.SETTINGS_CHANGELOG) }, + ) NoteExportProgressDialog( phase = noteExportUi.state.phase, onCancelAwaiting = noteExportUi.cancelPending, diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt b/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt index 01194b6a..9061d333 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/KairoRoutes.kt @@ -7,6 +7,7 @@ internal object KairoRoutes { const val LIBRARY_WITH_TAB = "library?tab={tab}" const val SETTINGS = "settings" const val SETTINGS_LANGUAGE = "settings/language" + const val SETTINGS_CHANGELOG = "settings/changelog" const val SETTINGS_INFO = "settings/info" const val SETTINGS_RSVP = "settings/rsvp" const val SETTINGS_BIONIC = "settings/bionic" diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt index cfc62032..ed09f955 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsRoutes.kt @@ -2,10 +2,12 @@ package com.kairo.reader.ui.navigation +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import com.kairo.reader.BuildConfig import com.kairo.reader.KairoApplication import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.ui.settings.BionicSettingsScreen @@ -18,6 +20,7 @@ import com.kairo.reader.ui.settings.SettingsHomeActions import com.kairo.reader.ui.settings.SettingsHomeScreen import com.kairo.reader.ui.settings.SettingsTutorialActions import com.kairo.reader.ui.tutorial.StartingTutorialOverlayState +import com.kairo.reader.ui.updates.ChangelogScreen import kotlinx.coroutines.launch internal data class SettingsRouteDependencies( @@ -27,6 +30,7 @@ internal data class SettingsRouteDependencies( val tutorialState: StartingTutorialOverlayState?, val onCheckForUpdates: () -> Unit, val onOpenStartingTutorial: () -> Unit, + val onOpenReleaseTour: () -> Unit, val onTutorialNext: () -> Unit, val onTutorialPrevious: () -> Unit, val onTutorialSkip: () -> Unit, @@ -48,6 +52,7 @@ internal fun NavGraphBuilder.settingsRoutes(dependencies: SettingsRouteDependenc onOpenTheme = { dependencies.navController.navigate(KairoRoutes.SETTINGS_THEME) }, onOpenReader = { dependencies.navController.navigate(KairoRoutes.SETTINGS_READER) }, onOpenFocus = { dependencies.navController.navigate(KairoRoutes.SETTINGS_FOCUS) }, + onOpenChangelog = { dependencies.navController.navigate(KairoRoutes.SETTINGS_CHANGELOG) }, onOpenInfo = { dependencies.navController.navigate(KairoRoutes.SETTINGS_INFO) }, onCheckForUpdates = dependencies.onCheckForUpdates, onOpenStartingTutorial = dependencies.onOpenStartingTutorial, @@ -99,6 +104,16 @@ internal fun NavGraphBuilder.settingsRoutes(dependencies: SettingsRouteDependenc LanguageSettingsScreen(onBack = { dependencies.navController.popBackStack() }) } + settingsSearchDestination(KairoRoutes.SETTINGS_CHANGELOG) { + LaunchedEffect(Unit) { + dependencies.container.preferencesRepository.acknowledgeRelease(BuildConfig.VERSION_CODE) + } + ChangelogScreen( + onBack = { dependencies.navController.popBackStack() }, + onStartTour = dependencies.onOpenReleaseTour, + ) + } + settingsSearchDestination(KairoRoutes.SETTINGS_INFO) { InfoSettingsScreen(onBack = { dependencies.navController.popBackStack() }) } diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt index 553df8cf..e9be4ce0 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/SettingsSearchNavigation.kt @@ -31,6 +31,7 @@ internal fun SettingsSearchPage.route(): String? = when (this) { SettingsSearchPage.BIONIC -> KairoRoutes.SETTINGS_BIONIC SettingsSearchPage.FOCUS -> KairoRoutes.SETTINGS_FOCUS SettingsSearchPage.LANGUAGE -> KairoRoutes.SETTINGS_LANGUAGE + SettingsSearchPage.CHANGELOG -> KairoRoutes.SETTINGS_CHANGELOG SettingsSearchPage.INFO -> KairoRoutes.SETTINGS_INFO else -> null } diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt index d10bcbf8..19db9861 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsHomeScreen.kt @@ -64,6 +64,7 @@ data class SettingsHomeActions( val onClose: () -> Unit, val onOpenSearchResult: ((SettingsSearchEntry) -> Unit)? = null, val onOpenTheme: () -> Unit = {}, + val onOpenChangelog: () -> Unit = {}, ) data class SettingsTutorialActions(val onNext: () -> Unit = {}, val onPrevious: () -> Unit = {}, val onSkip: () -> Unit = {},) @@ -82,6 +83,8 @@ fun SettingsHomeScreen( val tutorialTargetRequesters = remember { mapOf( + StartingTutorialTargetIds.SETTINGS_THEME to BringIntoViewRequester(), + StartingTutorialTargetIds.SETTINGS_CHANGELOG to BringIntoViewRequester(), StartingTutorialTargetIds.SETTINGS_LANGUAGE to BringIntoViewRequester(), StartingTutorialTargetIds.SETTINGS_RSVP to BringIntoViewRequester(), StartingTutorialTargetIds.SETTINGS_READER to BringIntoViewRequester(), @@ -164,6 +167,7 @@ private fun SettingsHomeRows( title = stringResource(R.string.theme_settings_title), subtitle = stringResource(R.string.theme_settings_subtitle), icon = Icons.Default.Palette, + targetId = StartingTutorialTargetIds.SETTINGS_THEME, onClick = actions.onOpenTheme, ), SettingsHomeRow( @@ -204,6 +208,13 @@ private fun SettingsHomeRows( targetId = StartingTutorialTargetIds.SETTINGS_FOCUS, onClick = actions.onOpenFocus, ), + SettingsHomeRow( + title = stringResource(R.string.whats_new_title), + subtitle = stringResource(R.string.whats_new_subtitle), + icon = Icons.Default.AutoStories, + targetId = StartingTutorialTargetIds.SETTINGS_CHANGELOG, + onClick = actions.onOpenChangelog, + ), SettingsHomeRow( title = stringResource(R.string.update_check_title), subtitle = stringResource(R.string.update_check_subtitle), diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt index 5a9f82dc..6bdc2dff 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchContent.kt @@ -106,6 +106,7 @@ internal fun SettingsHomeActions.openSearchResult(entry: SettingsSearchEntry) { SettingsSearchPage.FOCUS -> onOpenFocus() SettingsSearchPage.LANGUAGE -> onOpenLanguage() SettingsSearchPage.INFO -> onOpenInfo() + SettingsSearchPage.CHANGELOG -> onOpenChangelog() SettingsSearchPage.UPDATES -> onCheckForUpdates() SettingsSearchPage.TUTORIAL -> onOpenStartingTutorial() SettingsSearchPage.RESET -> Unit // The home screen opens the confirmation dialog. diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt index 2f74fee6..94d0af1d 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchGeneralEntries.kt @@ -142,6 +142,12 @@ internal val languageSearchEntries = listOf( ) internal val updatesSearchEntries = listOf( + SettingsSearchEntry( + id = "changelog.whats_new", + page = SettingsSearchPage.CHANGELOG, + titleRes = R.string.whats_new_title, + descriptionRes = R.string.whats_new_subtitle, + ), SettingsSearchEntry( id = "updates.update_check_title", page = SettingsSearchPage.UPDATES, diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt index a44a307d..a6584662 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchIndex.kt @@ -27,6 +27,7 @@ enum class SettingsSearchPage(@StringRes val titleRes: Int) { LANGUAGE(R.string.settings_language_title), INFO(R.string.info_settings_title), UPDATES(R.string.update_check_title), + CHANGELOG(R.string.whats_new_title), TUTORIAL(R.string.settings_starting_tutorial_title), RESET(R.string.settings_title), } diff --git a/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorial.kt b/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorial.kt index 55fa2a2a..376966e3 100644 --- a/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorial.kt +++ b/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorial.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,7 +21,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward @@ -35,6 +38,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,6 +62,8 @@ object StartingTutorialTargetIds { const val LIBRARY_IMPORT = "library_import" const val LIBRARY_TABS = "library_tabs" const val LIBRARY_SETTINGS = "library_settings" + const val SETTINGS_THEME = "settings_theme" + const val SETTINGS_CHANGELOG = "settings_changelog" const val SETTINGS_LANGUAGE = "settings_language" const val SETTINGS_RSVP = "settings_rsvp" const val SETTINGS_READER = "settings_reader" @@ -82,7 +88,12 @@ data class StartingTutorialStep( @param:StringRes val bodyRes: Int, ) -data class StartingTutorialOverlayState(val step: StartingTutorialStep, val index: Int, val totalSteps: Int,) { +data class StartingTutorialOverlayState( + val step: StartingTutorialStep, + val index: Int, + val totalSteps: Int, + val onViewChangelog: (() -> Unit)? = null, +) { val isFirstStep: Boolean = index == 0 val isLastStep: Boolean = index == totalSteps - 1 } @@ -256,6 +267,8 @@ fun StartingTutorialOverlay( onSkip: () -> Unit, modifier: Modifier = Modifier, ) { + val cardScrollState = rememberScrollState() + LaunchedEffect(state.index) { cardScrollState.scrollTo(0) } val interactionSource = remember { MutableInteractionSource() } val density = LocalDensity.current val windowInfo = LocalWindowInfo.current @@ -342,7 +355,7 @@ fun StartingTutorialOverlay( shadowElevation = 10.dp, ) { Column( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + modifier = Modifier.verticalScroll(cardScrollState).padding(horizontal = 18.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( @@ -365,15 +378,19 @@ fun StartingTutorialOverlay( color = MaterialTheme.colorScheme.onSurfaceVariant, ) TutorialHint(state.step) - Row( + state.onViewChangelog?.let { openChangelog -> + TextButton(onClick = openChangelog) { + Text(stringResource(R.string.whats_new_view_changelog)) + } + } + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = onSkip) { Text(stringResource(R.string.action_skip)) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (!state.isFirstStep) { OutlinedButton(onClick = onPrevious) { Text(stringResource(R.string.action_previous)) diff --git a/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorialCoordinator.kt b/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorialCoordinator.kt index 3021e401..0539843d 100644 --- a/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorialCoordinator.kt +++ b/app/src/main/java/com/kairo/reader/ui/tutorial/StartingTutorialCoordinator.kt @@ -20,6 +20,7 @@ import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.data.books.SharedTextImport import com.kairo.reader.sample.SampleBooks import com.kairo.reader.ui.navigation.KairoRoutes +import com.kairo.reader.ui.updates.releaseNotes import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -31,6 +32,7 @@ internal data class StartingTutorialCoordinator( val readerState: StartingTutorialOverlayState?, val rsvpState: StartingTutorialOverlayState?, val start: () -> Unit, + val startReleaseTour: () -> Unit, val next: () -> Unit, val previous: () -> Unit, val skip: () -> Unit, @@ -47,8 +49,10 @@ internal fun rememberStartingTutorialCoordinator( externalArticleUrl: String?, externalSharedText: SharedTextImport?, isImporting: Boolean, + onViewChangelog: () -> Unit, ): StartingTutorialCoordinator { val coroutineScope = rememberCoroutineScope() + var isReleaseTour by rememberSaveable { mutableStateOf(false) } var tutorialSteps by remember { mutableStateOf(startingTutorialSteps(includeReaderAndRsvp = false)) } @@ -56,7 +60,8 @@ internal fun rememberStartingTutorialCoordinator( var tutorialStepIndex by rememberSaveable { mutableIntStateOf(0) } var tutorialActive by rememberSaveable { mutableStateOf(false) } var tutorialAutoStarted by rememberSaveable { mutableStateOf(false) } - val safeTutorialStepIndex = clampTutorialStepIndex(tutorialStepIndex, tutorialSteps) + val sessionSteps = if (isReleaseTour) releaseNotes.first().tour else tutorialSteps + val safeTutorialStepIndex = clampTutorialStepIndex(tutorialStepIndex, sessionSteps) val availableTutorialLaunchContext by produceState( initialValue = null, prefs.hasSeenStartingTutorial, @@ -78,7 +83,7 @@ internal fun rememberStartingTutorialCoordinator( } } - LaunchedEffect(tutorialSteps.size, tutorialStepIndex) { + LaunchedEffect(sessionSteps.size, tutorialStepIndex) { if (safeTutorialStepIndex != null && safeTutorialStepIndex != tutorialStepIndex) { tutorialStepIndex = safeTutorialStepIndex } @@ -110,6 +115,7 @@ internal fun rememberStartingTutorialCoordinator( StartingTutorialRoute.SETTINGS -> { navController.navigate(KairoRoutes.SETTINGS) { + popUpTo(KairoRoutes.SETTINGS) { inclusive = false } launchSingleTop = true } } @@ -149,7 +155,16 @@ internal fun rememberStartingTutorialCoordinator( } } + fun startReleaseTour() { + val firstStep = releaseNotes.first().tour.firstOrNull() ?: return + isReleaseTour = true + tutorialStepIndex = 0 + tutorialActive = true + navigateToTutorialRoute(firstStep.route) + } + fun startStartingTutorial() { + isReleaseTour = false val sessionSteps = startingTutorialSteps(includeReaderAndRsvp = availableTutorialLaunchContext != null) tutorialLaunchContext = availableTutorialLaunchContext @@ -162,7 +177,7 @@ internal fun rememberStartingTutorialCoordinator( fun dismissStartingTutorial(markAsSeen: Boolean = true) { tutorialActive = false - if (markAsSeen) { + if (markAsSeen && !isReleaseTour) { markStartingTutorialSeenIfNeeded() } } @@ -174,13 +189,13 @@ internal fun rememberStartingTutorialCoordinator( fun moveStartingTutorial(stepDelta: Int) { val nextIndex = tutorialStepIndex + stepDelta - if (nextIndex !in tutorialSteps.indices) { + if (nextIndex !in sessionSteps.indices) { finishStartingTutorial() return } - val previousRoute = tutorialSteps.getOrNull(tutorialStepIndex)?.route + val previousRoute = sessionSteps.getOrNull(tutorialStepIndex)?.route tutorialStepIndex = nextIndex - val nextRoute = tutorialSteps[nextIndex].route + val nextRoute = sessionSteps[nextIndex].route if (nextRoute != previousRoute) { navigateToTutorialRoute(nextRoute) } @@ -189,9 +204,17 @@ internal fun rememberStartingTutorialCoordinator( val tutorialOverlayState = if (tutorialActive && safeTutorialStepIndex != null) { StartingTutorialOverlayState( - step = tutorialSteps[safeTutorialStepIndex], + step = sessionSteps[safeTutorialStepIndex], index = safeTutorialStepIndex, - totalSteps = tutorialSteps.size, + totalSteps = sessionSteps.size, + onViewChangelog = if (isReleaseTour) { + { + dismissStartingTutorial() + onViewChangelog() + } + } else { + null + }, ) } else { null @@ -237,6 +260,7 @@ internal fun rememberStartingTutorialCoordinator( rsvpState = tutorialOverlayState?.takeIf { it.step.route == StartingTutorialRoute.RSVP }, start = ::startStartingTutorial, + startReleaseTour = ::startReleaseTour, next = { moveStartingTutorial(1) }, previous = { moveStartingTutorial(-1) }, skip = { dismissStartingTutorial() }, diff --git a/app/src/main/java/com/kairo/reader/ui/updates/ChangelogScreen.kt b/app/src/main/java/com/kairo/reader/ui/updates/ChangelogScreen.kt new file mode 100644 index 00000000..24fe2c0f --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/updates/ChangelogScreen.kt @@ -0,0 +1,62 @@ +package com.kairo.reader.ui.updates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.kairo.reader.R +import com.kairo.reader.ui.settings.SettingsScaffold + +@Composable +internal fun ChangelogScreen(onBack: () -> Unit, onStartTour: () -> Unit) { + SettingsScaffold(title = stringResource(R.string.whats_new_title), onBack = onBack) { modifier -> + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp).testTag("changelog"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.whats_new_subtitle), style = MaterialTheme.typography.bodyLarge) + if (releaseNotes.first().tour.isNotEmpty()) { + Button(onClick = onStartTour) { Text(stringResource(R.string.whats_new_take_tour)) } + } + } + } + items(releaseNotes, key = { it.version ?: "upcoming" }) { release -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = release.version?.let { stringResource(R.string.whats_new_version, it) } + ?: stringResource(R.string.whats_new_upcoming), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { heading() }, + ) + release.changes.forEach { change -> + Text(stringResource(change), style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/kairo/reader/ui/updates/ReleaseNotes.kt b/app/src/main/java/com/kairo/reader/ui/updates/ReleaseNotes.kt new file mode 100644 index 00000000..79659ec9 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/updates/ReleaseNotes.kt @@ -0,0 +1,72 @@ +package com.kairo.reader.ui.updates + +import androidx.annotation.StringRes +import com.kairo.reader.R +import com.kairo.reader.ui.tutorial.StartingTutorialRoute +import com.kairo.reader.ui.tutorial.StartingTutorialStep +import com.kairo.reader.ui.tutorial.StartingTutorialTargetIds + +internal data class ReleaseNotes( + val version: String?, + val versionCode: Int?, + @param:StringRes val changes: List, + val tour: List = emptyList(), +) + +// Set both version fields during release preparation; unpublished notes never trigger an update prompt. +@Suppress("MagicNumber") // Android version codes from the corresponding release tags. +internal val releaseNotes = listOf( + ReleaseNotes( + version = null, + versionCode = null, + changes = listOf( + R.string.release_upcoming_themes, + R.string.release_upcoming_reading, + R.string.release_upcoming_guidance, + R.string.release_upcoming_progress, + R.string.release_upcoming_imports, + R.string.release_upcoming_search_focus, + R.string.release_upcoming_helper, + ), + tour = listOf( + StartingTutorialStep( + StartingTutorialRoute.SETTINGS, + StartingTutorialTargetIds.SETTINGS_THEME, + R.string.theme_settings_title, + R.string.release_upcoming_themes, + ), + StartingTutorialStep( + StartingTutorialRoute.SETTINGS, + StartingTutorialTargetIds.SETTINGS_RSVP, + R.string.rsvp_settings_title, + R.string.release_upcoming_reading, + ), + StartingTutorialStep( + StartingTutorialRoute.SETTINGS, + StartingTutorialTargetIds.SETTINGS_RSVP, + R.string.whats_new_word_guidance, + R.string.release_upcoming_guidance, + ), + StartingTutorialStep( + StartingTutorialRoute.SETTINGS, + StartingTutorialTargetIds.SETTINGS_CHANGELOG, + R.string.whats_new_title, + R.string.release_upcoming_helper, + ), + ), + ), + ReleaseNotes("1.3.0", 15, listOf(R.string.release_130)), + ReleaseNotes("1.2.0", 14, listOf(R.string.release_120)), + ReleaseNotes("1.1.0", 12, listOf(R.string.release_110)), + ReleaseNotes("1.0.8", 10, listOf(R.string.release_108)), +) + +internal fun shouldOfferReleaseTour( + releaseVersionCode: Int?, + installedVersionCode: Int, + lastSeenVersionCode: Int, + hasSeenStartingTutorial: Boolean, +): Boolean = + releaseVersionCode == installedVersionCode && + lastSeenVersionCode < installedVersionCode && + hasSeenStartingTutorial diff --git a/app/src/main/java/com/kairo/reader/ui/updates/WhatsNewPrompt.kt b/app/src/main/java/com/kairo/reader/ui/updates/WhatsNewPrompt.kt new file mode 100644 index 00000000..64fbc603 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/updates/WhatsNewPrompt.kt @@ -0,0 +1,102 @@ +package com.kairo.reader.ui.updates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kairo.reader.BuildConfig +import com.kairo.reader.R +import com.kairo.reader.core.model.UserPreferences +import com.kairo.reader.data.preferences.PreferencesRepository +import kotlinx.coroutines.launch + +@Composable +internal fun WhatsNewPrompt( + preferences: UserPreferences, + repository: PreferencesRepository, + canShow: Boolean, + onStartTour: () -> Unit, + onViewChangelog: () -> Unit, +) { + val release = releaseNotes.first() + val scope = rememberCoroutineScope() + var dismissed by rememberSaveable { mutableStateOf(false) } + + // New installs get the starting tutorial only, even after completing it in this session. + LaunchedEffect(Unit) { + if (!preferences.hasSeenStartingTutorial && preferences.lastSeenReleaseVersionCode == 0) { + dismissed = true + repository.acknowledgeRelease(BuildConfig.VERSION_CODE) + } + } + + fun acknowledge(action: () -> Unit = {}) { + dismissed = true + scope.launch { repository.acknowledgeRelease(BuildConfig.VERSION_CODE) } + action() + } + + val eligible = shouldOfferReleaseTour( + release.versionCode, + BuildConfig.VERSION_CODE, + preferences.lastSeenReleaseVersionCode, + preferences.hasSeenStartingTutorial, + ) + if (canShow && eligible && !dismissed) { + ReleaseInvitationDialog( + version = requireNotNull(release.version), + hasTour = release.tour.isNotEmpty(), + onSkip = { acknowledge() }, + onStartTour = { acknowledge(onStartTour) }, + onViewChangelog = { acknowledge(onViewChangelog) }, + ) + } +} + +@Composable +internal fun ReleaseInvitationDialog( + version: String, + hasTour: Boolean, + onSkip: () -> Unit, + onStartTour: () -> Unit, + onViewChangelog: () -> Unit, +) { + AlertDialog( + onDismissRequest = onSkip, + title = { Text(stringResource(R.string.whats_new_invitation_title, version)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.whats_new_invitation_body)) + if (hasTour) { + TextButton(onClick = onViewChangelog) { + Text(stringResource(R.string.whats_new_view_changelog)) + } + } + } + }, + confirmButton = { + TextButton(onClick = if (hasTour) onStartTour else onViewChangelog) { + Text(stringResource(if (hasTour) R.string.whats_new_take_tour else R.string.whats_new_view_changelog)) + } + }, + dismissButton = { + TextButton(onClick = onSkip) { Text(stringResource(R.string.action_skip)) } + }, + ) +} diff --git a/app/src/main/res/values-de/release_notes.xml b/app/src/main/res/values-de/release_notes.xml new file mode 100644 index 00000000..a38c06ee --- /dev/null +++ b/app/src/main/res/values-de/release_notes.xml @@ -0,0 +1,23 @@ + + + Neuigkeiten + Entdecke neue Funktionen und das Änderungsprotokoll der Versionen. + Änderungsprotokoll ansehen + Rundgang starten + Version %1$s + Demnächst + Neu in Kairo %1$s + Dein Update ist bereit zum Entdecken. Schau kurz rein oder lies weiter. Du findest diese Übersicht jederzeit unter Einstellungen → Neuigkeiten. + Klarere Worthilfen + Gestalte Kairo im Themenstudio: Passe Farben und Schriften an, sieh dir Leseansichten in der Vorschau an, mache Änderungen rückgängig und speichere eigene Designs. Verbesserte Kontraste halten hervorgehobenen Text lesbar. + RSVP gruppiert Text jetzt nach natürlichen Wortgruppen und Gedankengrenzen. Tempo und Leseunterstützung vereinfachen die Anpassung: mit unterschiedlichen Rhythmen der Voreinstellungen, sanfterem Kontext und Wortwechseln sowie geschützter Lesezeit bei Tempoänderungen. Beim Wechsel der Voreinstellung bleiben deine Anzeigeeinstellungen erhalten. + Worthilfen unterstützen bei ungewohnten englischen Schreibweisen. Ein klarerer Fixierpunkt bleibt in allen Themen erkennbar. Hervorhebungen des Autors, Dialoge und Einschübe fließen in das ausdrucksvolle Lesen ein; Hervorhebungen bleiben auch an Seitenumbrüchen und bei Zahlen erhalten. + Reader und RSVP zeigen den Kapitelfortschritt einheitlicher an. Springen, Drehen des Geräts, Kapitelwechsel und die Rückkehr zum Lesen bewahren deine Position zuverlässiger. Große Kapitel laden sicher, die Wiedergabe startet schneller und Reader scrollt zu einem Fokus außerhalb des sichtbaren Bereichs. + Zuverlässigere Importe von EPUB, PDF, MOBI, DOCX und Text: besseres Unicode und Wortzählung für Chinesisch, Japanisch und Koreanisch, ausgelassener gelöschter DOCX-Text, sicherer Umgang mit großen Dateien und Covern sowie zuverlässigeres Abbrechen. Unvollständige EPUBs werden abgelehnt statt als defekte Bücher hinzugefügt. + Die Textstellensuche zeigt Ergebnisse bereits während der Suche. Der Fokusmodus erlaubt Medien und gibt „Nicht stören“ frei, wenn Kairo in den Hintergrund wechselt. Beim Entfernen von Büchern werden ihre Zwischenspeicher aktualisiert. + Updates können einen freiwilligen Rundgang anbieten, sobald du zur Bibliothek zurückkehrst. Du kannst jederzeit überspringen, während des Rundgangs das Änderungsprotokoll öffnen oder Neuigkeiten in den Einstellungen erneut aufrufen. + Suche Einstellungen nach Name, Beschreibung oder Option, auch direkt aus Reader und RSVP. Passende Bedienelemente werden hervorgehoben.\n\nExportiere gespeicherte Notizen über die Android-Dateiauswahl als PDF oder Markdown, mit lokalisierten Bedienelementen und Dokumenten.\n\nÜberarbeitete Bibliothek und Einstellungen, bessere Bedienung im Querformat und auf kleinen Bildschirmen sowie einheitliche RSVP-Einstellungen in den Leseansichten.\n\nStabilere eingebettete Bilder, Wiederherstellung der Leseposition, HTML-Zeilenumbrüche, Erfassung kurzer Lesesitzungen und App-Updates. Verbesserte Android-Kompatibilität und Barrierefreiheit. + Gespeichert vereint Lesezeichen, Hervorhebungen und bearbeitbare Notizen. Durchsuche deine Bibliothek und Textstellen mit besserer Unicode-Unterstützung und zuverlässigeren Ergebnissen.\n\nMomentum erfasst Leseaktivität, Wochenziele und Verlauf, mit Ziel- und Rücksetzoptionen. Reader und zeitgesteuertes Lesen zeichnen Sitzungen auf.\n\nÖffne die originalen EPUB-Inhaltsverzeichnisse und folge ihren Kapitellinks. Verbesserte Navigationsreparatur, präzise Absatzauswahl, Datenintegrität und Barrierefreiheit. + Importiere PDFs mit auswählbarem Text, FB2, DOCX und weitere Textformate. Der Dateiinhalt hilft bei der Formaterkennung, unterstützte Formate sind in der Bibliothek klarer ersichtlich und große PDFs werden schneller importiert.\n\nPrüfe über Google Play auf Updates und installiere sie. Verbesserte Importprüfung, Android-Kompatibilität und Standardtextgröße für RSVP. + Zeitgesteuertes Bionic-Lesen ergänzt RSVP mit eigenen Einstellungen, adaptiver Textdarstellung und einer Lesemodus-Auswahl.\n\nImportiere eingefügten Text und Markdown oder teile Text aus anderen Apps mit Kairo.\n\nRSVP erhält fortlaufenden Kontext und kontextbezogene Wiederholung, mit besserer Wortgruppenausrichtung, Zeichensetzungsdauer, Ladeverhalten und Übersetzungen. + diff --git a/app/src/main/res/values-es/release_notes.xml b/app/src/main/res/values-es/release_notes.xml new file mode 100644 index 00000000..254b3e0f --- /dev/null +++ b/app/src/main/res/values-es/release_notes.xml @@ -0,0 +1,23 @@ + + + Novedades + Explora las nuevas funciones y el historial de cambios de cada versión. + Ver cambios + Iniciar recorrido + Versión %1$s + Próximamente + Novedades de Kairo %1$s + Tu actualización está lista para explorar. Echa un vistazo o sigue leyendo. Puedes volver aquí desde Ajustes → Novedades. + Guías de palabras más claras + Personaliza Kairo con el estudio de temas: ajusta colores y fuentes, previsualiza las pantallas de lectura, deshaz cambios y guarda diseños con nombre. El contraste mejorado mantiene legible el texto resaltado. + RSVP agrupa el texto según frases naturales y unidades de sentido. Velocidad y Apoyo de lectura simplifican los ajustes, con ritmos distintos por perfil, transiciones y contexto más suaves y tiempo de lectura protegido al cambiar la velocidad. Cambiar de perfil conserva tus preferencias de visualización. + Las guías de partes de palabras ayudan con grafías inglesas poco familiares y un punto de fijación más claro se distingue en todos los temas. El énfasis del autor, los diálogos y los incisos aportan expresividad sin perder el énfasis en saltos de página o números. + Reader y RSVP comparten el progreso del capítulo de forma más coherente. Los saltos, la rotación, los cambios de capítulo y la vuelta a la lectura conservan mejor tu posición. Los capítulos grandes se cargan con seguridad, la reproducción comienza antes y Reader desplaza la vista al foco cuando queda fuera de pantalla. + Importaciones de EPUB, PDF, MOBI, DOCX y texto más fiables: mejoras de Unicode y del recuento de palabras en chino, japonés y coreano, omisión del texto eliminado en DOCX, gestión más segura de archivos y portadas grandes y mejor cancelación. Los EPUB incompletos se rechazan en lugar de añadirse como libros dañados. + La búsqueda de pasajes muestra resultados mientras continúa el análisis. El modo de concentración permite contenido multimedia y desactiva No molestar cuando Kairo pasa a segundo plano. Las cachés se actualizan al eliminar libros. + Las actualizaciones pueden ofrecer un recorrido opcional al volver a la biblioteca. Puedes omitirlo en cualquier momento, abrir el historial de cambios durante el recorrido o volver a Novedades desde Ajustes. + Busca ajustes por nombre, descripción u opción, también desde Reader y RSVP, con los controles coincidentes resaltados.\n\nExporta notas guardadas como PDF o Markdown mediante el selector de archivos de Android, con controles y documentos localizados.\n\nDiseños renovados de biblioteca y ajustes, mejores controles en horizontal y pantallas pequeñas y ajustes de RSVP compartidos entre vistas de lectura.\n\nMayor estabilidad de imágenes integradas, restauración de la posición, saltos de línea HTML, registro de sesiones cortas y actualizaciones. Mejor compatibilidad con Android y accesibilidad. + Guardado reúne marcadores, resaltados y notas editables. Busca en tu biblioteca y en pasajes de libros, con mejores coincidencias Unicode y resultados más fiables.\n\nMomentum registra la actividad de lectura, los objetivos semanales y el historial, con controles de objetivos y reinicio. Reader y la lectura temporizada registran sesiones.\n\nAbre índices originales de EPUB y sigue sus enlaces a capítulos. Mejoras en reparación de navegación, selección precisa de párrafos, integridad de datos y accesibilidad. + Importa PDF con texto seleccionable, FB2, DOCX y más formatos de texto. El contenido ayuda a identificar el formato, los formatos compatibles se muestran más claramente en la biblioteca y los PDF grandes se importan más rápido.\n\nBusca e instala actualizaciones mediante Google Play. Mejoras en validación de importaciones, compatibilidad con Android y tamaño de texto predeterminado de RSVP. + La lectura temporizada Bionic se suma a RSVP, con ajustes propios, texto adaptable y selector de modo de lectura.\n\nImporta texto pegado y Markdown o comparte texto con Kairo desde otras aplicaciones.\n\nRSVP incorpora contexto continuo y repetición contextual, con mejoras en alineación de frases, tiempos de puntuación, carga y traducciones. + diff --git a/app/src/main/res/values-fr/release_notes.xml b/app/src/main/res/values-fr/release_notes.xml new file mode 100644 index 00000000..4fa68c46 --- /dev/null +++ b/app/src/main/res/values-fr/release_notes.xml @@ -0,0 +1,23 @@ + + + Nouveautés + Découvrez les nouvelles fonctions et l’historique des versions. + Voir les changements + Commencer la visite + Version %1$s + À venir + Nouveautés de Kairo %1$s + Votre mise à jour est prête à être explorée. Jetez un œil ou poursuivez votre lecture. Retrouvez cette présentation dans Paramètres → Nouveautés. + Des repères de mots plus clairs + Personnalisez Kairo avec le studio de thèmes : ajustez les couleurs et les polices, prévisualisez les écrans de lecture, annulez les modifications et enregistrez des thèmes nommés. Le contraste amélioré garde le texte surligné lisible. + RSVP regroupe désormais le texte selon des expressions naturelles et des unités de sens. Vitesse et Aide à la lecture simplifient les réglages, avec des rythmes distincts par profil, un contexte et des transitions plus fluides et un temps de lecture préservé lors des changements de vitesse. Changer de profil conserve vos préférences d’affichage. + Les repères de parties de mots aident à lire les graphies anglaises peu familières, tandis qu’un point de fixation plus net reste distinct dans tous les thèmes. L’emphase de l’auteur, les dialogues et les incises rendent la lecture plus expressive, sans perte d’emphase aux sauts de page ou autour des nombres. + Reader et RSVP partagent plus fidèlement la progression du chapitre. Les déplacements, rotations, changements de chapitre et reprises préservent mieux votre position. Les longs chapitres se chargent de manière sûre, la lecture démarre plus vite et Reader défile jusqu’au point de lecture hors écran. + Importations EPUB, PDF, MOBI, DOCX et texte plus fiables : meilleur Unicode et comptage des mots chinois, japonais et coréens, omission du texte supprimé des DOCX, gestion plus sûre des gros fichiers et couvertures et meilleure annulation. Les EPUB incomplets sont refusés au lieu d’être ajoutés comme livres défectueux. + La recherche de passages affiche des résultats pendant l’analyse. Le mode concentration autorise les médias et libère Ne pas déranger quand Kairo passe en arrière-plan. Les caches sont actualisés lors de la suppression de livres. + Les mises à jour peuvent proposer une visite facultative à votre retour à la bibliothèque. Passez-la à tout moment, ouvrez l’historique pendant la visite ou retrouvez Nouveautés dans les paramètres. + Recherchez les paramètres par nom, description ou option, y compris depuis Reader et RSVP, avec mise en évidence des commandes correspondantes.\n\nExportez les notes enregistrées en PDF ou Markdown via le sélecteur de fichiers Android, avec des commandes et documents localisés.\n\nBibliothèque et paramètres repensés, meilleures commandes en paysage et sur petit écran, et paramètres RSVP partagés entre les vues de lecture.\n\nImages intégrées plus stables, meilleure restauration de position, sauts de ligne HTML, suivi des courtes sessions et gestion des mises à jour. Compatibilité Android et accessibilité améliorées. + Enregistrés réunit signets, surlignages et notes modifiables. Recherchez dans votre bibliothèque et les passages des livres, avec de meilleures correspondances Unicode et des résultats plus fiables.\n\nMomentum suit l’activité, les objectifs hebdomadaires et l’historique, avec réglage des objectifs et remise à zéro. Reader et la lecture minutée enregistrent les sessions.\n\nOuvrez les sommaires originaux des EPUB et suivez les liens des chapitres. Amélioration de la réparation de navigation, de la sélection précise des paragraphes, de l’intégrité des données et de l’accessibilité. + Importez des PDF à texte sélectionnable, FB2, DOCX et d’autres formats texte. Le contenu aide à identifier le format, les formats pris en charge sont plus clairs dans la bibliothèque et les gros PDF sont importés plus vite.\n\nRecherchez et installez les mises à jour via Google Play. Validation des imports, compatibilité Android et taille de texte RSVP par défaut améliorées. + La lecture minutée Bionic rejoint RSVP, avec ses propres paramètres, un texte adaptatif et un sélecteur de mode de lecture.\n\nImportez du texte collé et du Markdown, ou partagez du texte vers Kairo depuis une autre application.\n\nRSVP propose un contexte continu et une relecture contextuelle, avec une meilleure disposition des groupes de mots, un meilleur rythme de ponctuation, un chargement amélioré et de nouvelles traductions. + diff --git a/app/src/main/res/values-ja/release_notes.xml b/app/src/main/res/values-ja/release_notes.xml new file mode 100644 index 00000000..14165691 --- /dev/null +++ b/app/src/main/res/values-ja/release_notes.xml @@ -0,0 +1,23 @@ + + + 新着情報 + 新機能とバージョンごとの変更履歴を確認できます。 + 変更履歴を見る + ツアーを開始 + バージョン %1$s + 今後の更新 + Kairo %1$s の新機能 + 更新内容をご紹介します。少し見てみることも、そのまま読書を続けることもできます。「設定 → 新着情報」からいつでも確認できます。 + よりわかりやすい単語ガイド + テーマスタジオで Kairo を自分好みに。色やフォントの調整、読書画面のプレビュー、編集の取り消し、名前付きデザインの保存ができます。コントラストの改善で強調された文字も読みやすくなります。 + RSVP は自然な語句や意味の区切りに沿ってテキストをまとめます。「速度」と「読書サポート」で調整が簡単になり、プリセットごとのリズム、滑らかな文脈表示と単語の切り替え、速度変更時の読書時間の確保に対応します。プリセットを切り替えても表示設定は保持されます。 + 単語の部分ガイドが不慣れな英語のつづりを読む助けとなり、より明確な注視点がどのテーマでも見分けやすくなります。著者の強調、会話、挿入句を表現豊かな読書に反映し、改ページや数字の前後でも強調を保持します。 + Reader と RSVP の章の進捗表示を統一しました。移動、画面回転、章の切り替え、読書の再開で位置をより確実に保持します。大きな章を安全に読み込み、再生開始の待ち時間を短縮し、Reader は画面外の読書位置までスクロールします。 + EPUB、PDF、MOBI、DOCX、テキストの取り込みを改善しました。Unicode と中国語・日本語・韓国語の語数計算を改善し、DOCX の削除済みテキストを除外します。大きなファイルや表紙をより安全に扱い、キャンセルも改善しました。不完全な EPUB は壊れた本として追加せず、取り込みを拒否します。 + 本文検索は検索中にも結果を表示します。集中モードはメディア再生を許可し、Kairo がバックグラウンドに移ると通知制限を解除します。本の削除時にはキャッシュも更新されます。 + 更新後にライブラリへ戻ると、任意の機能ツアーを案内できます。いつでもスキップでき、ツアー中に変更履歴を開いたり、設定の新着情報から再度確認したりできます。 + 名前、説明、選択肢で設定を検索できます。Reader や RSVP からも検索でき、該当する項目が強調されます。\n\n保存したノートを Android のファイル選択画面から PDF や Markdown として書き出せます。操作画面と文書は各言語に対応します。\n\nライブラリと設定のデザインを刷新し、横向きや小さな画面の操作性を改善。読書画面間で RSVP 設定を共通化しました。\n\n本文内の画像、読書位置の復元、HTML の改行、短い読書セッションの記録、アプリ更新を安定化。Android 互換性とアクセシビリティも改善しました。 + 保存済み画面にブックマーク、ハイライト、編集可能なノートを集約しました。ライブラリや本文を検索でき、Unicode の一致精度と結果の信頼性を改善しました。\n\nMomentum で読書活動、週間目標、履歴を確認でき、目標設定やリセットに対応します。Reader と時間制読書の両方でセッションを記録します。\n\nEPUB 本来の目次を開き、章へのリンクをたどれます。ナビゲーションの修復、正確な段落選択、読書データの整合性、アクセシビリティを改善しました。 + テキストを選択できる PDF、FB2、DOCX などの形式を取り込めます。内容による形式判定、ライブラリでの対応形式の案内を改善し、大きな PDF の取り込みを高速化しました。\n\nGoogle Play 経由で更新を確認・インストールできます。取り込みの検証、Android 互換性、RSVP の標準文字サイズを改善しました。 + RSVP に加えて、時間制の Bionic 読書に対応しました。専用設定、画面に応じた文字表示、読書モード選択を備えます。\n\n貼り付けたテキストや Markdown を取り込んだり、他のアプリから Kairo にテキストを共有したりできます。\n\nRSVP に連続した文脈表示と文脈に応じた再読を追加し、語句の配置、句読点の間、読み込み、翻訳を改善しました。 + diff --git a/app/src/main/res/values-pt-rBR/release_notes.xml b/app/src/main/res/values-pt-rBR/release_notes.xml new file mode 100644 index 00000000..807587e8 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/release_notes.xml @@ -0,0 +1,23 @@ + + + Novidades + Explore os novos recursos e o histórico de alterações de cada versão. + Ver alterações + Iniciar tour + Versão %1$s + Em breve + Novidades do Kairo %1$s + Sua atualização está pronta para explorar. Dê uma olhada ou continue lendo. Você encontra esta visão geral em Configurações → Novidades. + Guias de palavras mais claros + Personalize o Kairo com o estúdio de temas: ajuste cores e fontes, visualize as telas de leitura, desfaça edições e salve designs com nome. O contraste aprimorado mantém o texto destacado legível. + O RSVP agora agrupa o texto em expressões naturais e unidades de sentido. Velocidade e Apoio à leitura simplificam os ajustes, com ritmos distintos por perfil, contexto e transições mais suaves e tempo de leitura protegido ao mudar a velocidade. Trocar de perfil mantém suas preferências de exibição. + Os guias de partes das palavras ajudam com grafias inglesas pouco familiares, enquanto um ponto de fixação mais claro se destaca em todos os temas. Ênfase do autor, diálogos e comentários entre parênteses orientam uma leitura expressiva sem perder a ênfase em quebras de página ou números. + Reader e RSVP compartilham o progresso do capítulo de forma mais consistente. Saltos, rotação, mudanças de capítulo e retomadas preservam sua posição com mais segurança. Capítulos grandes carregam com segurança, a reprodução começa mais rápido e o Reader rola até o foco fora da tela. + Importações de EPUB, PDF, MOBI, DOCX e texto mais confiáveis: melhorias em Unicode e na contagem de palavras em chinês, japonês e coreano, omissão de texto excluído em DOCX, tratamento mais seguro de arquivos e capas grandes e melhor cancelamento. EPUBs incompletos são rejeitados em vez de adicionados como livros danificados. + A busca por trechos mostra resultados enquanto a análise continua. O modo de foco permite mídia e libera Não perturbe quando o Kairo vai para segundo plano. Os caches são atualizados ao remover livros. + As atualizações podem oferecer um tour opcional quando você voltar à biblioteca. Pule a qualquer momento, abra o histórico durante o tour ou revisite Novidades nas Configurações. + Busque configurações por nome, descrição ou opção, inclusive no Reader e RSVP, com destaque para os controles encontrados.\n\nExporte notas salvas como PDF ou Markdown pelo seletor de arquivos do Android, com controles e documentos localizados.\n\nBiblioteca e configurações renovadas, controles melhores na horizontal e em telas pequenas e configurações de RSVP compartilhadas nas telas de leitura.\n\nMais estabilidade em imagens integradas, restauração da posição, quebras de linha HTML, registro de sessões curtas e atualizações. Melhor compatibilidade com Android e acessibilidade. + Salvos reúne marcadores, destaques e notas editáveis. Busque na biblioteca e nos trechos dos livros, com melhores correspondências Unicode e resultados mais confiáveis.\n\nMomentum registra atividade, metas semanais e histórico, com controles de metas e redefinição. Reader e leitura temporizada registram sessões.\n\nAbra sumários originais de EPUB e siga os links dos capítulos. Melhorias no reparo da navegação, seleção precisa de parágrafos, integridade dos dados e acessibilidade. + Importe PDFs com texto selecionável, FB2, DOCX e outros formatos de texto. O conteúdo ajuda a identificar o formato, os formatos compatíveis ficam mais claros na biblioteca e PDFs grandes são importados mais rápido.\n\nBusque e instale atualizações pelo Google Play. Melhorias na validação de importações, compatibilidade com Android e tamanho padrão do texto RSVP. + A leitura temporizada Bionic se junta ao RSVP, com configurações próprias, texto adaptável e seletor de modo de leitura.\n\nImporte texto colado e Markdown ou compartilhe texto com o Kairo de outros aplicativos.\n\nO RSVP ganha contexto contínuo e repetição contextual, com melhorias no alinhamento de expressões, tempo de pontuação, carregamento e traduções. + diff --git a/app/src/main/res/values-zh-rCN/release_notes.xml b/app/src/main/res/values-zh-rCN/release_notes.xml new file mode 100644 index 00000000..96686a7e --- /dev/null +++ b/app/src/main/res/values-zh-rCN/release_notes.xml @@ -0,0 +1,23 @@ + + + 更新内容 + 探索新功能,查看各版本的更新日志。 + 查看更新日志 + 开始导览 + 版本 %1$s + 即将推出 + Kairo %1$s 的新功能 + 更新已准备就绪。可以快速了解,也可以继续阅读。随时可从“设置 → 更新内容”再次查看。 + 更清晰的单词引导 + 通过主题工作室定制 Kairo:调整颜色和字体、预览阅读界面、撤销修改,并保存命名设计。改进的对比度让高亮文字保持清晰。 + RSVP 现在按自然短语和语义边界组织文本。“速度”和“阅读支持”让调整更简单,预设具有不同的阅读节奏,上下文和单词切换更流畅,改变速度时仍保障阅读时间。切换预设会保留显示偏好。 + 单词部分引导有助于识别不熟悉的英语拼写,更清晰的注视点在各种主题中都易于区分。作者强调、对话和插入语参与富有表现力的阅读,分页或数字附近的强调也能保留。 + Reader 和 RSVP 更一致地显示章节进度。跳转、旋转屏幕、切换章节和恢复阅读时,位置保留更可靠。大章节安全加载,播放启动等待更短,Reader 会滚动到屏幕外的阅读焦点。 + EPUB、PDF、MOBI、DOCX 和文本导入更可靠:改进 Unicode 和中日韩文的字词计数,忽略 DOCX 中已删除的文本,更安全地处理大文件和封面,并改善取消操作。不完整的 EPUB 会被拒绝,而不是作为损坏的图书加入。 + 段落搜索会在扫描期间显示结果。专注模式允许媒体播放,并在 Kairo 进入后台时解除勿扰。移除图书时会更新相关缓存。 + 更新后返回书库时,可以选择参加功能导览。随时跳过、在导览中打开更新日志,或从设置中的更新内容再次查看。 + 按名称、说明或选项搜索设置,也可从 Reader 和 RSVP 中搜索,匹配的控件会高亮显示。\n\n通过 Android 文件选择器将已保存的笔记导出为 PDF 或 Markdown,操作界面和文档均已本地化。\n\n焕新的书库和设置布局,改善横屏和小屏操作,并在阅读界面间共享 RSVP 设置。\n\n改善内嵌图片稳定性、阅读位置恢复、HTML 换行、短时阅读记录及应用更新处理。提高 Android 兼容性和无障碍体验。 + 已保存页面汇集书签、高亮和可编辑笔记。可搜索书库和书中段落,Unicode 匹配更准确,结果更可靠。\n\nMomentum 跟踪阅读活动、每周目标和历史,支持调整目标及重置。Reader 和定时阅读均会记录阅读过程。\n\n打开 EPUB 原有目录并跟随章节链接。改进导航修复、精确段落选择、阅读数据完整性和无障碍体验。 + 可导入文字可选的 PDF、FB2、DOCX 及更多文本格式。通过文件内容辅助识别格式,在书库中更清楚地展示支持的格式,并加快大 PDF 的导入。\n\n通过 Google Play 检查并安装更新。改进导入验证、Android 兼容性和 RSVP 默认字号。 + 在 RSVP 之外新增定时 Bionic 阅读,提供独立设置、自适应文字显示和阅读模式选择。\n\n导入粘贴文本和 Markdown,或从其他应用向 Kairo 分享文本。\n\nRSVP 新增连续上下文和上下文重读,并改进短语对齐、标点停顿、加载和翻译。 + diff --git a/app/src/main/res/values/release_notes.xml b/app/src/main/res/values/release_notes.xml new file mode 100644 index 00000000..d164c9aa --- /dev/null +++ b/app/src/main/res/values/release_notes.xml @@ -0,0 +1,23 @@ + + + What’s new + Explore new features and the version changelog. + View changelog + Take a tour + Version %1$s + Upcoming + New in Kairo %1$s + Your update is ready to explore. Have a quick look, or carry on reading. You can find this again in Settings → What’s new. + Clearer word guidance + Make Kairo your own with the theme studio: adjust colours and fonts, preview your reading screens, undo edits, and save named designs. Improved contrast keeps highlighted text readable. + RSVP now groups text around natural phrases and thought boundaries. Speed and Reading support make tuning simpler, with distinct preset rhythms, smoother context and word transitions, and reading time protected when you change speed. Switching presets keeps your display preferences. + Word-part guidance helps with unfamiliar English spellings, while a clearer pivot accent stays distinct across themes. Author emphasis, dialogue, and asides inform expressive reading without losing emphasis at page breaks or around numbers. + Reader and RSVP share chapter progress more consistently. Seeking, rotation, chapter changes, and returning to reading preserve your place more reliably. Large chapters load safely, playback starts with less waiting, and Reader scrolls to an offscreen focus. + More reliable EPUB, PDF, MOBI, DOCX, and text imports: improved Unicode and Chinese, Japanese, and Korean word counts, deleted DOCX text omitted, safer handling of large files and covers, and better cancellation. Incomplete EPUBs are rejected instead of added as broken books. + Passage search shows results while scanning continues. Focus mode allows media and releases Do Not Disturb when Kairo goes into the background. Book caches are refreshed when books are removed. + Updates can offer an optional feature tour once you return to the library. Skip at any time, open the changelog during the tour, or revisit What’s new from Settings. + Search settings by name, description, or option, including from Reader and RSVP, with matching controls highlighted.\n\nExport saved notes as PDF or Markdown through Android’s file picker, with localised controls and documents.\n\nRefreshed library and settings layouts, improved landscape and compact-screen controls, and shared RSVP settings across reading views.\n\nMore stable inline images, reading-position restoration, HTML line breaks, short-session tracking, and app-update handling. Improved Android compatibility and accessibility. + Saved brings bookmarks, highlights, and editable notes together. Search your library and book passages, with improved Unicode matching and more reliable results.\n\nMomentum tracks reading activity, weekly goals, and history, with goal and reset controls. Both Reader and timed reading record sessions.\n\nOpen authored EPUB contents pages and follow their chapter links. Improved navigation repair, precise paragraph selection, reading-data integrity, and accessibility. + Import selectable-text PDFs, FB2, DOCX, and additional text formats. File contents help identify the format, supported formats are clearer in the library, and larger PDF imports are faster.\n\nCheck for and install available updates through Google Play. Improved import validation, Android compatibility, and default RSVP text sizing. + Bionic timed reading joins RSVP, with its own settings, adaptive text rendering, and a reading-mode launcher.\n\nImport pasted text and Markdown, or share text to Kairo from another app.\n\nRSVP gains continuous context and contextual replay, with improved phrase alignment, punctuation timing, loading, and translations. + diff --git a/app/src/test/java/com/kairo/reader/ui/updates/ReleaseNotesTest.kt b/app/src/test/java/com/kairo/reader/ui/updates/ReleaseNotesTest.kt new file mode 100644 index 00000000..66a0006b --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/updates/ReleaseNotesTest.kt @@ -0,0 +1,48 @@ +package com.kairo.reader.ui.updates + +import com.kairo.reader.ui.tutorial.StartingTutorialRoute +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReleaseNotesTest { + @Test + fun offersNewPublishedReleaseToExistingUsersIncludingMigrationAndSkippedVersions() { + listOf(0, 10, 14).forEach { seen -> + assertTrue(shouldOfferReleaseTour(15, 15, seen, hasSeenStartingTutorial = true)) + } + } + + @Test + fun doesNotOfferFreshInstallsAcknowledgedVersionsOrDowngrades() { + assertFalse(shouldOfferReleaseTour(15, 15, 0, hasSeenStartingTutorial = false)) + assertFalse(shouldOfferReleaseTour(15, 15, 15, hasSeenStartingTutorial = true)) + assertFalse(shouldOfferReleaseTour(15, 15, 16, hasSeenStartingTutorial = true)) + } + + @Test + fun unpublishedAndMismatchedNotesNeverAutoLaunch() { + assertFalse(shouldOfferReleaseTour(null, 15, 0, hasSeenStartingTutorial = true)) + assertFalse(shouldOfferReleaseTour(16, 15, 0, hasSeenStartingTutorial = true)) + assertFalse(shouldOfferReleaseTour(14, 15, 0, hasSeenStartingTutorial = true)) + } + + @Test + fun catalogHasUniqueDescendingVersionsAndCompleteNotes() { + val published = releaseNotes.mapNotNull { it.versionCode } + assertTrue(published == published.distinct().sortedDescending()) + assertTrue(releaseNotes.all { (it.version == null) == (it.versionCode == null) }) + assertTrue(releaseNotes.all { it.changes.isNotEmpty() }) + assertTrue(releaseNotes.count { it.versionCode == null } <= 1) + assertTrue(releaseNotes.drop(1).all { it.versionCode != null }) + } + + @Test + fun currentTourCanRunWithoutAnImportedBook() { + assertTrue( + releaseNotes.first().tour.all { + it.route == StartingTutorialRoute.SETTINGS || it.route == StartingTutorialRoute.LIBRARY + } + ) + } +} diff --git a/docs/branching-and-releases.md b/docs/branching-and-releases.md index c7e81b39..3ca3854d 100644 --- a/docs/branching-and-releases.md +++ b/docs/branching-and-releases.md @@ -43,6 +43,40 @@ Allowed release-branch work: Do not add new features on a release branch. +### In-app changelog and feature tour + +Settings → What’s new contains the offline changelog and a replayable feature +tour. Maintain its content in +`app/src/main/java/com/kairo/reader/ui/updates/ReleaseNotes.kt` and the +`values*/release_notes.xml` resources for every supported language. + +During development, keep the newest entry's `version` and `versionCode` null. +It appears as Upcoming and can be toured manually, but never prompts users. +During release preparation, assign the actual `versionName` and `versionCode` +from `app/build.gradle.kts` to that entry. Keep older entries in descending order. +When starting the next version, prepend a new Upcoming entry. A fixes-only +release may leave its tour empty and offer just the changelog. + +Review `git log --no-merges ..HEAD` and the corresponding +diffs. Summarise every user-facing addition and fix in the changelog, grouping +related commits; omit internal build, test, and refactoring work. Pick only a few +features for the tour. Keep its steps on Library or Settings so it works without +an imported book. New targets must also register their highlight bounds and +scroll requesters on those screens. The app uses packaged notes, not Git or a +network service at runtime. + +The initial history covers `v1.0.7..v1.0.8`, `v1.0.8..v1.1.0`, +`v1.1.0..v1.2.0`, and `v1.2.0..v1.3.0`. The first Upcoming entry covers +`v1.3.0..2785d82` plus the new changelog and tour itself. + +Before shipping, run `./gradlew qualityGate :app:compileDebugAndroidTestKotlin` +and the `WhatsNewDeviceTest` instrumentation tests on a device. Check an upgrade +from the previous published version: the invitation appears once in an idle +library, all dismissal paths suppress it, and Settings can replay the tour. +Check a fresh install too: only the starting tutorial should appear. Verify +rotation, Back, large text, landscape, and pending external imports. Resetting +display preferences must not reset the acknowledged release version. + When the release is ready: ```bash