Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.homeassistant.companion.android.changelog

import androidx.annotation.StringRes
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 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.
*/
enum class ChangelogCategory(@field:StringRes val labelRes: Int) {
NEW(commonR.string.changelog_category_new),
IMPROVED(commonR.string.changelog_category_improved),
FIXED(commonR.string.changelog_category_fixed),
}

/**
* 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<ChangelogPlatform>,
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<ChangelogEntry> = emptyList(),
val improved: List<ChangelogEntry> = emptyList(),
val fixed: List<ChangelogEntry> = emptyList(),
) {
/** The non-empty sections of this changelog, in display order. */
fun toSections(): List<ChangelogSection> = 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<ChangelogEntry>)
Original file line number Diff line number Diff line change
@@ -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.
*/
Comment thread
TimoPtr marked this conversation as resolved.
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),
),
),
)
Original file line number Diff line number Diff line change
@@ -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()))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.homeassistant.companion.android.changelog

import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.homeassistant.companion.android.common.LocalStorageImpl
import io.homeassistant.companion.android.common.data.LocalStorage
import io.homeassistant.companion.android.common.util.getSharedPreferencesSuspend
import javax.inject.Qualifier
import javax.inject.Singleton

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class NamedChangelogStorage

@Module
@InstallIn(SingletonComponent::class)
object ChangelogModule {

@Provides
@Singleton
@NamedChangelogStorage
fun provideChangelogLocalStorage(@ApplicationContext appContext: Context): LocalStorage = LocalStorageImpl {
appContext.getSharedPreferencesSuspend(CHANGELOG_PREFERENCES_NAME)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.homeassistant.companion.android.changelog

import androidx.annotation.VisibleForTesting
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.LocalStorage
import javax.inject.Inject

/**
* Name of the SharedPreferences file previously written by the `info.hannes.changelog` library,
* reused so existing installs keep their "last seen changelog" state.
*/
internal const val CHANGELOG_PREFERENCES_NAME = "changelog"

/** Key previously written by the `info.hannes.changelog` library, see [CHANGELOG_PREFERENCES_NAME]. */
private const val KEY_LAST_SEEN_VERSION_CODE = "ChangeLog_last_version_code"

/**
* Tracks the app version whose changelog the user last saw.
*/
class ChangelogRepository @VisibleForTesting constructor(
private val localStorage: LocalStorage,
private val currentVersionCode: Int,
) {

@Inject
constructor(@NamedChangelogStorage localStorage: LocalStorage) : this(localStorage, BuildConfig.VERSION_CODE)

/**
* Returns `true` when the app was updated since the changelog was last marked seen.
*
* A fresh install is not an update: the current version is stored as the baseline to compare
* future updates against, and `false` is returned.
*/
suspend fun wasAppUpdatedSinceChangelogSeen(): Boolean {
val lastSeenVersionCode = localStorage.getInt(KEY_LAST_SEEN_VERSION_CODE) ?: run {
markChangelogSeen()
return false
}
return lastSeenVersionCode < currentVersionCode
}

/** Stores the current app version as the last one whose changelog was seen. */
suspend fun markChangelogSeen() {
localStorage.putInt(KEY_LAST_SEEN_VERSION_CODE, currentVersionCode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.homeassistant.companion.android.changelog

import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import javax.inject.Inject

/**
* Decides whether the changelog screen should be shown.
*/
@HiltViewModel
class ChangelogShowViewModel @Inject constructor(
private val changelogRepository: ChangelogRepository,
private val prefsRepository: PrefsRepository,
) : ViewModel() {

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() && changelogRepository.wasAppUpdatedSinceChangelogSeen()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.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<ChangelogSection>,
)

/**
* 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(
changelogRepository: ChangelogRepository,
isAutomotive: Boolean,
rawVersionName: String,
) : ViewModel() {

@Inject
constructor(
changelogRepository: ChangelogRepository,
@IsAutomotive isAutomotive: Boolean,
) : this(changelogRepository, isAutomotive, BuildConfig.VERSION_NAME)

val uiState: StateFlow<ChangelogUiState> = 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 {
changelogRepository.markChangelogSeen()
}
}
}
Loading
Loading