diff --git a/.agents/skills/ha-android-committing/SKILL.md b/.agents/skills/ha-android-committing/SKILL.md index 0825c14dc70..b9f96d5cc4b 100644 --- a/.agents/skills/ha-android-committing/SKILL.md +++ b/.agents/skills/ha-android-committing/SKILL.md @@ -11,7 +11,7 @@ Use this skill when finalizing a change and preparing it for commit or a pull re 1. Format: `./gradlew :build-logic:convention:ktlintFormat ktlintFormat` 2. Tests: `./gradlew test` -3. If the change is visible to end users or changes behavior, add it to `app/src/main/res/xml/changelog_master.xml`. +3. If the change is visible to end users or changes behavior, add it to `app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogContent.kt`. 4. After adding or updating any dependency (in `gradle/libs.versions.toml` or module declarations), run `./gradlew alldependencies --write-locks`. 5. Run the `ha-android-review` skill over the diff before handing it off. diff --git a/.github/workflows/prepareNextRelease.yml b/.github/workflows/prepareNextRelease.yml deleted file mode 100644 index 56d85c521d1..00000000000 --- a/.github/workflows/prepareNextRelease.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Prepare next release -# This workflow prepares the next release by updating the changelog and creating a pull request. -# It is triggered using the GitHub Actions API (in monthly and weekly workflows) or manually. - -on: # yamllint disable-line rule:truthy - workflow_dispatch: - -concurrency: - group: prepare-next-release - cancel-in-progress: true - -jobs: - tag: - name: Prepare next release - runs-on: ubuntu-latest - permissions: - contents: write # To push to weekly_changelog_bump branch - pull-requests: write # To be able to create or update PR - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: true # Needed to be able to push the new branch - - - uses: ./.github/actions/setup-build-env - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - # Without any change compare to the latest tag, reckon will not bump the version. - - name: Add dumb modification to trigger version bump - run: touch dumb_file_to_trigger_version_bump - - - name: Generate version.txt - run: ./gradlew versionFile - - - name: Update changelog_master.xml - run: | - VERSION=$(cat version.txt | sed 's/-.*//') - echo "VERSION=$VERSION" >> $GITHUB_ENV - # Update the version in changelog_master.xml (the command is only working on GNU sed) - sed -i -E '/]*version="[^"]+"/ s/(version=")[^ -]+/\1'"$VERSION"'/' app/src/main/res/xml/changelog_master.xml - - - name: Create changelog update branch - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch origin - git checkout -B weekly_changelog_bump - git add app/src/main/res/xml/changelog_master.xml - git commit -m "Bump changelog_master.xml for weekly release ${VERSION}" - git push -f origin weekly_changelog_bump - - - name: Create or update change log update PR - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - VERSION: ${{ env.VERSION }} - with: - script: | - const version = process.env.VERSION; - const { owner, repo } = context.repo; - const head = 'weekly_changelog_bump'; - const base = 'main'; - const title = `Bump changelog_master.xml for weekly release ${version}`; - const body = [ - 'Automated PR to update changelog_master.xml for weekly release.', - ].join('\n'); - - // Search for existing open PR from the branch - const prs = await github.rest.pulls.list({ - owner: owner, - repo: repo, - head: `${owner}:${head}`, - base: base, - state: 'open' - }); - - if (prs.data.length > 0) { - // Update the existing PR - const pr = prs.data[0]; - await github.rest.pulls.update({ - owner: owner, - repo: repo, - pull_number: pr.number, - title: title, - body: body, - base: base - }); - core.info(`Updated existing PR #${pr.number}`); - } else { - // Create a new PR - await github.rest.pulls.create({ - owner: owner, - repo: repo, - title: title, - head: head, - base: base, - body: body - }); - core.info('Created new PR'); - } diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 577ed34fcbc..dba36ef178b 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -38,14 +38,3 @@ jobs: "beta": "true", }, }) - - - name: Trigger Prepare Next Release - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'prepareNextRelease.yml', - ref: 'main', - }) diff --git a/AGENTS.md b/AGENTS.md index 5661cf11f92..3c69b394854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc ## Pull Requests -When creating a pull request, use `.github/pull_request_template.md` as the PR body. Keep PRs small and focused. If the change is visible to end users, add it to the changelog in `app/src/main/res/xml/changelog_master.xml`. Before committing, format with ktlint and run the tests. +When creating a pull request, use `.github/pull_request_template.md` as the PR body. Keep PRs small and focused. If the change is visible to end users, add it to the changelog in `app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogContent.kt`. Before committing, format with ktlint and run the tests. ## AI policy diff --git a/app/gradle.lockfile b/app/gradle.lockfile index d59736a31e9..6307f8840b0 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -312,7 +312,6 @@ com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-androi com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.caverock:androidsvg-aar:1.4=fullDebugAndroidTestLintChecksClasspath,fullDebugLintChecksClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestLintChecksClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestLintChecksClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseLintChecksClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestLintChecksClasspath,fullReleaseScreenshotTestRuntimeClasspath,minimalDebugAndroidTestLintChecksClasspath,minimalDebugLintChecksClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestLintChecksClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestLintChecksClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseLintChecksClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestLintChecksClasspath,minimalReleaseScreenshotTestRuntimeClasspath -com.github.AppDevNext:ChangeLog:3.8.2=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestLintChecksClasspath,fullDebugCompileClasspath,fullDebugLintChecksClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestLintChecksClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestLintChecksClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseLintChecksClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestLintChecksClasspath,fullReleaseScreenshotTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestLintChecksClasspath,minimalDebugCompileClasspath,minimalDebugLintChecksClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestLintChecksClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestLintChecksClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseLintChecksClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestLintChecksClasspath,minimalReleaseScreenshotTestRuntimeClasspath com.github.Dimezis:BlurView:version-2.0.6=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestLintChecksClasspath,fullDebugCompileClasspath,fullDebugLintChecksClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestLintChecksClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestLintChecksClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseLintChecksClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestLintChecksClasspath,fullReleaseScreenshotTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestLintChecksClasspath,minimalDebugCompileClasspath,minimalDebugLintChecksClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestLintChecksClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestLintChecksClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseLintChecksClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestLintChecksClasspath,minimalReleaseScreenshotTestRuntimeClasspath com.github.ajalt.clikt:clikt-jvm:5.0.2=ktlint com.github.ajalt.clikt:clikt:5.0.2=ktlint diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/Changelog.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/Changelog.kt new file mode 100644 index 00000000000..67a403bc8f7 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/Changelog.kt @@ -0,0 +1,83 @@ +package io.homeassistant.companion.android.changelog + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.theme.HAColorScheme +import io.homeassistant.companion.android.frontend.navigation.WidgetType +import io.homeassistant.companion.android.settings.SettingsActivity + +/** + * The platforms a changelog entry can apply to, in chip display order. + */ +enum class ChangelogPlatform(@field:StringRes val labelRes: Int) { + APP(commonR.string.changelog_platform_app), + AUTOMOTIVE(commonR.string.changelog_platform_automotive), + WEAR(commonR.string.changelog_platform_wear), +} + +/** + * The kinds of changes a changelog groups its entries under, each fully describing how its + * section header is displayed. + * + * @property labelRes The header label of the section. + * @property markerColor The color of the section marker, resolved from the current color scheme + * so it adapts to the theme. + */ +enum class ChangelogCategory(@field:StringRes val labelRes: Int, val markerColor: (HAColorScheme) -> Color) { + NEW(commonR.string.changelog_category_new, HAColorScheme::colorFillSuccessLoudResting), + IMPROVED(commonR.string.changelog_category_improved, HAColorScheme::colorFillPrimaryLoudResting), + FIXED(commonR.string.changelog_category_fixed, HAColorScheme::colorFillNeutralLoudResting), +} + +/** + * An action performed when the user taps a changelog entry. An entry with an action is rendered + * as a clickable row with a chevron. + */ +sealed interface ChangelogAction { + /** Opens [url] in the browser or the matching app. */ + data class OpenUrl(val url: String) : ChangelogAction + + /** Opens the settings on the screen targeted by [deeplink]. */ + data class OpenSettings(val deeplink: SettingsActivity.Deeplink) : ChangelogAction + + /** Opens the configuration screen of [widgetType] without a preselected entity. */ + data class OpenWidgetConfig(val widgetType: WidgetType) : ChangelogAction +} + +/** + * One change of a release. + * + * @property contentRes The change description, a string resource from `strings_changelog.xml` + * in `:common`. Escaped inline HTML (like `<b>`) is rendered as styling. + * @property platforms The platforms this change applies to. + * @property action Optional action performed when the user taps the entry. + */ +data class ChangelogEntry( + @param:StringRes val contentRes: Int, + val platforms: Set, + val action: ChangelogAction? = null, +) + +/** + * The changes of one release, one field per category so a category cannot appear twice. + * A category without entries is not displayed. + */ +data class Changelog( + val new: List = emptyList(), + val improved: List = emptyList(), + val fixed: List = emptyList(), +) { + /** The non-empty sections of this changelog, in display order. */ + fun toSections(): List = listOf( + ChangelogSection(ChangelogCategory.NEW, new), + ChangelogSection(ChangelogCategory.IMPROVED, improved), + ChangelogSection(ChangelogCategory.FIXED, fixed), + ).filter { it.entries.isNotEmpty() } +} + +/** + * A group of [entries] of the same [category], displayed under one header. Derived from a + * [Changelog] through [Changelog.toSections], not meant to be built directly. + */ +data class ChangelogSection(val category: ChangelogCategory, val entries: List) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogContent.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogContent.kt new file mode 100644 index 00000000000..bbb2947024d --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogContent.kt @@ -0,0 +1,44 @@ +package io.homeassistant.companion.android.changelog + +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.frontend.navigation.WidgetType +import io.homeassistant.companion.android.settings.SettingsActivity + +/** + * The changelog content of the current release, with the displayed strings in the dedicated + * `strings_changelog.xml` file of `:common` so they get translated. Entries not yet translated + * fall back to English. + * + * Update this together with the release notes; the changelog screenshot test pins the rendered + * result. + */ +internal val currentChangelog = Changelog( + new = listOf( + ChangelogEntry( + contentRes = commonR.string.changelog_entry_assistant_volume, + platforms = setOf(ChangelogPlatform.APP, ChangelogPlatform.AUTOMOTIVE, ChangelogPlatform.WEAR), + ), + ), + improved = listOf( + ChangelogEntry( + contentRes = commonR.string.changelog_entry_health_connect, + platforms = setOf(ChangelogPlatform.APP, ChangelogPlatform.AUTOMOTIVE), + ), + ChangelogEntry( + contentRes = commonR.string.changelog_entry_entity_widgets, + platforms = setOf(ChangelogPlatform.APP), + action = ChangelogAction.OpenWidgetConfig(WidgetType.Entity), + ), + ChangelogEntry( + contentRes = commonR.string.changelog_entry_tiles, + platforms = setOf(ChangelogPlatform.APP), + action = ChangelogAction.OpenSettings(SettingsActivity.Deeplink.QSTile()), + ), + ), + fixed = listOf( + ChangelogEntry( + contentRes = commonR.string.changelog_entry_bug_fixes, + platforms = setOf(ChangelogPlatform.APP, ChangelogPlatform.AUTOMOTIVE, ChangelogPlatform.WEAR), + ), + ), +) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogFragment.kt new file mode 100644 index 00000000000..75d86ffd82e --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogFragment.kt @@ -0,0 +1,66 @@ +package io.homeassistant.companion.android.changelog + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.changelog.ui.ChangelogContent +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.theme.HATheme +import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme +import io.homeassistant.companion.android.settings.SettingsActivity +import io.homeassistant.companion.android.util.safeBottomWindowInsets + +/** + * Hosts the changelog within the settings, whose activity already provides the toolbar with the + * title and back navigation. + */ +@AndroidEntryPoint +class ChangelogFragment : Fragment() { + + private val viewModel: ChangelogViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ComposeView(requireContext()).apply { + setContent { + HATheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + ChangelogContent( + uiState = uiState, + onGotItClick = { parentFragmentManager.popBackStack() }, + onActionClick = ::onActionClick, + modifier = Modifier + .background(LocalHAColorScheme.current.colorSurfaceDefault) + .windowInsetsPadding(safeBottomWindowInsets(applyHorizontal = false)), + ) + } + } + } + } + + override fun onResume() { + super.onResume() + activity?.title = getString(commonR.string.changelog_screen_title) + } + + private fun onActionClick(action: ChangelogAction) { + when (action) { + is ChangelogAction.OpenUrl -> startActivity(Intent(Intent.ACTION_VIEW, action.url.toUri())) + is ChangelogAction.OpenSettings -> + startActivity(SettingsActivity.newInstance(requireContext(), action.deeplink)) + is ChangelogAction.OpenWidgetConfig -> + startActivity(action.widgetType.toConfigureIntent(requireContext())) + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogShowViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogShowViewModel.kt new file mode 100644 index 00000000000..64f8a8029bc --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogShowViewModel.kt @@ -0,0 +1,37 @@ +package io.homeassistant.companion.android.changelog + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.BuildConfig +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import javax.inject.Inject + +/** + * Decides whether the changelog screen should be shown. + */ +@HiltViewModel +class ChangelogShowViewModel @VisibleForTesting constructor( + private val prefsRepository: PrefsRepository, + private val currentVersionCode: Int, +) : ViewModel() { + + @Inject + constructor(prefsRepository: PrefsRepository) : this(prefsRepository, BuildConfig.VERSION_CODE) + + private var consumed = false + + /** + * Returns `true` when the user has enabled the changelog popup and the changelog of the + * current app version has not been seen yet. + * + * The decision is consumed: it returns `true` at most once per instance so configuration + * changes or returning to the frontend don't show the changelog again. + */ + suspend fun consumeShouldShowChangelog(): Boolean { + if (consumed) return false + consumed = true + return prefsRepository.isChangeLogPopupEnabled() && + prefsRepository.wasAppUpdatedSinceChangelogSeen(currentVersionCode) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogViewModel.kt new file mode 100644 index 00000000000..974b5ebcdbc --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ChangelogViewModel.kt @@ -0,0 +1,71 @@ +package io.homeassistant.companion.android.changelog + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.BuildConfig +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.di.qualifiers.IsAutomotive +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +private const val RELEASE_TAG_URL_PREFIX = "https://github.com/home-assistant/android/releases/tag/" + +/** Flavor suffixes appended to the version name that are meaningless to the user. */ +private val FLAVOR_SUFFIXES = listOf("-full", "-minimal") + +/** + * @property versionName The version of the app currently running, without the flavor suffix. + * @property releaseUrl URL of the GitHub release page of [versionName]. + * @property currentPlatform The platform the app is running on, emphasized on the entries' tags. + * @property sections The changelog content, in display order. + */ +data class ChangelogUiState( + val versionName: String, + val releaseUrl: String, + val currentPlatform: ChangelogPlatform, + val sections: List, +) + +/** + * ViewModel of the changelog screen. + * + * Marks the changelog as seen as soon as the screen is shown, so it is not shown again for the + * current app version regardless of how the user leaves the screen. + */ +@HiltViewModel +class ChangelogViewModel @VisibleForTesting constructor( + prefsRepository: PrefsRepository, + isAutomotive: Boolean, + rawVersionName: String, + currentVersionCode: Int, +) : ViewModel() { + + @Inject + constructor( + prefsRepository: PrefsRepository, + @IsAutomotive isAutomotive: Boolean, + ) : this(prefsRepository, isAutomotive, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + + val uiState: StateFlow = MutableStateFlow( + run { + val versionName = FLAVOR_SUFFIXES.fold(rawVersionName, String::removeSuffix) + ChangelogUiState( + versionName = versionName, + releaseUrl = RELEASE_TAG_URL_PREFIX + versionName, + currentPlatform = if (isAutomotive) ChangelogPlatform.AUTOMOTIVE else ChangelogPlatform.APP, + sections = currentChangelog.toSections(), + ) + }, + ).asStateFlow() + + init { + viewModelScope.launch { + prefsRepository.markChangelogSeen(currentVersionCode) + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/navigation/ChangelogNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/navigation/ChangelogNavigation.kt new file mode 100644 index 00000000000..89b32ff9ce8 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/navigation/ChangelogNavigation.kt @@ -0,0 +1,74 @@ +package io.homeassistant.companion.android.changelog.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import io.homeassistant.companion.android.changelog.ChangelogAction +import io.homeassistant.companion.android.changelog.ChangelogShowViewModel +import io.homeassistant.companion.android.changelog.ui.ChangelogScreen +import io.homeassistant.companion.android.frontend.navigation.FrontendRoute +import io.homeassistant.companion.android.settings.navigation.navigateToSettings +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +internal data object ChangelogRoute + +internal fun NavController.navigateToChangelog(navOptions: NavOptions? = null) { + navigate(ChangelogRoute, navOptions) +} + +/** + * Registers the changelog destination, displaying the changes of the app version currently + * running. Opening the destination marks the changelog as seen. + * + * @param onOpenUrl Invoked with the URL of a [ChangelogAction.OpenUrl] the user tapped. + */ +internal fun NavGraphBuilder.changelogScreen(navController: NavController, onOpenUrl: suspend (String) -> Unit) { + composable { + val coroutineScope = rememberCoroutineScope() + ChangelogScreen( + viewModel = hiltViewModel(), + onCloseClick = { navController.popBackStack() }, + onActionClick = { action -> + when (action) { + is ChangelogAction.OpenUrl -> coroutineScope.launch { onOpenUrl(action.url) } + is ChangelogAction.OpenSettings -> navController.navigateToSettings(action.deeplink) + is ChangelogAction.OpenWidgetConfig -> navController.context.startActivity( + action.widgetType.toConfigureIntent(navController.context), + ) + } + }, + ) + } +} + +/** + * Navigates to the changelog once the frontend is displayed after an app update, unless the user + * disabled the automatic changelog popup. + * + * The decision is delegated to (and consumed from) [ChangelogShowViewModel] so it happens at + * most once per process and survives configuration changes. + */ +@Composable +internal fun ChangelogAutoShowEffect( + navController: NavController, + viewModel: ChangelogShowViewModel = hiltViewModel(), +) { + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val isOnFrontend = currentBackStackEntry?.destination?.hasRoute() == true + + LaunchedEffect(isOnFrontend) { + if (isOnFrontend && viewModel.consumeShouldShowChangelog()) { + navController.navigateToChangelog() + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ui/ChangelogScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ui/ChangelogScreen.kt new file mode 100644 index 00000000000..b4c186dad2b --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/changelog/ui/ChangelogScreen.kt @@ -0,0 +1,328 @@ +package io.homeassistant.companion.android.changelog.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.homeassistant.companion.android.changelog.ChangelogAction +import io.homeassistant.companion.android.changelog.ChangelogEntry +import io.homeassistant.companion.android.changelog.ChangelogPlatform +import io.homeassistant.companion.android.changelog.ChangelogSection +import io.homeassistant.companion.android.changelog.ChangelogUiState +import io.homeassistant.companion.android.changelog.ChangelogViewModel +import io.homeassistant.companion.android.changelog.currentChangelog +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.composable.HAAccentButton +import io.homeassistant.companion.android.common.compose.composable.HALabel +import io.homeassistant.companion.android.common.compose.composable.HATopBar +import io.homeassistant.companion.android.common.compose.composable.HAVerticalDivider +import io.homeassistant.companion.android.common.compose.composable.LabelSize +import io.homeassistant.companion.android.common.compose.composable.LabelVariant +import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.common.compose.theme.HASize +import io.homeassistant.companion.android.common.compose.theme.HATextStyle +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme + +/** + * Displays what changed in the current app version, for the app itself and its Wear OS and + * Automotive companions. + */ +@Composable +internal fun ChangelogScreen( + viewModel: ChangelogViewModel, + onCloseClick: () -> Unit, + onActionClick: (ChangelogAction) -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ChangelogScreenContent( + uiState = uiState, + onCloseClick = onCloseClick, + onActionClick = onActionClick, + modifier = modifier, + ) +} + +@Composable +internal fun ChangelogScreenContent( + uiState: ChangelogUiState, + onCloseClick: () -> Unit, + onActionClick: (ChangelogAction) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + HATopBar( + title = { Text(stringResource(commonR.string.changelog_screen_title)) }, + onCloseClick = onCloseClick, + ) + }, + ) { contentPadding -> + ChangelogContent( + uiState = uiState, + onGotItClick = onCloseClick, + onActionClick = onActionClick, + modifier = Modifier.padding(contentPadding), + ) + } +} + +/** + * The changelog content with its "Got it" button, without any top bar, so it can also be hosted + * where a toolbar already exists (like the settings). + */ +@Composable +internal fun ChangelogContent( + uiState: ChangelogUiState, + onGotItClick: () -> Unit, + onActionClick: (ChangelogAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = HADimens.SPACE4), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + ) { + ChangelogHeader( + versionName = uiState.versionName, + releaseUrl = uiState.releaseUrl, + onActionClick = onActionClick, + ) + uiState.sections.forEach { section -> + ChangelogSectionContent( + section = section, + currentPlatform = uiState.currentPlatform, + onActionClick = onActionClick, + ) + } + } + + HAAccentButton( + text = stringResource(commonR.string.changelog_got_it), + onClick = onGotItClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = HADimens.SPACE4) + .align(Alignment.CenterHorizontally), + ) + } +} + +@Composable +private fun ColumnScope.ChangelogHeader( + versionName: String, + releaseUrl: String, + onActionClick: (ChangelogAction) -> Unit, +) { + Text( + text = versionName, + style = HATextStyle.Headline.copy(textAlign = TextAlign.Start), + modifier = Modifier + .fillMaxWidth() + .padding(top = HADimens.SPACE4), + ) + + val linkStyles = HATextStyle.Link + val subtitle = buildAnnotatedString { + append(stringResource(commonR.string.changelog_subtitle_platforms)) + append(" · ") + withLink( + LinkAnnotation.Url( + url = releaseUrl, + styles = linkStyles, + linkInteractionListener = { onActionClick(ChangelogAction.OpenUrl(releaseUrl)) }, + ), + ) { + append(stringResource(commonR.string.changelog_release_notes)) + } + } + Text( + text = subtitle, + style = HATextStyle.UserInput, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +@Suppress("UnusedReceiverParameter") // Scopes the multi-element emission to a Column +private fun ColumnScope.ChangelogSectionContent( + section: ChangelogSection, + currentPlatform: ChangelogPlatform, + onActionClick: (ChangelogAction) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + modifier = Modifier.padding(top = HADimens.SPACE4), + ) { + Box( + modifier = Modifier + .size(HASize.X2S) + .background(color = section.category.markerColor(LocalHAColorScheme.current), shape = CircleShape), + ) + Text( + text = stringResource(section.category.labelRes).uppercase(), + style = HATextStyle.BodyMedium.copy(textAlign = TextAlign.Start), + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + // Bound the height to the entries so the divider can fill it + modifier = Modifier.height(IntrinsicSize.Min), + ) { + // As wide as the section marker above, so the divider is centered under it and the + // entries align with the section label + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(HASize.X2S) + .fillMaxHeight(), + ) { + HAVerticalDivider(modifier = Modifier.fillMaxHeight()) + } + Column { + section.entries.forEach { entry -> + ChangelogEntryContent( + entry = entry, + currentPlatform = currentPlatform, + onActionClick = onActionClick, + ) + } + } + } +} + +@Composable +private fun ChangelogEntryContent( + entry: ChangelogEntry, + currentPlatform: ChangelogPlatform, + onActionClick: (ChangelogAction) -> Unit, +) { + val action = entry.action + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .then( + if (action != null) { + Modifier.clickable( + onClickLabel = stringResource(commonR.string.changelog_entry_open), + role = Role.Button, + ) { onActionClick(action) } + } else { + Modifier + }, + ) + .padding(vertical = HADimens.SPACE2), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + ) { + val contentHtml = stringResource(entry.contentRes) + val content = remember(contentHtml) { AnnotatedString.fromHtml(contentHtml) } + Text( + text = content, + style = HATextStyle.Body.copy( + color = LocalHAColorScheme.current.colorTextPrimary, + textAlign = TextAlign.Start, + ), + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE1), + ) { + ChangelogPlatform.entries.filter { it in entry.platforms }.forEach { platform -> + PlatformTag(platform = platform, isCurrent = platform == currentPlatform) + } + } + } + if (action != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = LocalHAColorScheme.current.colorOnNeutralQuiet, + ) + } + } +} + +@Composable +private fun PlatformTag(platform: ChangelogPlatform, isCurrent: Boolean) { + val label = stringResource(platform.labelRes) + HALabel( + text = if (isCurrent) { + stringResource(commonR.string.changelog_platform_this_device, label) + } else { + label + }, + variant = if (isCurrent) LabelVariant.PRIMARY else LabelVariant.NEUTRAL, + size = LabelSize.SMALL, + ) +} + +@Preview +@Composable +private fun ChangelogScreenPreview() { + HAThemeForPreview { + ChangelogScreenContent( + uiState = ChangelogUiState( + versionName = "2026.7.6", + releaseUrl = "https://github.com/home-assistant/android/releases/tag/2026.7.6", + currentPlatform = ChangelogPlatform.APP, + sections = currentChangelog.toSections(), + ), + onCloseClick = {}, + onActionClick = {}, + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt index 812a7de1568..dc5199c2b0e 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/WidgetType.kt @@ -16,27 +16,28 @@ import io.homeassistant.companion.android.widgets.todo.TodoWidgetConfigureActivi sealed interface WidgetType { /** - * Builds the configuration [Intent] for this widget type, pre-filled with [entityId]. + * Builds the configuration [Intent] for this widget type, pre-filled with [entityId] when + * one is given. */ - fun toConfigureIntent(context: Context, entityId: String): Intent + fun toConfigureIntent(context: Context, entityId: String? = null): Intent data object Entity : WidgetType { - override fun toConfigureIntent(context: Context, entityId: String): Intent = + override fun toConfigureIntent(context: Context, entityId: String?): Intent = EntityWidgetConfigureActivity.newInstance(context, entityId) } data object MediaPlayer : WidgetType { - override fun toConfigureIntent(context: Context, entityId: String): Intent = + override fun toConfigureIntent(context: Context, entityId: String?): Intent = MediaPlayerControlsWidgetConfigureActivity.newInstance(context, entityId) } data object Camera : WidgetType { - override fun toConfigureIntent(context: Context, entityId: String): Intent = + override fun toConfigureIntent(context: Context, entityId: String?): Intent = CameraWidgetConfigureActivity.newInstance(context, entityId) } data object Todo : WidgetType { - override fun toConfigureIntent(context: Context, entityId: String): Intent = + override fun toConfigureIntent(context: Context, entityId: String?): Intent = TodoWidgetConfigureActivity.newInstance(context, entityId) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt index 76a077662d8..22a669acef5 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt @@ -39,6 +39,7 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import io.homeassistant.companion.android.authenticator.Authenticator import io.homeassistant.companion.android.authenticator.Authenticator.Companion.AuthenticationResult +import io.homeassistant.companion.android.changelog.navigation.ChangelogAutoShowEffect import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.sensors.SensorWorker @@ -47,7 +48,6 @@ import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.frontend.navigation.FrontendTarget import io.homeassistant.companion.android.launch.applock.HazeLockOverlay import io.homeassistant.companion.android.sensors.SensorReceiver -import io.homeassistant.companion.android.util.ChangeLog import io.homeassistant.companion.android.util.CheckLocationDisabledUseCase import io.homeassistant.companion.android.util.PLAY_SERVICES_FLAVOR_DOC_URL import io.homeassistant.companion.android.util.PlayServicesAvailability @@ -95,9 +95,6 @@ class LaunchActivity : AppCompatActivity() { @Inject internal lateinit var checkLocalNetworkPermission: CheckLocalNetworkPermissionUseCase - @Inject - internal lateinit var changeLog: ChangeLog - /** * Represents deep link actions that can be passed to [LaunchActivity] to navigate to specific destinations. */ @@ -213,6 +210,8 @@ class LaunchActivity : AppCompatActivity() { navController = navController, ) + ChangelogAutoShowEffect(navController) + HAApp( navController = navController, startDestination = (uiState as? LaunchUiState.Ready)?.startDestination, @@ -251,7 +250,6 @@ class LaunchActivity : AppCompatActivity() { WebsocketManager.start(this@LaunchActivity) checkLocationDisabled() checkLocalNetworkPermission() - changeLog.showChangeLog(this@LaunchActivity, forceShow = false) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt index 86a8cf233a5..9f104ec0fca 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt @@ -69,7 +69,9 @@ class SettingsActivity : BaseActivity() { data object Developer : Deeplink data class HomeNetwork(val serverId: Int) : Deeplink data object NotificationHistory : Deeplink - data class QSTile(val tileId: String) : Deeplink + + /** Opens the tile settings, preselecting [tileId] when one is given. */ + data class QSTile(val tileId: String? = null) : Deeplink data class Sensor(val sensorId: String) : Deeplink data object Websocket : Deeplink data object AssistSettings : Deeplink diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt index a2851d7817c..f3159363076 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt @@ -28,6 +28,7 @@ import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.R import io.homeassistant.companion.android.WIPFeature import io.homeassistant.companion.android.authenticator.Authenticator.Companion.AuthenticationResult +import io.homeassistant.companion.android.changelog.ChangelogFragment import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.util.SdkVersion import io.homeassistant.companion.android.common.util.isAutomotive @@ -354,8 +355,9 @@ class SettingsFragment( } findPreference("changelog_prompt")?.setOnPreferenceClickListener { - lifecycleScope.launch { - presenter.showChangeLog(requireContext()) + parentFragmentManager.commit { + replace(R.id.content, ChangelogFragment::class.java, null) + addToBackStack(getString(commonR.string.changelog_screen_title)) } true } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt index 410a6d1b7c3..c5ebb0183a1 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenter.kt @@ -21,7 +21,6 @@ interface SettingsPresenter { fun getSuggestionFlow(): StateFlow suspend fun getServersFlow(): Flow> suspend fun getNotificationRateLimits(): RateLimitResponse? - suspend fun showChangeLog(context: Context) suspend fun isChangeLogPopupEnabled(): Boolean suspend fun setChangeLogPopupEnabled(enabled: Boolean) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt index dfdfd47e2a1..3ef0e9579fc 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt @@ -20,7 +20,6 @@ import io.homeassistant.companion.android.database.settings.SettingsDao import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager import io.homeassistant.companion.android.settings.language.LanguagesManager import io.homeassistant.companion.android.themes.NightModeManager -import io.homeassistant.companion.android.util.ChangeLog import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,7 +38,6 @@ class SettingsPresenterImpl @Inject constructor( private val prefsRepository: PrefsRepository, private val nightModeManager: NightModeManager, private val langsManager: LanguagesManager, - private val changeLog: ChangeLog, private val settingsDao: SettingsDao, private val defaultAssistantManager: DefaultAssistantManager, ) : PreferenceDataStore(), @@ -160,10 +158,6 @@ class SettingsPresenterImpl @Inject constructor( } } - override suspend fun showChangeLog(context: Context) { - changeLog.showChangeLog(context, true) - } - override suspend fun isChangeLogPopupEnabled(): Boolean { return prefsRepository.isChangeLogPopupEnabled() } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/ChangeLog.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/ChangeLog.kt deleted file mode 100755 index 3aa12461444..00000000000 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/ChangeLog.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.homeassistant.companion.android.util - -import android.content.Context -import android.content.res.Configuration -import android.view.ContextThemeWrapper -import androidx.annotation.VisibleForTesting -import info.hannes.changelog.ChangeLog -import io.homeassistant.companion.android.R -import io.homeassistant.companion.android.common.data.prefs.NightModeTheme -import io.homeassistant.companion.android.common.data.prefs.PrefsRepository -import io.homeassistant.companion.android.themes.NightModeManager -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -open class ChangeLog @Inject constructor( - val nightModeManager: NightModeManager, - private val prefsRepository: PrefsRepository, -) { - @VisibleForTesting - internal open fun createChangeLog(context: Context): ChangeLog { - return ChangeLog(context) - } - - @VisibleForTesting - internal open fun createDarkThemeChangeLog(context: Context): DarkThemeChangeLog { - return DarkThemeChangeLog(context) - } - - suspend fun showChangeLog(context: Context, forceShow: Boolean) { - // Check if user has enabled change log popup or this is a forced show - if (!forceShow && !prefsRepository.isChangeLogPopupEnabled()) { - return - } - - val isDarkTheme = when (nightModeManager.getCurrentNightMode()) { - NightModeTheme.ANDROID, NightModeTheme.SYSTEM -> { - val nightModeFlags = - context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - nightModeFlags == Configuration.UI_MODE_NIGHT_YES - } - NightModeTheme.DARK -> true - else -> false - } - - // Ensure UI operations happen on Main thread - withContext(Dispatchers.Main) { - val changeLog = if (isDarkTheme) { - createDarkThemeChangeLog(context) - } else { - createChangeLog(context) - } - if ((!changeLog.isFirstRunEver && changeLog.isFirstRun) || forceShow) { - changeLog.fullLogDialog.show() - } - } - } -} - -class DarkThemeChangeLog internal constructor(context: Context) : - ChangeLog(ContextThemeWrapper(context, R.style.Theme_HomeAssistant_PopupTheme), DARK_THEME_CSS) { - companion object { - internal const val DARK_THEME_CSS = "body { color: #ffffff; background-color: #282828; }\n$DEFAULT_CSS" - } -} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt index c98df6cc60f..435915b687b 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt @@ -10,6 +10,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import io.homeassistant.companion.android.automotive.navigation.carAppActivity import io.homeassistant.companion.android.automotive.navigation.navigateToCarAppActivity +import io.homeassistant.companion.android.changelog.navigation.changelogScreen import io.homeassistant.companion.android.common.util.DisabledLocationHandler import io.homeassistant.companion.android.common.util.FailFast import io.homeassistant.companion.android.common.util.isAutomotive @@ -133,6 +134,10 @@ internal fun HANavHost( onRequestFullscreen = onRequestFullscreen, onPipReadinessChanged = onPipReadinessChanged, ) + changelogScreen( + navController = navController, + onOpenUrl = { url -> navController.navigateToUri(url, onShowSnackbar) }, + ) setHomeNetworkScreen( onGotoNextScreen = { navController.popBackStack(inclusive = false) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt index 782ee45cd57..643547f8c95 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt @@ -35,9 +35,9 @@ import timber.log.Timber class CameraWidgetConfigureActivity : BaseWidgetConfigureActivity() { companion object { - fun newInstance(context: Context, entityId: String): Intent { + fun newInstance(context: Context, entityId: String? = null): Intent { return Intent(context, CameraWidgetConfigureActivity::class.java).apply { - putExtra(FOR_ENTITY, entityId) + entityId?.let { putExtra(FOR_ENTITY, it) } putExtra(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, true) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt index 951f78a8015..f66be0f2e1f 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt @@ -20,9 +20,9 @@ class EntityWidgetConfigureActivity : BaseActivity() { companion object { private const val FOR_ENTITY = "for_entity" - fun newInstance(context: Context, entityId: String): Intent { + fun newInstance(context: Context, entityId: String? = null): Intent { return Intent(context, EntityWidgetConfigureActivity::class.java).apply { - putExtra(FOR_ENTITY, entityId) + entityId?.let { putExtra(FOR_ENTITY, it) } putExtra(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, true) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt index 980c821fc95..b9f498e6004 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt @@ -37,9 +37,9 @@ class MediaPlayerControlsWidgetConfigureActivity : BaseWidgetConfigureActivity() { companion object { - fun newInstance(context: Context, entityId: String): Intent { + fun newInstance(context: Context, entityId: String? = null): Intent { return Intent(context, MediaPlayerControlsWidgetConfigureActivity::class.java).apply { - putExtra(FOR_ENTITY, entityId) + entityId?.let { putExtra(FOR_ENTITY, it) } putExtra(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, true) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/todo/TodoWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/todo/TodoWidgetConfigureActivity.kt index 12b5a980038..02d498b1172 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/todo/TodoWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/todo/TodoWidgetConfigureActivity.kt @@ -65,9 +65,9 @@ class TodoWidgetConfigureActivity : BaseActivity() { companion object { private const val FOR_ENTITY = "for_entity" - fun newInstance(context: Context, entityId: String): Intent { + fun newInstance(context: Context, entityId: String? = null): Intent { return Intent(context, TodoWidgetConfigureActivity::class.java).apply { - putExtra(FOR_ENTITY, entityId) + entityId?.let { putExtra(FOR_ENTITY, it) } putExtra(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, true) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml deleted file mode 100755 index aa7d3a5d66b..00000000000 --- a/app/src/main/res/xml/changelog_master.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - Android 17: added assistant volume level sensor + notification command for control - Health Connect sleep duration sensor now ignores awake and out of bed time - Modernized settings for entity widgets and tiles - Bug fixes and dependency updates - - - Android 17: added assistant volume level sensor - Bug fixes and dependency updates - - - Android 17: added assistant volume level sensor + notification command for control - Health Connect sleep duration sensor now ignores awake and out of bed time - Bug fixes and dependency updates - - diff --git a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest.kt new file mode 100644 index 00000000000..370f9696ff1 --- /dev/null +++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest.kt @@ -0,0 +1,32 @@ +package io.homeassistant.companion.android.changelog + +import androidx.compose.runtime.Composable +import com.android.tools.screenshot.PreviewTest +import io.homeassistant.companion.android.changelog.ui.ChangelogScreenContent +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.util.compose.HAPreviews + +/** + * Renders the changelog with the real authored content of the current release, so a content + * change shows up as a screenshot diff. + */ +class ChangelogScreenshotTest { + + @PreviewTest + @HAPreviews + @Composable + fun `Changelog with current release content`() { + HAThemeForPreview { + ChangelogScreenContent( + uiState = ChangelogUiState( + versionName = "20XX.X.X", + releaseUrl = "https://github.com/home-assistant/android/releases/tag/2026.7.6", + currentPlatform = ChangelogPlatform.APP, + sections = currentChangelog.toSections(), + ), + onCloseClick = {}, + onActionClick = {}, + ) + } + } +} diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_foldable_c908f502_0.png new file mode 100644 index 00000000000..2ce4bd73cdb Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_phone_e05166be_0.png new file mode 100644 index 00000000000..e0a206e542f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..66a91e943b0 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..4bfc7eb15b3 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..9f955c38d2f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..607bcdd3d84 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/changelog/ChangelogScreenshotTest/Changelog with current release content_tablet_landscape_62cae397_0.png differ diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogShowViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogShowViewModelTest.kt new file mode 100644 index 00000000000..15a5a18b2f0 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogShowViewModelTest.kt @@ -0,0 +1,57 @@ +package io.homeassistant.companion.android.changelog + +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +private const val CURRENT_VERSION_CODE = 42 + +class ChangelogShowViewModelTest { + + private val prefsRepository: PrefsRepository = mockk() + private val viewModel = ChangelogShowViewModel(prefsRepository, CURRENT_VERSION_CODE) + + @Test + fun `Given enabled popup and unseen changelog when consuming then returns true only once`() = runTest { + coEvery { prefsRepository.isChangeLogPopupEnabled() } returns true + coEvery { prefsRepository.wasAppUpdatedSinceChangelogSeen(CURRENT_VERSION_CODE) } returns true + + assertTrue(viewModel.consumeShouldShowChangelog()) + assertFalse(viewModel.consumeShouldShowChangelog()) + + coVerify(exactly = 1) { prefsRepository.wasAppUpdatedSinceChangelogSeen(CURRENT_VERSION_CODE) } + } + + @Test + fun `Given disabled popup when consuming then returns false without checking if the app was updated`() = runTest { + coEvery { prefsRepository.isChangeLogPopupEnabled() } returns false + + assertFalse(viewModel.consumeShouldShowChangelog()) + + // wasAppUpdatedSinceChangelogSeen marks a fresh install as seen, the short-circuit must prevent that + coVerify(exactly = 0) { prefsRepository.wasAppUpdatedSinceChangelogSeen(any()) } + } + + @Test + fun `Given seen changelog when consuming then returns false`() = runTest { + coEvery { prefsRepository.isChangeLogPopupEnabled() } returns true + coEvery { prefsRepository.wasAppUpdatedSinceChangelogSeen(CURRENT_VERSION_CODE) } returns false + + assertFalse(viewModel.consumeShouldShowChangelog()) + } + + @Test + fun `Given consumed decision when consuming again then repositories are not queried again`() = runTest { + coEvery { prefsRepository.isChangeLogPopupEnabled() } returns false + + assertFalse(viewModel.consumeShouldShowChangelog()) + assertFalse(viewModel.consumeShouldShowChangelog()) + + coVerify(exactly = 1) { prefsRepository.isChangeLogPopupEnabled() } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogTest.kt new file mode 100644 index 00000000000..32672a0a4aa --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogTest.kt @@ -0,0 +1,33 @@ +package io.homeassistant.companion.android.changelog + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ChangelogTest { + + private fun entry() = ChangelogEntry(android.R.string.ok, setOf(ChangelogPlatform.APP)) + + @Test + fun `Given entries in every category when converting to sections then they are in display order`() { + val changelog = Changelog( + new = listOf(entry()), + improved = listOf(entry()), + fixed = listOf(entry()), + ) + + assertEquals( + listOf(ChangelogCategory.NEW, ChangelogCategory.IMPROVED, ChangelogCategory.FIXED), + changelog.toSections().map { it.category }, + ) + } + + @Test + fun `Given empty categories when converting to sections then they are omitted`() { + val changelog = Changelog(improved = listOf(entry())) + + assertEquals( + listOf(ChangelogSection(ChangelogCategory.IMPROVED, changelog.improved)), + changelog.toSections(), + ) + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogViewModelTest.kt new file mode 100644 index 00000000000..e67bcaf4500 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ChangelogViewModelTest.kt @@ -0,0 +1,55 @@ +package io.homeassistant.companion.android.changelog + +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +private const val CURRENT_VERSION_CODE = 42 + +@ExtendWith(MainDispatcherJUnit5Extension::class) +class ChangelogViewModelTest { + + private val prefsRepository: PrefsRepository = mockk(relaxUnitFun = true) + + private fun createViewModel(isAutomotive: Boolean = false, rawVersionName: String = "2026.7.6-full") = ChangelogViewModel(prefsRepository, isAutomotive, rawVersionName, CURRENT_VERSION_CODE) + + @Test + fun `Given creation when initializing then marks changelog seen`() = runTest { + createViewModel() + advanceUntilIdle() + + coVerify { prefsRepository.markChangelogSeen(CURRENT_VERSION_CODE) } + } + + @Test + fun `Given full flavor version when reading state then flavor suffix is stripped and release url built`() { + val state = createViewModel(rawVersionName = "2026.7.6-full").uiState.value + + assertEquals("2026.7.6", state.versionName) + assertEquals("https://github.com/home-assistant/android/releases/tag/2026.7.6", state.releaseUrl) + } + + @Test + fun `Given minimal flavor version when reading state then flavor suffix is stripped`() { + assertEquals("2026.7.6", createViewModel(rawVersionName = "2026.7.6-minimal").uiState.value.versionName) + } + + @Test + fun `Given phone app when reading state then current platform is app with the authored content`() { + val state = createViewModel(isAutomotive = false).uiState.value + + assertEquals(ChangelogPlatform.APP, state.currentPlatform) + assertEquals(currentChangelog.toSections(), state.sections) + } + + @Test + fun `Given automotive when reading state then current platform is automotive`() { + assertEquals(ChangelogPlatform.AUTOMOTIVE, createViewModel(isAutomotive = true).uiState.value.currentPlatform) + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/changelog/navigation/ChangelogNavigationTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/navigation/ChangelogNavigationTest.kt new file mode 100644 index 00000000000..69ec227571a --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/navigation/ChangelogNavigationTest.kt @@ -0,0 +1,148 @@ +package io.homeassistant.companion.android.changelog.navigation + +import androidx.compose.material3.Text +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.testing.TestNavHostController +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.BuildConfig +import io.homeassistant.companion.android.HiltComponentActivity +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.frontend.navigation.FrontendRoute +import io.homeassistant.companion.android.frontend.navigation.FrontendTarget +import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit4Rule +import io.homeassistant.companion.android.testing.unit.stringResource +import javax.inject.Inject +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private const val FRONTEND_FAKE_CONTENT = "frontend" + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +@HiltAndroidTest +internal class ChangelogNavigationTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val mainDispatcherRule = MainDispatcherJUnit4Rule() + + @Inject + lateinit var prefsRepository: PrefsRepository + + private lateinit var navController: TestNavHostController + + @Before + fun setup() { + hiltRule.inject() + } + + private suspend fun seedLastSeenVersionCode(versionCode: Int) { + prefsRepository.markChangelogSeen(versionCode) + } + + private suspend fun hasUnseenChangelog(): Boolean = prefsRepository.wasAppUpdatedSinceChangelogSeen(BuildConfig.VERSION_CODE) + + /** + * Runs the scheduler until the changelog is marked seen. The ViewModel coroutine runs on the + * controlled Main dispatcher, but the write itself hops to the real IO dispatcher, so keep + * advancing until its result is visible. Bounded by the runTest timeout. + */ + private suspend fun TestScope.awaitChangelogMarkedSeen() { + while (hasUnseenChangelog()) { + advanceUntilIdle() + } + } + + private fun setContent() { + composeTestRule.setContent { + navController = TestNavHostController(LocalContext.current) + navController.navigatorProvider.addNavigator(ComposeNavigator()) + + NavHost( + navController = navController, + startDestination = FrontendRoute(FrontendTarget.Default), + ) { + composable { Text(FRONTEND_FAKE_CONTENT) } + changelogScreen(navController, onOpenUrl = {}) + } + + ChangelogAutoShowEffect(navController) + } + } + + private fun isOnChangelog(): Boolean = navController.currentBackStackEntry?.destination?.hasRoute() == true + + @Test + fun `Given unseen changelog when frontend is displayed then navigates to changelog and marks it seen`() = runTest { + seedLastSeenVersionCode(BuildConfig.VERSION_CODE - 1) + + setContent() + composeTestRule.waitUntil { isOnChangelog() } + + composeTestRule.onNodeWithText(composeTestRule.stringResource(commonR.string.changelog_screen_title)) + .assertIsDisplayed() + awaitChangelogMarkedSeen() + } + + @Test + fun `Given changelog already seen when frontend is displayed then stays on frontend`() = runTest { + seedLastSeenVersionCode(BuildConfig.VERSION_CODE) + + setContent() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(FRONTEND_FAKE_CONTENT).assertIsDisplayed() + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) + } + + @Test + fun `Given popup disabled when frontend is displayed then stays on frontend without marking seen`() = runTest { + seedLastSeenVersionCode(BuildConfig.VERSION_CODE - 1) + prefsRepository.setChangeLogPopupEnabled(false) + + setContent() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(FRONTEND_FAKE_CONTENT).assertIsDisplayed() + assertTrue(hasUnseenChangelog()) + } + + @Test + fun `Given changelog displayed when closing then returns to frontend`() = runTest { + seedLastSeenVersionCode(BuildConfig.VERSION_CODE - 1) + + setContent() + composeTestRule.waitUntil { isOnChangelog() } + + composeTestRule.onNodeWithText(composeTestRule.stringResource(commonR.string.changelog_got_it)) + .performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(FRONTEND_FAKE_CONTENT).assertIsDisplayed() + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ui/ChangelogScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ui/ChangelogScreenTest.kt new file mode 100644 index 00000000000..5c1bd44f127 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/changelog/ui/ChangelogScreenTest.kt @@ -0,0 +1,137 @@ +package io.homeassistant.companion.android.changelog.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.HiltComponentActivity +import io.homeassistant.companion.android.changelog.ChangelogAction +import io.homeassistant.companion.android.changelog.ChangelogCategory +import io.homeassistant.companion.android.changelog.ChangelogEntry +import io.homeassistant.companion.android.changelog.ChangelogPlatform +import io.homeassistant.companion.android.changelog.ChangelogSection +import io.homeassistant.companion.android.changelog.ChangelogUiState +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.testing.unit.stringResource +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private const val VERSION_NAME = "2026.7.6" +private const val RELEASE_URL = "https://github.com/home-assistant/android/releases/tag/2026.7.6" +private const val ACTION_URL = "https://www.home-assistant.io/blog/" + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +@HiltAndroidTest +internal class ChangelogScreenTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + private var closeClicked = false + private val clickedActions = mutableListOf() + + private fun uiState(action: ChangelogAction? = null) = ChangelogUiState( + versionName = VERSION_NAME, + releaseUrl = RELEASE_URL, + currentPlatform = ChangelogPlatform.APP, + sections = listOf( + ChangelogSection( + category = ChangelogCategory.NEW, + entries = listOf( + ChangelogEntry( + contentRes = commonR.string.changelog_entry_entity_widgets, + platforms = setOf(ChangelogPlatform.APP, ChangelogPlatform.WEAR), + action = action, + ), + ), + ), + ), + ) + + /** The rendered plain text of [commonR.string.changelog_entry_entity_widgets] once the HTML is parsed. */ + private val entryText = "Modernized settings for entity widgets" + + private fun setContent(action: ChangelogAction? = null) { + composeTestRule.setContent { + ChangelogScreenContent( + uiState = uiState(action), + onCloseClick = { closeClicked = true }, + onActionClick = { clickedActions += it }, + ) + } + } + + @Test + fun `Given displayed changelog then version content platform tags and release link are displayed`() { + setContent() + + composeTestRule.apply { + onNodeWithText(VERSION_NAME).assertIsDisplayed() + onNodeWithText(entryText).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.changelog_category_new).uppercase()).assertIsDisplayed() + onNodeWithText( + activity.getString( + commonR.string.changelog_platform_this_device, + stringResource(commonR.string.changelog_platform_app), + ), + ).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.changelog_platform_wear)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.changelog_subtitle_platforms), substring = true) + .assertIsDisplayed() + } + } + + @Test + fun `Given entry with action when tapping it then it invokes onActionClick with the action`() { + val action = ChangelogAction.OpenUrl(ACTION_URL) + setContent(action = action) + + composeTestRule.onNodeWithText(entryText).performClick() + + assertEquals(listOf(action), clickedActions) + } + + @Test + fun `Given entry without action when tapping it then nothing happens`() { + setContent() + + composeTestRule.onNodeWithText(entryText).performClick() + + assertTrue(clickedActions.isEmpty()) + } + + @Test + fun `Given displayed changelog when tapping got it then it invokes onCloseClick`() { + setContent() + + composeTestRule.apply { + onNodeWithText(stringResource(commonR.string.changelog_got_it)).performClick() + } + + assertTrue(closeClicked) + } + + @Test + fun `Given displayed changelog when tapping close then it invokes onCloseClick`() { + setContent() + + composeTestRule.apply { + onNodeWithContentDescription(stringResource(commonR.string.close)).performClick() + } + + assertTrue(closeClicked) + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt index 621f2b773e1..00f47766844 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt @@ -20,7 +20,6 @@ import io.homeassistant.companion.android.common.sensors.SensorWorker import io.homeassistant.companion.android.common.util.DisabledLocationHandler import io.homeassistant.companion.android.di.ServerManagerModule import io.homeassistant.companion.android.sensors.SensorReceiver -import io.homeassistant.companion.android.util.ChangeLog import io.homeassistant.companion.android.websocket.WebsocketManager import io.mockk.Runs import io.mockk.coEvery @@ -28,9 +27,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkConstructor import io.mockk.mockkObject -import io.mockk.unmockkConstructor import io.mockk.unmockkObject import io.mockk.verify import org.junit.After @@ -71,8 +68,6 @@ class LaunchActivityTest { coEvery { WebsocketManager.start(any()) } just Runs every { SensorReceiver.updateAllSensors(any()) } just Runs every { DisabledLocationHandler.isLocationEnabled(any()) } returns true - mockkConstructor(ChangeLog::class) - coEvery { anyConstructed().showChangeLog(any(), any()) } just Runs } @After @@ -81,16 +76,14 @@ class LaunchActivityTest { unmockkObject(WebsocketManager.Companion) unmockkObject(SensorReceiver.Companion) unmockkObject(DisabledLocationHandler) - unmockkConstructor(ChangeLog::class) } @Test - fun `Given activity resumes then sensor worker and websocket manager are started and changelog is shown`() { + fun `Given activity resumes then sensor worker and websocket manager are started`() { ActivityScenario.launch(LaunchActivity::class.java).use { verify { SensorWorker.start(any()) } coVerify { WebsocketManager.start(any()) } verify { DisabledLocationHandler.isLocationEnabled(any()) } - coVerify { anyConstructed().showChangeLog(any(), eq(false)) } } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/ChangeLogTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/ChangeLogTest.kt deleted file mode 100644 index 52de00fd298..00000000000 --- a/app/src/test/kotlin/io/homeassistant/companion/android/util/ChangeLogTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package io.homeassistant.companion.android.util - -import android.app.AlertDialog -import android.content.Context -import io.homeassistant.companion.android.common.data.prefs.NightModeTheme -import io.homeassistant.companion.android.common.data.prefs.PrefsRepository -import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension -import io.homeassistant.companion.android.themes.NightModeManager -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith - -@OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(MainDispatcherJUnit5Extension::class) -class ChangeLogTest { - private val context = mockk(relaxed = true) - private val prefsRepository = mockk() - private val nightModeManager = mockk() - private val hannesChangeLog = mockk(relaxed = true) - private val darkThemeChangeLog = mockk(relaxed = true) - private val dialog = mockk(relaxed = true) - private lateinit var changeLog: TestableChangeLog - - @BeforeEach - fun setup() { - coEvery { nightModeManager.getCurrentNightMode() } returns NightModeTheme.LIGHT - every { hannesChangeLog.fullLogDialog } returns dialog - every { hannesChangeLog.isFirstRun } returns true - every { hannesChangeLog.isFirstRunEver } returns false - every { darkThemeChangeLog.fullLogDialog } returns dialog - every { darkThemeChangeLog.isFirstRun } returns true - every { darkThemeChangeLog.isFirstRunEver } returns false - - changeLog = TestableChangeLog(nightModeManager, prefsRepository, hannesChangeLog, darkThemeChangeLog) - } - - @Test - fun `Given change log popup is not enabled when showing change log then popup is not shown`() = runTest { - coEvery { prefsRepository.isChangeLogPopupEnabled() } returns false - changeLog.showChangeLog(context, forceShow = false) - verify(exactly = 0) { dialog.show() } - } - - @Test - fun `Given change log popup is enabled when showing change log then popup is shown`() = runTest { - coEvery { prefsRepository.isChangeLogPopupEnabled() } returns true - changeLog.showChangeLog(context, forceShow = false) - verify(exactly = 1) { dialog.show() } - } - - @Test - fun `Given forceShow is true when showing change log then popup is shown regardless of enabled setting`() = runTest { - coEvery { prefsRepository.isChangeLogPopupEnabled() } returns false - changeLog.showChangeLog(context, forceShow = true) - verify(exactly = 1) { dialog.show() } - } - - // Test class that extends ChangeLog and overrides the factory methods for testing - private class TestableChangeLog( - nightModeManager: NightModeManager, - prefsRepository: PrefsRepository, - private val mockChangeLog: info.hannes.changelog.ChangeLog, - private val mockDarkThemeChangeLog: DarkThemeChangeLog, - ) : ChangeLog(nightModeManager, prefsRepository) { - - override fun createChangeLog(context: Context): info.hannes.changelog.ChangeLog { - return mockChangeLog - } - - override fun createDarkThemeChangeLog(context: Context): DarkThemeChangeLog { - return mockDarkThemeChangeLog - } - } -} diff --git a/automotive/gradle.lockfile b/automotive/gradle.lockfile index 94f76329be4..0613a73f110 100644 --- a/automotive/gradle.lockfile +++ b/automotive/gradle.lockfile @@ -312,7 +312,6 @@ com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-androi com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.caverock:androidsvg-aar:1.4=fullDebugAndroidTestLintChecksClasspath,fullDebugLintChecksClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestLintChecksClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestLintChecksClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseLintChecksClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestLintChecksClasspath,fullReleaseScreenshotTestRuntimeClasspath,minimalDebugAndroidTestLintChecksClasspath,minimalDebugLintChecksClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestLintChecksClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestLintChecksClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseLintChecksClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestLintChecksClasspath,minimalReleaseScreenshotTestRuntimeClasspath -com.github.AppDevNext:ChangeLog:3.8.2=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestLintChecksClasspath,fullDebugCompileClasspath,fullDebugLintChecksClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestLintChecksClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestLintChecksClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseLintChecksClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestLintChecksClasspath,fullReleaseScreenshotTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestLintChecksClasspath,minimalDebugCompileClasspath,minimalDebugLintChecksClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestLintChecksClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestLintChecksClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseLintChecksClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestLintChecksClasspath,minimalReleaseScreenshotTestRuntimeClasspath com.github.Dimezis:BlurView:version-2.0.6=fullDebugAndroidTestCompileClasspath,fullDebugAndroidTestLintChecksClasspath,fullDebugCompileClasspath,fullDebugLintChecksClasspath,fullDebugRuntimeClasspath,fullDebugScreenshotTestCompileClasspath,fullDebugScreenshotTestLintChecksClasspath,fullDebugScreenshotTestRuntimeClasspath,fullDebugUnitTestCompileClasspath,fullDebugUnitTestLintChecksClasspath,fullDebugUnitTestRuntimeClasspath,fullReleaseCompileClasspath,fullReleaseLintChecksClasspath,fullReleaseRuntimeClasspath,fullReleaseScreenshotTestCompileClasspath,fullReleaseScreenshotTestLintChecksClasspath,fullReleaseScreenshotTestRuntimeClasspath,minimalDebugAndroidTestCompileClasspath,minimalDebugAndroidTestLintChecksClasspath,minimalDebugCompileClasspath,minimalDebugLintChecksClasspath,minimalDebugRuntimeClasspath,minimalDebugScreenshotTestCompileClasspath,minimalDebugScreenshotTestLintChecksClasspath,minimalDebugScreenshotTestRuntimeClasspath,minimalDebugUnitTestCompileClasspath,minimalDebugUnitTestLintChecksClasspath,minimalDebugUnitTestRuntimeClasspath,minimalReleaseCompileClasspath,minimalReleaseLintChecksClasspath,minimalReleaseRuntimeClasspath,minimalReleaseScreenshotTestCompileClasspath,minimalReleaseScreenshotTestLintChecksClasspath,minimalReleaseScreenshotTestRuntimeClasspath com.github.ajalt.clikt:clikt-jvm:5.0.2=ktlint com.github.ajalt.clikt:clikt:5.0.2=ktlint diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt index debc1d39615..8ab49772189 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationDependenciesConventionPlugin.kt @@ -97,7 +97,6 @@ class AndroidApplicationDependenciesConventionPlugin : Plugin { "implementation"(libs.bundles.paging) "implementation"(libs.reorderable) - "implementation"(libs.changeLog) "implementation"(libs.aboutlibraries.compose.m3) "implementation"(libs.zxing) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HAHorizontalDivider.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HADivider.kt similarity index 67% rename from common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HAHorizontalDivider.kt rename to common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HADivider.kt index 7b0586afa61..423898b21b7 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HAHorizontalDivider.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/compose/composable/HADivider.kt @@ -1,6 +1,7 @@ package io.homeassistant.companion.android.common.compose.composable import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.homeassistant.companion.android.common.compose.theme.HABorderWidth @@ -14,3 +15,12 @@ fun HAHorizontalDivider(modifier: Modifier = Modifier) { thickness = HABorderWidth.S, ) } + +@Composable +fun HAVerticalDivider(modifier: Modifier = Modifier) { + VerticalDivider( + modifier = modifier, + color = LocalHAColorScheme.current.colorBorderNeutralQuiet, + thickness = HABorderWidth.S, + ) +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt index 79cf582661d..22b30c07440 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt @@ -177,6 +177,18 @@ interface PrefsRepository { suspend fun setChangeLogPopupEnabled(enabled: Boolean) + /** + * Returns `true` when the app was updated since the changelog was last marked seen with + * [markChangelogSeen]. + * + * A fresh install is not an update: [currentVersionCode] is stored as the baseline to compare + * future updates against, and `false` is returned. + */ + suspend fun wasAppUpdatedSinceChangelogSeen(currentVersionCode: Int): Boolean + + /** Stores [currentVersionCode] as the last app version whose changelog was seen. */ + suspend fun markChangelogSeen(currentVersionCode: Int) + /** Clean up any app-level preferences that might reference servers */ suspend fun removeServer(serverId: Int) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt index be9ee6f7120..e24a8b599b7 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt @@ -39,6 +39,7 @@ private const val PREF_LOCATION_HISTORY_DISABLED = "location_history" private const val PREF_IMPROV_PERMISSION_DISPLAYED = "improv_permission_displayed" private const val PREF_GESTURE_ACTION_PREFIX = "gesture_action" private const val PREF_CHANGE_LOG_POPUP_ENABLED = "change_log_popup_enabled" +private const val PREF_LAST_SEEN_CHANGELOG_VERSION = "last_seen_changelog_version" private const val PREF_SHOW_PRIVACY_HINT = "show_privacy_hint" private const val PREF_WAKE_WORD_ENABLED = "wake_word_enabled" private const val PREF_SELECTED_WAKE_WORD = "selected_wake_word" @@ -385,6 +386,18 @@ internal class PrefsRepositoryImpl @Inject constructor( localStorage().putBoolean(PREF_CHANGE_LOG_POPUP_ENABLED, enabled) } + override suspend fun wasAppUpdatedSinceChangelogSeen(currentVersionCode: Int): Boolean { + val lastSeenVersionCode = localStorage().getInt(PREF_LAST_SEEN_CHANGELOG_VERSION) ?: run { + markChangelogSeen(currentVersionCode) + return false + } + return lastSeenVersionCode < currentVersionCode + } + + override suspend fun markChangelogSeen(currentVersionCode: Int) { + localStorage().putInt(PREF_LAST_SEEN_CHANGELOG_VERSION, currentVersionCode) + } + override suspend fun removeServer(serverId: Int) { val controlsAuthEntities = getControlsAuthEntities().filter { it.split(".")[0].toIntOrNull() != serverId } setControlsAuthEntities(controlsAuthEntities) diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 43c3371ab7f..278771c3ceb 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -149,6 +149,18 @@ Displays the latest picture from a camera or image entity Cancel View full change log + What\'s new + Got it + Release notes + App, Wear OS and Automotive + New + Improved + Fixed + App + Automotive + Wear OS + %1$s · this device + Open Choose entity Clear favorites Clear approved tags diff --git a/common/src/main/res/values/strings_changelog.xml b/common/src/main/res/values/strings_changelog.xml new file mode 100644 index 00000000000..2264176abce --- /dev/null +++ b/common/src/main/res/values/strings_changelog.xml @@ -0,0 +1,11 @@ + + + + <b>Android 17</b>: added assistant volume level sensor, plus a notification command to control it + <b>Health Connect</b> sleep duration now ignores awake and out-of-bed time + Modernized settings for <b>entity widgets</b> + Modernized settings for <b>tiles</b> + Bug fixes and dependency updates everywhere + diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt index 75759214c40..859bc17f957 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImplTest.kt @@ -47,166 +47,144 @@ class PrefsRepositoryImplTest { repository = PrefsRepositoryImpl(localStorage = localStorage, integrationStorage = integrationStorage) } - @ParameterizedTest - @CsvSource( - "SWIPE_UP_THREE,SERVER_LIST", - "SWIPE_DOWN_THREE,QUICKBAR_DEFAULT", - "SWIPE_LEFT_THREE,SERVER_PREVIOUS", - "SWIPE_RIGHT_THREE,SERVER_NEXT", - ) - fun `Given gesture with default action when getting pref then default action is returned`( - gestureName: String, - actionName: String, - ) = runTest { - coEvery { localStorage.getString("gesture_action_$gestureName") } returns null - - val result = repository.getGestureAction(HAGesture.valueOf(gestureName)) - - assertEquals(GestureAction.valueOf(actionName), result) - } - - @Test - fun `Given user customized gesture action when getting pref then user action is returned`() = runTest { - coEvery { localStorage.getString("gesture_action_SWIPE_LEFT_THREE") } returns "QUICKBAR_DEFAULT" - - val result = repository.getGestureAction(HAGesture.valueOf("SWIPE_LEFT_THREE")) - - assertEquals(GestureAction.QUICKBAR_DEFAULT, result) - } - - @ParameterizedTest - @ValueSource(strings = ["SWIPE_UP_TWO", "SWIPE_DOWN_TWO", "SWIPE_LEFT_TWO", "SWIPE_RIGHT_TWO"]) - fun `Given gesture with no default action when getting pref then none action is returned`( - gestureName: String, - ) = runTest { - coEvery { localStorage.getString("gesture_action_$gestureName") } returns null - - val result = repository.getGestureAction(HAGesture.valueOf(gestureName)) - - assertEquals(GestureAction.NONE, result) - } - - @Test - fun `Given no preference set when checking change log popup enabled then default is true`() = runTest { - coEvery { localStorage.getBooleanOrNull("change_log_popup_enabled") } returns null + @Nested + inner class GestureActions { + @ParameterizedTest + @CsvSource( + "SWIPE_UP_THREE,SERVER_LIST", + "SWIPE_DOWN_THREE,QUICKBAR_DEFAULT", + "SWIPE_LEFT_THREE,SERVER_PREVIOUS", + "SWIPE_RIGHT_THREE,SERVER_NEXT", + ) + fun `Given gesture with default action when getting pref then default action is returned`( + gestureName: String, + actionName: String, + ) = runTest { + coEvery { localStorage.getString("gesture_action_$gestureName") } returns null - val result = repository.isChangeLogPopupEnabled() + val result = repository.getGestureAction(HAGesture.valueOf(gestureName)) - assertTrue(result) - } + assertEquals(GestureAction.valueOf(actionName), result) + } - @Test - fun `Given user sets change log popup enabled to true when retrieving then value is true`() = runTest { - coEvery { localStorage.putBoolean("change_log_popup_enabled", true) } returns Unit - coEvery { localStorage.getBooleanOrNull("change_log_popup_enabled") } returns true - repository.setChangeLogPopupEnabled(true) + @Test + fun `Given user customized gesture action when getting pref then user action is returned`() = runTest { + coEvery { localStorage.getString("gesture_action_SWIPE_LEFT_THREE") } returns "QUICKBAR_DEFAULT" - val result = repository.isChangeLogPopupEnabled() + val result = repository.getGestureAction(HAGesture.valueOf("SWIPE_LEFT_THREE")) - assertTrue(result) - } + assertEquals(GestureAction.QUICKBAR_DEFAULT, result) + } - @Test - fun `Given user sets change log popup enabled to false when retrieving then value is false`() = runTest { - coEvery { localStorage.putBoolean("change_log_popup_enabled", false) } returns Unit - coEvery { localStorage.getBooleanOrNull("change_log_popup_enabled") } returns false - repository.setChangeLogPopupEnabled(false) + @ParameterizedTest + @ValueSource(strings = ["SWIPE_UP_TWO", "SWIPE_DOWN_TWO", "SWIPE_LEFT_TWO", "SWIPE_RIGHT_TWO"]) + fun `Given gesture with no default action when getting pref then none action is returned`( + gestureName: String, + ) = runTest { + coEvery { localStorage.getString("gesture_action_$gestureName") } returns null - val result = repository.isChangeLogPopupEnabled() + val result = repository.getGestureAction(HAGesture.valueOf(gestureName)) - assertFalse(result) + assertEquals(GestureAction.NONE, result) + } } - @Test - fun `Given migration already at current version when accessing prefs multiple times then migration check only runs once`() = runTest { - // This test verifies the fix where migrationChecked.set(true) is called - // even when migration is not needed to prevent repeated migration checks - - // Setup - migration already at current version, no migration needed - coEvery { localStorage.getInt(MIGRATION_PREF) } returns MIGRATION_VERSION - coEvery { localStorage.getString(any()) } returns "test_value" - - // Execute multiple calls - repository.getAppVersion() - repository.getCurrentLang() - repository.getControlsAuthRequired() - - // Verify migration version check only happened once - // This confirms migrationChecked.set(true) was called after first check - // even though no actual migration was performed - coVerify(exactly = 1) { localStorage.getInt(MIGRATION_PREF) } - - // Verify no integration storage was accessed since no migration was needed - coVerify(exactly = 0) { integrationStorage.getString(any()) } + @Nested + inner class Migration { + @Test + fun `Given migration already at current version when accessing prefs multiple times then migration check only runs once`() = runTest { + // This test verifies the fix where migrationChecked.set(true) is called + // even when migration is not needed to prevent repeated migration checks + + // Setup - migration already at current version, no migration needed + coEvery { localStorage.getInt(MIGRATION_PREF) } returns MIGRATION_VERSION + coEvery { localStorage.getString(any()) } returns "test_value" + + // Execute multiple calls + repository.getAppVersion() + repository.getCurrentLang() + repository.getControlsAuthRequired() + + // Verify migration version check only happened once + // This confirms migrationChecked.set(true) was called after first check + // even though no actual migration was performed + coVerify(exactly = 1) { localStorage.getInt(MIGRATION_PREF) } + + // Verify no integration storage was accessed since no migration was needed + coVerify(exactly = 0) { integrationStorage.getString(any()) } + } } - @Test - fun `Given collecting flow when autoplay key changes then updated value is emitted`() = runTest { - coEvery { localStorage.getBoolean("autoplay_video") } returns false + @Nested + inner class SettingFlows { + @Test + fun `Given collecting flow when autoplay key changes then updated value is emitted`() = runTest { + coEvery { localStorage.getBoolean("autoplay_video") } returns false - repository.autoPlayVideoFlow().test { - assertFalse(awaitItem()) + repository.autoPlayVideoFlow().test { + assertFalse(awaitItem()) - coEvery { localStorage.getBoolean("autoplay_video") } returns true - keyChangesFlow.emit("autoplay_video") + coEvery { localStorage.getBoolean("autoplay_video") } returns true + keyChangesFlow.emit("autoplay_video") - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } } - } - @Test - fun `Given collecting flow when full screen changes then updated full screen enabled is emitted`() = runTest { - coEvery { localStorage.getBoolean("fullscreen_enabled") } returns true + @Test + fun `Given collecting flow when full screen changes then updated full screen enabled is emitted`() = runTest { + coEvery { localStorage.getBoolean("fullscreen_enabled") } returns true - repository.fullScreenEnabledFlow().test { - assertTrue(awaitItem()) + repository.fullScreenEnabledFlow().test { + assertTrue(awaitItem()) - coEvery { localStorage.getBoolean("fullscreen_enabled") } returns false - keyChangesFlow.emit("fullscreen_enabled") + coEvery { localStorage.getBoolean("fullscreen_enabled") } returns false + keyChangesFlow.emit("fullscreen_enabled") - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() + assertFalse(awaitItem()) + cancelAndIgnoreRemainingEvents() + } } - } - @Test - fun `Given collecting flow when screen orientation changes then typed value is emitted`() = runTest { - coEvery { localStorage.getString("screen_orientation") } returns null + @Test + fun `Given collecting flow when screen orientation changes then typed value is emitted`() = runTest { + coEvery { localStorage.getString("screen_orientation") } returns null - repository.screenOrientationFlow().test { - // Null storage value falls back to SYSTEM - assertEquals(ScreenOrientation.SYSTEM, awaitItem()) + repository.screenOrientationFlow().test { + // Null storage value falls back to SYSTEM + assertEquals(ScreenOrientation.SYSTEM, awaitItem()) - coEvery { localStorage.getString("screen_orientation") } returns "portrait" - keyChangesFlow.emit("screen_orientation") - assertEquals(ScreenOrientation.PORTRAIT, awaitItem()) + coEvery { localStorage.getString("screen_orientation") } returns "portrait" + keyChangesFlow.emit("screen_orientation") + assertEquals(ScreenOrientation.PORTRAIT, awaitItem()) - coEvery { localStorage.getString("screen_orientation") } returns "landscape" - keyChangesFlow.emit("screen_orientation") - assertEquals(ScreenOrientation.LANDSCAPE, awaitItem()) + coEvery { localStorage.getString("screen_orientation") } returns "landscape" + keyChangesFlow.emit("screen_orientation") + assertEquals(ScreenOrientation.LANDSCAPE, awaitItem()) - // Unknown value falls back to SYSTEM - coEvery { localStorage.getString("screen_orientation") } returns "garbage" - keyChangesFlow.emit("screen_orientation") - assertEquals(ScreenOrientation.SYSTEM, awaitItem()) + // Unknown value falls back to SYSTEM + coEvery { localStorage.getString("screen_orientation") } returns "garbage" + keyChangesFlow.emit("screen_orientation") + assertEquals(ScreenOrientation.SYSTEM, awaitItem()) - cancelAndIgnoreRemainingEvents() + cancelAndIgnoreRemainingEvents() + } } - } - @Test - fun `Given collecting flow when keep screen on changes then updated keep screen on enabled is emitted`() = runTest { - coEvery { localStorage.getBoolean("keep_screen_on_enabled") } returns false + @Test + fun `Given collecting flow when keep screen on changes then updated keep screen on enabled is emitted`() = runTest { + coEvery { localStorage.getBoolean("keep_screen_on_enabled") } returns false - repository.keepScreenOnFlow().test { - assertFalse(awaitItem()) + repository.keepScreenOnFlow().test { + assertFalse(awaitItem()) - coEvery { localStorage.getBoolean("keep_screen_on_enabled") } returns true - keyChangesFlow.emit("keep_screen_on_enabled") + coEvery { localStorage.getBoolean("keep_screen_on_enabled") } returns true + keyChangesFlow.emit("keep_screen_on_enabled") - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() + assertTrue(awaitItem()) + cancelAndIgnoreRemainingEvents() + } } } @@ -304,55 +282,138 @@ class PrefsRepositoryImplTest { } } - @Test - fun `Given no approved tags when listing then returns empty list`() = runTest { - coEvery { localStorage.getStringSet("allowed_tags") } returns null + @Nested + inner class AllowedTags { + @Test + fun `Given no approved tags when listing then returns empty list`() = runTest { + coEvery { localStorage.getStringSet("allowed_tags") } returns null - assertEquals(emptySet(), repository.getAllowedTags()) - } + assertEquals(emptySet(), repository.getAllowedTags()) + } - @Test - fun `Given approved tags stored when listing then returns them`() = runTest { - coEvery { localStorage.getStringSet("allowed_tags") } returns setOf("tag-a", "tag-b") + @Test + fun `Given approved tags stored when listing then returns them`() = runTest { + coEvery { localStorage.getStringSet("allowed_tags") } returns setOf("tag-a", "tag-b") - assertEquals(setOf("tag-a", "tag-b"), repository.getAllowedTags()) - } + assertEquals(setOf("tag-a", "tag-b"), repository.getAllowedTags()) + } - @Test - fun `Given new tag when approving then it is added to the stored set`() = runTest { - coEvery { localStorage.getStringSet("allowed_tags") } returns setOf("tag-a") - coEvery { localStorage.putStringSet(any(), any()) } returns Unit + @Test + fun `Given new tag when approving then it is added to the stored set`() = runTest { + coEvery { localStorage.getStringSet("allowed_tags") } returns setOf("tag-a") + coEvery { localStorage.putStringSet(any(), any()) } returns Unit - repository.addAllowedTag("tag-b") + repository.addAllowedTag("tag-b") - coVerify(exactly = 1) { localStorage.putStringSet("allowed_tags", setOf("tag-a", "tag-b")) } - } + coVerify(exactly = 1) { localStorage.putStringSet("allowed_tags", setOf("tag-a", "tag-b")) } + } - @Test - fun `Given no approved tags when approving then writes a single-entry set`() = runTest { - coEvery { localStorage.getStringSet("allowed_tags") } returns null - coEvery { localStorage.putStringSet(any(), any()) } returns Unit + @Test + fun `Given no approved tags when approving then writes a single-entry set`() = runTest { + coEvery { localStorage.getStringSet("allowed_tags") } returns null + coEvery { localStorage.putStringSet(any(), any()) } returns Unit - repository.addAllowedTag("tag-a") + repository.addAllowedTag("tag-a") - coVerify(exactly = 1) { localStorage.putStringSet("allowed_tags", setOf("tag-a")) } - } + coVerify(exactly = 1) { localStorage.putStringSet("allowed_tags", setOf("tag-a")) } + } + + @Test + fun `Given tag already approved when approving again then storage is not written`() = runTest { + coEvery { localStorage.getStringSet("allowed_tags") } returns setOf("tag-a") + + repository.addAllowedTag("tag-a") + + coVerify(exactly = 0) { localStorage.putStringSet(any(), any()) } + } - @Test - fun `Given tag already approved when approving again then storage is not written`() = runTest { - coEvery { localStorage.getStringSet("allowed_tags") } returns setOf("tag-a") + @Test + fun `Given approved tags when clearing then storage entry is removed`() = runTest { + coEvery { localStorage.remove(any()) } returns Unit - repository.addAllowedTag("tag-a") + repository.clearAllowedTags() - coVerify(exactly = 0) { localStorage.putStringSet(any(), any()) } + coVerify(exactly = 1) { localStorage.remove("allowed_tags") } + } } - @Test - fun `Given approved tags when clearing then storage entry is removed`() = runTest { - coEvery { localStorage.remove(any()) } returns Unit + @Nested + inner class Changelog { + // Duplicated literal to guard against accidental key renames that would lose the stored state + private val key = "last_seen_changelog_version" + private val currentVersionCode = 42 + + @Test + fun `Given no preference set when checking change log popup enabled then default is true`() = runTest { + coEvery { localStorage.getBooleanOrNull("change_log_popup_enabled") } returns null + + val result = repository.isChangeLogPopupEnabled() + + assertTrue(result) + } + + @Test + fun `Given user sets change log popup enabled to true when retrieving then value is true`() = runTest { + coEvery { localStorage.putBoolean("change_log_popup_enabled", true) } returns Unit + coEvery { localStorage.getBooleanOrNull("change_log_popup_enabled") } returns true + repository.setChangeLogPopupEnabled(true) + + val result = repository.isChangeLogPopupEnabled() + + assertTrue(result) + } + + @Test + fun `Given user sets change log popup enabled to false when retrieving then value is false`() = runTest { + coEvery { localStorage.putBoolean("change_log_popup_enabled", false) } returns Unit + coEvery { localStorage.getBooleanOrNull("change_log_popup_enabled") } returns false + repository.setChangeLogPopupEnabled(false) + + val result = repository.isChangeLogPopupEnabled() + + assertFalse(result) + } + + @Test + fun `Given no stored version when checking if the app was updated then returns false and stores current version`() = runTest { + coEvery { localStorage.getInt(key) } returns null + coEvery { localStorage.putInt(key, any()) } returns Unit + + assertFalse(repository.wasAppUpdatedSinceChangelogSeen(currentVersionCode)) + + coVerify { localStorage.putInt(key, currentVersionCode) } + } + + @Test + fun `Given older stored version when checking if the app was updated then returns true without storing`() = runTest { + coEvery { localStorage.getInt(key) } returns currentVersionCode - 1 + + assertTrue(repository.wasAppUpdatedSinceChangelogSeen(currentVersionCode)) - repository.clearAllowedTags() + coVerify(exactly = 0) { localStorage.putInt(any(), any()) } + } + + @Test + fun `Given current stored version when checking if the app was updated then returns false`() = runTest { + coEvery { localStorage.getInt(key) } returns currentVersionCode + + assertFalse(repository.wasAppUpdatedSinceChangelogSeen(currentVersionCode)) + } + + @Test + fun `Given newer stored version when checking if the app was updated then returns false`() = runTest { + coEvery { localStorage.getInt(key) } returns currentVersionCode + 1 - coVerify(exactly = 1) { localStorage.remove("allowed_tags") } + assertFalse(repository.wasAppUpdatedSinceChangelogSeen(currentVersionCode)) + } + + @Test + fun `When marking changelog seen then stores current version`() = runTest { + coEvery { localStorage.putInt(key, any()) } returns Unit + + repository.markChangelogSeen(currentVersionCode) + + coVerify { localStorage.putInt(key, currentVersionCode) } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c87bf5cc0b..f9baff5cf37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,6 @@ appcompat = "1.7.1" biometric = "1.1.0" blurView = "version-2.0.6" car-versions = "1.7.0" -changeLog = "3.8.2" classgraph = "4.8.186" cmake = "4.1.2" community-material-typeface = "7.0.96.2-kotlin" @@ -166,7 +165,6 @@ blurView = { module = "com.github.Dimezis:BlurView", version.ref = "blurView" } car-core = { module = "androidx.car.app:app", version.ref = "car-versions" } car-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-versions" } car-projected = { module = "androidx.car.app:app-projected", version.ref = "car-versions" } -changeLog = { module = "com.github.AppDevNext:ChangeLog", version.ref = "changeLog" } classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" } coil-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }