diff --git a/lawnchair/res/values/config.xml b/lawnchair/res/values/config.xml
index d68ef4a8bf..5068dacf2e 100644
--- a/lawnchair/res/values/config.xml
+++ b/lawnchair/res/values/config.xml
@@ -115,6 +115,8 @@
true
false
true
+ true
+ true
true
true
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt
new file mode 100644
index 0000000000..934b8c8614
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt
@@ -0,0 +1,194 @@
+package app.lawnchair.ui.preferences.components
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.Launch
+import androidx.compose.material.icons.rounded.NewReleases
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import app.lawnchair.preferences2.asState
+import app.lawnchair.ui.preferences.components.layout.ExpandAndShrink
+import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
+import app.lawnchair.ui.preferences.data.liveinfo.liveInformationManager
+import app.lawnchair.ui.preferences.data.liveinfo.model.Announcement
+import app.lawnchair.ui.util.addIf
+import com.android.launcher3.BuildConfig
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun AnnouncementPreference() {
+ val liveInformationManager = liveInformationManager()
+
+ val enabled by liveInformationManager.enabled.asState()
+ val showAnnouncements by liveInformationManager.showAnnouncements.asState()
+ val liveInformation by liveInformationManager.liveInformation.asState()
+
+ if (enabled && showAnnouncements) {
+ AnnouncementPreference(
+ announcements = liveInformation.announcementsImmutable,
+ )
+ }
+}
+
+@Composable
+fun AnnouncementPreference(
+ announcements: ImmutableList,
+) {
+ Column {
+ announcements.forEach { announcement ->
+ AnnouncementItem(announcement)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Composable
+private fun AnnouncementItem(
+ announcement: Announcement,
+) {
+ var show by remember { mutableStateOf(true) }
+
+ ExpandAndShrink(
+ visible = show && announcement.active &&
+ announcement.text.isNotBlank() &&
+ (!announcement.test || BuildConfig.DEBUG),
+ ) {
+ AnnouncementItemContent(
+ text = announcement.text,
+ url = announcement.url,
+ onClose = { show = false },
+ )
+ }
+}
+
+@Composable
+private fun AnnouncementItemContent(
+ text: String,
+ url: String?,
+ onClose: () -> Unit,
+) {
+ Surface(
+ modifier = Modifier
+ .padding(16.dp, 0.dp, 16.dp, 0.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ AnnouncementPreferenceItemContent(text = text, url = url, onClose = onClose)
+ }
+}
+
+@Composable
+private fun AnnouncementPreferenceItemContent(
+ text: String,
+ url: String?,
+ onClose: (() -> Unit)?,
+) {
+ val context = LocalContext.current
+ val hasLink = !url.isNullOrBlank()
+
+ PreferenceTemplate(
+ modifier = Modifier
+ .fillMaxWidth()
+ .addIf(hasLink) {
+ clickable {
+ val webpage = Uri.parse(url)
+ val intent = Intent(Intent.ACTION_VIEW, webpage)
+ if (intent.resolveActivity(context.packageManager) != null) {
+ context.startActivity(intent)
+ }
+ }
+ },
+ title = {},
+ description = {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = text,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ )
+ },
+ startWidget = {
+ Icon(
+ imageVector = Icons.Rounded.NewReleases,
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null,
+ )
+ },
+ endWidget = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End,
+ ) {
+ if (hasLink) {
+ Icon(
+ imageVector = Icons.Rounded.Launch,
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ if (onClose != null) {
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(16.dp).offset(x = (8).dp, y = (-16).dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ tint = MaterialTheme.colorScheme.surfaceTint,
+ contentDescription = null,
+ )
+ }
+ }
+ }
+ },
+ )
+}
+
+@Preview
+@Composable
+private fun InfoPreferenceWithoutLinkPreview() {
+ AnnouncementPreferenceItemContent(
+ text = "Very important announcement ",
+ url = "",
+ onClose = null,
+ )
+}
+
+@Preview
+@Composable
+private fun InfoPreferenceWithLinkPreview() {
+ AnnouncementPreferenceItemContent(
+ text = "Very important announcement with a very important link",
+ url = "https://lawnchair.app/",
+ onClose = null,
+ )
+}
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt
new file mode 100644
index 0000000000..2913db7bfa
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt
@@ -0,0 +1,50 @@
+package app.lawnchair.ui.preferences.data.liveinfo
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import app.lawnchair.ui.preferences.data.liveinfo.model.LiveInformation
+import com.android.launcher3.R
+import com.android.launcher3.util.MainThreadInitializedObject
+import com.patrykmichalik.opto.core.PreferenceManager
+import kotlinx.serialization.json.Json
+
+class LiveInformationManager private constructor(context: Context) : PreferenceManager {
+
+ companion object {
+ private val Context.preferencesDataStore by preferencesDataStore(
+ name = "live-information",
+ )
+
+ @JvmField
+ val INSTANCE = MainThreadInitializedObject(::LiveInformationManager)
+
+ @JvmStatic
+ fun getInstance(context: Context) = INSTANCE.get(context)!!
+ }
+
+ override val preferencesDataStore = context.preferencesDataStore
+
+ val enabled = preference(
+ key = booleanPreferencesKey(name = "enabled"),
+ defaultValue = context.resources.getBoolean(R.bool.config_default_live_information_enabled),
+ )
+
+ val showAnnouncements = preference(
+ key = booleanPreferencesKey(name = "show_announcements"),
+ defaultValue = context.resources.getBoolean(R.bool.config_default_live_information_show_announcements),
+ )
+
+ val liveInformation = preference(
+ key = stringPreferencesKey(name = "live_information"),
+ defaultValue = LiveInformation.default,
+ parse = { Json.decodeFromString(it) },
+ save = { Json.encodeToString(LiveInformation.serializer(), it) },
+ )
+}
+
+@Composable
+fun liveInformationManager() = LiveInformationManager.getInstance(LocalContext.current)
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt
new file mode 100644
index 0000000000..9bdba5ae0a
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt
@@ -0,0 +1,42 @@
+package app.lawnchair.ui.preferences.data.liveinfo
+
+import android.util.Log
+import app.lawnchair.ui.preferences.data.liveinfo.model.LiveInformation
+import app.lawnchair.util.kotlinxJson
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.Retrofit
+import retrofit2.create
+
+private val retrofit = Retrofit.Builder()
+ .baseUrl("https://lawnchair.app/")
+ .addConverterFactory(kotlinxJson.asConverterFactory("application/json".toMediaType()))
+ .build()
+
+val liveInformationService: LiveInformationService = retrofit.create()
+
+suspend fun getLiveInformation(): LiveInformation? = withContext(Dispatchers.IO) {
+ try {
+ val response: Response = liveInformationService.getLiveInformation()
+
+ if (response.isSuccessful) {
+ val responseBody = response.body()?.string() ?: return@withContext null
+
+ val liveInformation = Json.decodeFromString(responseBody)
+ Log.v("LiveInformation", "getLiveInformation: $liveInformation")
+
+ return@withContext liveInformation
+ } else {
+ Log.d("LiveInformation", "getLiveInformation: response code ${response.code()}")
+ return@withContext null
+ }
+ } catch (e: Exception) {
+ Log.e("LiveInformation", "getLiveInformation: Error during news retrieval: ${e.message}")
+ return@withContext null
+ }
+}
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt
new file mode 100644
index 0000000000..4eea50eea1
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt
@@ -0,0 +1,11 @@
+package app.lawnchair.ui.preferences.data.liveinfo
+
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.GET
+
+interface LiveInformationService {
+
+ @GET("live-information.json")
+ suspend fun getLiveInformation(): Response
+}
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt
new file mode 100644
index 0000000000..3fcc58368e
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt
@@ -0,0 +1,26 @@
+package app.lawnchair.ui.preferences.data.liveinfo
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import app.lawnchair.preferences2.asState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun SyncLiveInformation(
+ liveInformationManager: LiveInformationManager = liveInformationManager(),
+) {
+ val enabled by liveInformationManager.enabled.asState()
+
+ LaunchedEffect(enabled) {
+ if (enabled) {
+ CoroutineScope(Dispatchers.IO).launch {
+ getLiveInformation()?.let { liveInformation ->
+ liveInformationManager.liveInformation.set(liveInformation)
+ }
+ }
+ }
+ }
+}
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt
new file mode 100644
index 0000000000..93abe43754
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt
@@ -0,0 +1,11 @@
+package app.lawnchair.ui.preferences.data.liveinfo.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Announcement(
+ val text: String,
+ val url: String? = null,
+ val active: Boolean = true,
+ val test: Boolean = false,
+)
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt
new file mode 100644
index 0000000000..b119399315
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt
@@ -0,0 +1,20 @@
+package app.lawnchair.ui.preferences.data.liveinfo.model
+
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LiveInformation(
+ private val announcements: List,
+) {
+
+ val announcementsImmutable: ImmutableList
+ get() = announcements.toImmutableList()
+
+ companion object {
+ val default = LiveInformation(
+ announcements = emptyList(),
+ )
+ }
+}
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt b/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt
index cac7196fab..3ead3f778e 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt
@@ -36,12 +36,14 @@ import app.lawnchair.preferences.preferenceManager
import app.lawnchair.ui.OverflowMenu
import app.lawnchair.ui.preferences.LocalNavController
import app.lawnchair.ui.preferences.Routes
+import app.lawnchair.ui.preferences.components.AnnouncementPreference
import app.lawnchair.ui.preferences.components.controls.WarningPreference
import app.lawnchair.ui.preferences.components.layout.ClickableIcon
import app.lawnchair.ui.preferences.components.layout.PreferenceCategory
import app.lawnchair.ui.preferences.components.layout.PreferenceDivider
import app.lawnchair.ui.preferences.components.layout.PreferenceLayout
import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
+import app.lawnchair.ui.preferences.data.liveinfo.SyncLiveInformation
import app.lawnchair.ui.preferences.subRoute
import app.lawnchair.util.isDefaultLauncher
import app.lawnchair.util.restartLauncher
@@ -51,12 +53,16 @@ import com.android.launcher3.R
@Composable
fun PreferencesDashboard() {
val context = LocalContext.current
+ SyncLiveInformation()
+
PreferenceLayout(
label = stringResource(id = R.string.settings),
verticalArrangement = Arrangement.Top,
backArrowVisible = false,
actions = { PreferencesOverflowMenu() },
) {
+ AnnouncementPreference()
+
if (BuildConfig.DEBUG) PreferencesDebugWarning()
if (!context.isDefaultLauncher()) {
@@ -189,7 +195,7 @@ fun PreferencesDebugWarning() {
) {
WarningPreference(
// Don't move to strings.xml, no need to translate this warning
- text = "Warning: You are currently using a development build. These builds WILL contain bugs, broken features, and unexpected crashes. Use at your own risk!",
+ text = "You are currently using a development build. Use at your own risk!",
)
}
}