[WIP] Add experimental feed support

This commit is contained in:
MM20
2026-01-04 00:58:35 +01:00
parent 720ea787a9
commit 664419b3c4
35 changed files with 876 additions and 15 deletions

View File

@@ -158,6 +158,7 @@ dependencies {
implementation(project(":data:locations"))
implementation(project(":services:plugins"))
implementation(project(":core:devicepose"))
implementation(project(":services:feed"))
// Uncomment this if you want annoying notifications in your debug builds
//debugImplementation(libs.leakcanary)

View File

@@ -31,6 +31,7 @@ import de.mm20.launcher2.locations.locationsModule
import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.data.plugins.dataPluginsModule
import de.mm20.launcher2.devicepose.devicePoseModule
import de.mm20.launcher2.feed.feedModule
import de.mm20.launcher2.plugins.servicesPluginsModule
import de.mm20.launcher2.preferences.preferencesModule
import de.mm20.launcher2.profiles.profilesModule
@@ -98,6 +99,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
devicePoseModule,
profilesModule,
i18nDataModule,
feedModule,
)
)
}

View File

@@ -161,5 +161,6 @@ dependencies {
implementation(project(":services:global-actions"))
implementation(project(":services:widgets"))
implementation(project(":services:favorites"))
implementation(project(":services:feed"))
implementation(project(":core:devicepose"))
}

View File

@@ -10,6 +10,7 @@ import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
@@ -27,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.IntOffset
@@ -48,6 +50,7 @@ import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.scaffold.ClockAndWidgetsHomeComponent
import de.mm20.launcher2.ui.launcher.scaffold.ClockHomeComponent
import de.mm20.launcher2.ui.launcher.scaffold.DismissComponent
import de.mm20.launcher2.ui.launcher.scaffold.FeedComponent
import de.mm20.launcher2.ui.launcher.scaffold.Gesture
import de.mm20.launcher2.ui.launcher.scaffold.LaunchComponent
import de.mm20.launcher2.ui.launcher.scaffold.LauncherScaffold
@@ -168,7 +171,7 @@ abstract class SharedLauncherActivity(
val darkSearchBar = LocalPreferDarkContentOverWallpaper.current
&& searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark
LaunchedEffect(dimBackground && darkTheme) {
/*LaunchedEffect(dimBackground && darkTheme) {
if (dimBackground && darkTheme) {
val windowAttributes = window.attributes
windowAttributes.flags =
@@ -182,7 +185,7 @@ abstract class SharedLauncherActivity(
window.attributes = windowAttributes
window.setDimAmount(0f)
}
}
}*/
val enterTransitionProgress = remember { mutableStateOf(100f) }
var enterTransition by remember {
@@ -211,6 +214,10 @@ abstract class SharedLauncherActivity(
OverlayHost(
modifier = Modifier
.background(
if (dimBackground && darkTheme) Color( 0f, 0f, 0f, 0.3f)
else Color.Transparent
)
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
@@ -266,6 +273,7 @@ abstract class SharedLauncherActivity(
openKeyboard = searchBarAutofocus,
)
val widgetComponent by lazy { WidgetsComponent }
val feedComponent by lazy { FeedComponent(this@SharedLauncherActivity) }
fun getScaffoldGesture(
action: GestureAction?,
@@ -318,6 +326,11 @@ abstract class SharedLauncherActivity(
animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push,
)
is GestureAction.Feed -> ScaffoldGesture(
component = FeedComponent(this@SharedLauncherActivity),
animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push,
)
is GestureAction.Launch if (searchable != null) -> ScaffoldGesture(
component = LaunchComponent(
this@SharedLauncherActivity,

View File

@@ -0,0 +1,145 @@
package de.mm20.launcher2.ui.launcher.scaffold
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.mm20.launcher2.feed.FeedConnection
import de.mm20.launcher2.feed.FeedService
import de.mm20.launcher2.preferences.feed.FeedSettings
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toIntOffset
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
internal class FeedComponent(
private val context: Context,
) : ScaffoldComponent(), KoinComponent {
private val feedSettings: FeedSettings by inject()
private val feedService: FeedService by inject()
override val permanent: Boolean = true
@Composable
override fun Component(
modifier: Modifier,
insets: PaddingValues,
state: LauncherScaffoldState
) {
val activity = LocalActivity.current
val lifecycleOwner = LocalLifecycleOwner.current
val progress = state.currentProgress
val feedProgress = remember { mutableFloatStateOf(0f) }
val feedProviderPackage by remember { feedSettings.providerPackage }.collectAsState(null)
var feedConnection by remember { mutableStateOf<FeedConnection?>(null) }
val feedReady = feedConnection?.ready?.collectAsState(false)
val feedAvailable = feedConnection?.available?.collectAsState(null)
DisposableEffect(feedProviderPackage) {
val conn = feedProviderPackage?.let {
feedService.createFeedInstance(activity as AppCompatActivity, it) { p ->
feedProgress.floatValue = p
}
}
feedConnection = conn
onDispose {
conn?.destroy()
}
}
val enableScroll = state.currentComponent == this && progress > 0f && progress < 1f
LaunchedEffect(enableScroll) {
if (enableScroll) {
feedConnection?.startScroll()
} else {
feedConnection?.endScroll()
}
}
LaunchedEffect(progress) {
feedConnection?.onScroll(progress)
}
LaunchedEffect(feedProgress.floatValue) {
if (isActive) {
state.setProgress(feedProgress.floatValue)
if (feedProgress.floatValue <= 0f) {
state.onPredictiveBackEnd()
}
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (feedAvailable?.value == false) {
Icon(
painterResource(R.drawable.error_48px),
null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
Text(
"Feed could not be loaded.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
@SuppressLint("ModifierFactoryExtensionFunction")
override fun homePageModifier(
state: LauncherScaffoldState,
defaultModifier: Modifier
): Modifier {
return defaultModifier.alpha(1f - state.currentProgress)
}
@SuppressLint("ModifierFactoryExtensionFunction")
override fun searchBarModifier(
state: LauncherScaffoldState,
defaultModifier: Modifier
): Modifier {
return defaultModifier
.offset { state.currentOffset.toIntOffset() }
.alpha(1f - state.currentProgress)
}
}

View File

@@ -791,17 +791,13 @@ internal class LauncherScaffoldState(
}
}
private val backInterpolation = PathInterpolator(0f, 0f, 0f, 1f)
fun onPredictiveBack(progress: Float) {
if (!isSettledOnSecondaryPage) return
fun setProgress(progress: Float) {
val gesture = currentGesture ?: return
val anim = currentAnimation ?: return
val progress = backInterpolation.getInterpolation(progress)
when (gesture) {
Gesture.TapSearchBar, Gesture.DoubleTap, Gesture.LongPress -> {
currentZOffset = 1f - progress
currentZOffset = progress
}
else -> {
@@ -818,13 +814,13 @@ internal class LauncherScaffoldState(
currentOffset = if (anim == ScaffoldAnimation.Push) {
Offset(
x * size.width * (1f - progress * 0.1f),
y * size.height * (1f - progress * 0.1f)
x * size.width * progress,
y * size.height * progress
)
} else {
Offset(
x * rubberbandThreshold * progress,
y * rubberbandThreshold * progress
x * rubberbandThreshold * (1f - progress),
y * rubberbandThreshold * (1f - progress)
)
}
@@ -832,6 +828,21 @@ internal class LauncherScaffoldState(
}
}
private val backInterpolation = PathInterpolator(0f, 0f, 0f, 1f)
fun onPredictiveBack(progress: Float) {
if (!isSettledOnSecondaryPage) return
val anim = currentAnimation ?: return
val progress = backInterpolation.getInterpolation(progress)
val p = when(anim) {
ScaffoldAnimation.Push -> 1f - progress * 0.1f
else -> 1f - progress
}
setProgress(p)
}
suspend fun onPredictiveBackCancel() {
val gesture = currentGesture ?: return
val anim = currentAnimation ?: return

View File

@@ -70,6 +70,8 @@ import de.mm20.launcher2.ui.settings.easteregg.EasterEggSettingsRoute
import de.mm20.launcher2.ui.settings.easteregg.EasterEggSettingsScreen
import de.mm20.launcher2.ui.settings.favorites.FavoritesSettingsRoute
import de.mm20.launcher2.ui.settings.favorites.FavoritesSettingsScreen
import de.mm20.launcher2.ui.settings.feed.FeedIntegrationSettingsRoute
import de.mm20.launcher2.ui.settings.feed.FeedIntegrationSettingsScreen
import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsRoute
import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsScreen
import de.mm20.launcher2.ui.settings.filterbar.FilterBarSettingsRoute
@@ -305,6 +307,9 @@ class SettingsActivity : BaseActivity() {
entry<SmartspacerSettingsRoute> {
SmartspacerSettingsScreen()
}
entry<FeedIntegrationSettingsRoute> {
FeedIntegrationSettingsScreen()
}
}

View File

@@ -0,0 +1,74 @@
package de.mm20.launcher2.ui.settings.feed
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavKey
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import kotlinx.serialization.Serializable
@Serializable
data object FeedIntegrationSettingsRoute : NavKey
@Composable
fun FeedIntegrationSettingsScreen() {
val context = LocalContext.current
val viewModel: FeedIntegrationSettingsScreenVM = viewModel()
val selectedProvider by viewModel.providerPackage.collectAsState(null)
val providers = remember { viewModel.getFeedProviders(context) }
PreferenceScreen(
title = stringResource(R.string.preference_feed_integration),
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/integrations/feed"
) {
if (providers.isEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.fillParentMaxHeight()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LargeMessage(
icon = R.drawable.news_48px,
text = stringResource(R.string.no_feed_providers),
color = MaterialTheme.colorScheme.secondary
)
}
}
} else {
item {
PreferenceCategory {
for (prov in providers) {
Preference(
title = prov.label,
icon = if (prov.packageName == selectedProvider) R.drawable.radio_button_checked_24px else R.drawable.radio_button_unchecked_24px,
onClick = {
viewModel.setProviderPackage(prov.packageName)
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
package de.mm20.launcher2.ui.settings.feed
import android.content.Context
import android.content.Intent
import android.os.Process
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.feed.FeedProvider
import de.mm20.launcher2.feed.FeedService
import de.mm20.launcher2.preferences.feed.FeedSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.text.Collator
class FeedIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
private val feedService: FeedService by inject()
private val feedSettings: FeedSettings by inject()
val providerPackage = feedSettings.providerPackage
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(0), 1)
fun setProviderPackage(providerPackage: String?) {
feedSettings.setProviderPackage(providerPackage)
}
fun getFeedProviders(context: Context): List<FeedProvider> {
return feedService.getAvailableFeedProviders()
}
}

View File

@@ -61,13 +61,14 @@ fun GestureSettingsScreen() {
add(stringResource(R.string.gesture_action_launch_app) to GestureAction.Launch(null))
}
val optionsWithFeed = options + (stringResource(R.string.gesture_action_feed) to GestureAction.Feed)
val context = LocalContext.current
PreferenceScreen(title = stringResource(R.string.preference_screen_gestures)) {
item {
val appIconSize = 32.dp.toPixels()
PreferenceCategory {
val swipeDown by viewModel.swipeDown.collectAsStateWithLifecycle(null)
val swipeDownApp by viewModel.swipeDownApp.collectAsState(null)
val swipeDownAppIcon by remember(swipeDownApp?.key) {
@@ -128,7 +129,7 @@ fun GestureSettingsScreen() {
icon = R.drawable.swipe_right_alt_24px,
value = swipeRight,
onValueChanged = { viewModel.setSwipeRight(it) },
options = options,
options = optionsWithFeed,
app = swipeRightApp,
appIcon = swipeRightAppIcon,
onAppChanged = { viewModel.setSwipeRightApp(it) }

View File

@@ -11,6 +11,7 @@ import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.locals.LocalBackStack
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsRoute
import de.mm20.launcher2.ui.settings.feed.FeedIntegrationSettingsRoute
import de.mm20.launcher2.ui.settings.media.MediaIntegrationSettingsRoute
import de.mm20.launcher2.ui.settings.nextcloud.NextcloudSettingsRoute
import de.mm20.launcher2.ui.settings.owncloud.OwncloudSettingsRoute
@@ -45,6 +46,13 @@ fun IntegrationsSettingsScreen() {
backStack.add(MediaIntegrationSettingsRoute)
}
)
Preference(
title = stringResource(R.string.preference_feed_integration),
icon = R.drawable.news_24px,
onClick = {
backStack.add(FeedIntegrationSettingsRoute)
}
)
}
}
item {

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L607,120Q623,120 637.5,126Q652,132 663,143L817,297Q828,308 834,322.5Q840,337 840,353L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,360L640,360Q623,360 611.5,348.5Q600,337 600,320L600,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM640,680Q657,680 668.5,668.5Q680,657 680,640Q680,623 668.5,611.5Q657,600 640,600L320,600Q303,600 291.5,611.5Q280,623 280,640Q280,657 291.5,668.5Q303,680 320,680L640,680ZM440,360Q457,360 468.5,348.5Q480,337 480,320Q480,303 468.5,291.5Q457,280 440,280L320,280Q303,280 291.5,291.5Q280,303 280,320Q280,337 291.5,348.5Q303,360 320,360L440,360ZM640,520Q657,520 668.5,508.5Q680,497 680,480Q680,463 668.5,451.5Q657,440 640,440L320,440Q303,440 291.5,451.5Q280,463 280,480Q280,497 291.5,508.5Q303,520 320,520L640,520ZM200,200L200,200L200,360L200,360L200,200L200,360L200,360L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200Z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M180,840Q156,840 138,822Q120,804 120,780L120,180Q120,156 138,138Q156,120 180,120L617,120Q629.44,120 640.72,125Q652,130 660,138L822,300Q830,308 835,319.28Q840,330.56 840,343L840,780Q840,804 822,822Q804,840 780,840L180,840ZM180,780L780,780Q780,780 780,780Q780,780 780,780L780,351L639,351Q626.25,351 617.63,342.37Q609,333.75 609,321L609,180L180,180Q180,180 180,180Q180,180 180,180L180,780Q180,780 180,780Q180,780 180,780ZM651,669Q663.75,669 672.38,660.32Q681,651.65 681,638.82Q681,626 672.38,617.5Q663.75,609 651,609L309,609Q296.25,609 287.63,617.68Q279,626.35 279,639.18Q279,652 287.63,660.5Q296.25,669 309,669L651,669ZM450,351Q462.75,351 471.38,342.32Q480,333.65 480,320.82Q480,308 471.38,299.5Q462.75,291 450,291L309,291Q296.25,291 287.63,299.68Q279,308.35 279,321.18Q279,334 287.63,342.5Q296.25,351 309,351L450,351ZM651,510Q663.75,510 672.38,501.32Q681,492.65 681,479.82Q681,467 672.38,458.5Q663.75,450 651,450L309,450Q296.25,450 287.63,458.68Q279,467.35 279,480.18Q279,493 287.63,501.5Q296.25,510 309,510L651,510ZM180,180L180,180L180,351.43L180,351.43L180,180L180,351.43L180,351.43L180,780Q180,780 180,780Q180,780 180,780L180,780Q180,780 180,780Q180,780 180,780L180,180Q180,180 180,180Q180,180 180,180Z"/>
</vector>

View File

@@ -700,6 +700,8 @@
<string name="preference_smartspacer_integration_description">Smartspacer is a free and open source At a Glance widget. If installed, it can be integrated into the dynamic zone of the clock widget</string>
<string name="preference_smartspacer_enable">Enable Smartspacer integration</string>
<string name="preference_launch_smartspacer_app">Open Smartspacer</string>
<string name="preference_feed_integration">Feed</string>
<string name="no_feed_providers">No feed providers installed</string>
<string name="preference_contacts_call_on_tap">Tap to call</string>
<string name="preference_contacts_call_on_tap_summary">Start a call without confirmation when tapping a phone number</string>
<!-- Used in an info banner if a specific feature requires a Nextcloud account -->
@@ -804,6 +806,7 @@
<string name="gesture_action_quick_settings">Open quick settings</string>
<string name="gesture_action_power_menu">Show power menu</string>
<string name="gesture_action_recents">Open recent apps</string>
<string name="gesture_action_feed">Feed</string>
<string name="gesture_failed_message">You have performed a \"%1$s\" gesture. This gesture is currently set to trigger a \"%2$s\" action. However, the action could not be performed for the following reason:</string>
<string name="missing_permission_accessibility_gesture_failed">The launcher\'s accessibility service needs to be enabled to perform this action.</string>
<string name="missing_permission_accessibility_gesture_settings">This action requires the launcher\'s accessibility service to be enabled.</string>

View File

@@ -202,6 +202,8 @@ data class LauncherSettingsData internal constructor(
*/
val localeTransliterator: String? = "",
val feedProviderPackage: String? = null
) {
constructor(
@@ -394,6 +396,10 @@ sealed interface GestureAction {
@Serializable
@SerialName("launch_searchable")
data class Launch(val key: String?) : GestureAction
@Serializable
@SerialName("feed")
data object Feed : GestureAction
}

View File

@@ -1,6 +1,7 @@
package de.mm20.launcher2.preferences
import de.mm20.launcher2.backup.Backupable
import de.mm20.launcher2.preferences.feed.FeedSettings
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import de.mm20.launcher2.preferences.media.MediaSettings
import de.mm20.launcher2.preferences.search.CalculatorSearchSettings
@@ -52,4 +53,5 @@ val preferencesModule = module {
factory { LocationSearchSettings(get()) }
factory { SearchFilterSettings(get()) }
factory { LocaleSettings(get()) }
factory { FeedSettings(get()) }
}

View File

@@ -0,0 +1,18 @@
package de.mm20.launcher2.preferences.feed
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.flow.map
class FeedSettings internal constructor(
private val launcherDataStore: LauncherDataStore,
) {
val providerPackage
get() = launcherDataStore.data.map { it.feedProviderPackage }
fun setProviderPackage(providerPackage: String?) {
launcherDataStore.update {
it.copy(feedProviderPackage = providerPackage)
}
}
}

View File

@@ -0,0 +1,38 @@
# Feed
The feed is a personalized content page that lives to the left of your home screen, that can be
opened without leaving the launcher. Traditionally, launchers show the Google Discover feed here,
but there are other alternatives.
## Feed providers
The feed content is provided by a third party app, which needs to be installed on the device.
### Google Discover
On most devices, the Google app is preinstalled as a feed provider. Unfortunately, the Google app
can't be embedded as a feed directly, because it only allows to be embedded by system apps and
development builds. If you want to use Google Discover as your feed provider, you can
install [AIDL Bridge](https://github.com/amirzaidi/AIDLBridge/releases)
as a workaround. AIDL Bridge acts as a bridge to allow third party apps to connect to the
Google Discover feed.
> [!WARNING]
> AIDL Bridge uses an unofficial workaround to expose the Google Discover feed to third party apps.
> This workaround might stop working without further notice. Use at your own risk.
### Other providers
The following feed providers have been tested and are known to be working:
- **[Neo-Feed](https://github.com/NeoApplications/Neo-Feed)**: A simple RSS feed reader
- **[Smartspacer](https://github.com/KieronQuinn/Smartspacer)**: A customizable widgets page
## Enable the feed
After you have installed at least one feed provider, you can enable it under Settings >
Integrations > Feed. Then you can assign the feed action to the swipe right gesture under Settings >
Gestures
> [!NOTE]
> The feed can only be assigned to the swipe right gesture

View File

@@ -47,6 +47,7 @@ junitVersion = "1.2.1"
espressoCore = "3.6.1"
osmOpeningHours = "0.4.0"
material = "1.13.0"
[libraries]
mustache-compiler = { module = "com.github.spullara.mustache.java:compiler", version.ref = "mustache" }
@@ -135,6 +136,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
osmopeninghours = { group = "de.westnordost", name = "osm-opening-hours", version.ref = "osmOpeningHours" }
smartspacer = { group = "com.kieronquinn.smartspacer", name = "sdk-client", version = "1.1.2" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[bundles]
kotlin = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android", "kotlinx-collections-immutable", "kotlinx-serialization-json"]

1
services/feed/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,58 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
}
android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_1_8)
}
}
buildFeatures {
aidl = true
}
namespace = "de.mm20.launcher2.feed"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)
implementation(project(":core:ktx"))
implementation(project(":core:preferences"))
implementation(project(":core:crashreporter"))
implementation(project(":core:base"))
}

View File

21
services/feed/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,7 @@
package amirz.aidlbridge;
import amirz.aidlbridge.IBridgeCallback;
interface IBridge {
oneway void bindService(in IBridgeCallback cb, in int flags);
}

View File

@@ -0,0 +1,7 @@
package amirz.aidlbridge;
interface IBridgeCallback {
oneway void onServiceConnected(in ComponentName name, in IBinder service);
oneway void onServiceDisconnected(in ComponentName name);
}

View File

@@ -0,0 +1,42 @@
package com.google.android.libraries.launcherclient;
import android.view.WindowManager.LayoutParams;
import com.google.android.libraries.launcherclient.ILauncherOverlayCallback;
interface ILauncherOverlay {
oneway void startScroll();
oneway void onScroll(in float progress);
oneway void endScroll();
oneway void windowAttached(in LayoutParams lp, in ILauncherOverlayCallback cb, in int flags);
oneway void windowDetached(in boolean isChangingConfigurations);
oneway void closeOverlay(in int flags);
oneway void onPause();
oneway void onResume();
oneway void openOverlay(in int flags);
oneway void requestVoiceDetection(in boolean start);
String getVoiceSearchLanguage();
boolean isVoiceDetectionRunning();
boolean hasOverlayContent();
oneway void windowAttached2(in Bundle bundle, in ILauncherOverlayCallback cb);
oneway void unusedMethod();
oneway void setActivityState(in int flags);
boolean startSearch(in byte[] data, in Bundle bundle);
}

View File

@@ -0,0 +1,9 @@
package com.google.android.libraries.launcherclient;
interface ILauncherOverlayCallback {
oneway void overlayScrollChanged(float progress);
oneway void overlayStatusChanged(int status);
}

View File

@@ -0,0 +1,5 @@
package de.mm20.launcher2.feed
fun interface FeedCallback {
fun onOverlayScrollChanged(progress: Float)
}

View File

@@ -0,0 +1,211 @@
package de.mm20.launcher2.feed
import amirz.aidlbridge.IBridge
import amirz.aidlbridge.IBridgeCallback
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.os.RemoteException
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.libraries.launcherclient.ILauncherOverlay
import com.google.android.libraries.launcherclient.ILauncherOverlayCallback
import de.mm20.launcher2.crashreporter.CrashReporter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
interface FeedConnection {
val available: StateFlow<Boolean>
val ready: StateFlow<Boolean>
fun restart()
fun destroy()
fun startScroll()
fun endScroll()
fun onScroll(progress: Float)
}
internal class FeedConnectionImpl(
private val activity: AppCompatActivity,
private val serviceIntent: Intent,
private val callback: FeedCallback,
) : IBridgeCallback.Stub(), FeedConnection, ServiceConnection, DefaultLifecycleObserver {
private var isActivityStarted = false
private var isActivityResumed = false
private val activityState: Int
get() {
return (if (isActivityResumed) 2 else 0) + (if (isActivityStarted) 1 else 0)
}
override val available = MutableStateFlow(true)
override val ready = MutableStateFlow(false)
private var overlay: ILauncherOverlay? = null
init {
start()
}
override fun startScroll() {
try {
overlay?.startScroll()
} catch (e: RemoteException) {
CrashReporter.logException(e)
}
}
override fun endScroll() {
try {
overlay?.endScroll()
} catch (e: RemoteException) {
CrashReporter.logException(e)
}
}
override fun onScroll(progress: Float) {
try {
overlay?.onScroll(progress)
} catch (e: RemoteException) {
CrashReporter.logException(e)
}
}
fun start() {
activity.lifecycle.addObserver(this)
val service = activity.packageManager.resolveService(serviceIntent, 0)
if (service?.serviceInfo == null) {
available.value = false
ready.value = false
return
} else {
available.value = true
}
activity.bindService(serviceIntent, this, Flags)
}
override fun restart() {
destroy()
start()
}
override fun destroy() {
activity.lifecycle.removeObserver(this)
try {
activity.unbindService(this)
} catch (e: IllegalArgumentException) {
// Service was not bound
}
}
override fun onServiceConnected(
name: ComponentName?,
service: IBinder?
) {
if (service == null) {
ready.value = false
available.value = false
overlay = null
return
}
try {
if (service.interfaceDescriptor == IBridge.DESCRIPTOR) {
val bridge = IBridge.Stub.asInterface(service)
bridge.bindService(this, Flags)
} else if (service.interfaceDescriptor == ILauncherOverlay.DESCRIPTOR) {
overlay = ILauncherOverlay.Stub.asInterface(service)
sendConfig()
ready.value = true
} else {
Log.e(
"FeedConnection",
"Unknown service descriptor \"${service.interfaceDescriptor}\" for intent $serviceIntent"
)
available.value = false
ready.value = false
destroy()
}
} catch (e: RemoteException) {
CrashReporter.logException(e)
ready.value = false
available.value = false
destroy()
}
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w("FeedConnection", "service has been disconnected")
ready.value = false
overlay = null
}
override fun onBindingDied(name: ComponentName?) {
super.onBindingDied(name)
Log.w("FeedConnection", "binding has died :(")
restart()
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
isActivityStarted = true
overlay?.setActivityState(activityState)
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
isActivityResumed = true
overlay?.setActivityState(activityState)
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
isActivityResumed = false
overlay?.setActivityState(activityState)
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
isActivityStarted = false
overlay?.setActivityState(activityState)
}
@Throws(RemoteException::class)
private fun sendConfig() {
val layoutParams = activity.window.attributes
val callback = object : ILauncherOverlayCallback.Stub() {
override fun overlayScrollChanged(progress: Float) {
callback.onOverlayScrollChanged(progress)
}
override fun overlayStatusChanged(status: Int) {
Log.d("FeedConnection", "overlayStatusChanged: $status")
}
}
overlay?.windowAttached2(
Bundle().also {
it.putParcelable("layout_params", layoutParams)
it.putParcelable("configuration", activity.resources.configuration);
it.putInt("client_options", Flags)
},
callback,
)
overlay?.setActivityState(activityState)
}
companion object {
private const val Flags = Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT
}
}

View File

@@ -0,0 +1,6 @@
package de.mm20.launcher2.feed
data class FeedProvider(
val label: String,
val packageName: String,
)

View File

@@ -0,0 +1,78 @@
package de.mm20.launcher2.feed
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Process
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import java.text.Collator
class FeedService(
private val context: Context,
) {
fun createFeedInstance(
activity: AppCompatActivity,
feedProvider: String,
callback: FeedCallback,
): FeedConnection {
val intent = Intent(
"com.android.launcher3.WINDOW_OVERLAY",
).also {
it.`package` = feedProvider
it.data = "app://${context.packageName}:${Process.myUid()}".toUri()
.buildUpon()
.appendQueryParameter("v", "7")
.appendQueryParameter("cv", "9")
.build()
}
return FeedConnectionImpl(
activity,
intent.setPackage(feedProvider),
callback,
)
}
fun getAvailableFeedProviders(): List<FeedProvider> {
val services = context.packageManager.queryIntentServices(
Intent(
"com.android.launcher3.WINDOW_OVERLAY",
).also {
it.data = "app://${context.packageName}:${Process.myUid()}".toUri()
.buildUpon()
.appendQueryParameter("v", "7")
.appendQueryParameter("cv", "9")
.build()
}, 0
)
val collator = Collator.getInstance().apply { strength = Collator.SECONDARY }
return services.map {
FeedProvider(
it.loadLabel(context.packageManager).toString(),
it.serviceInfo.packageName,
)
}
.filter {
it.packageName !in BlocklistedPackages
}
.sortedWith { el1, el2 ->
collator.compare(el1.label, el2.label)
}
}
companion object {
/**
* These overlay providers are known to not work with third party apps; block them to avoid
* confusion.
*/
private val BlocklistedPackages = if (BuildConfig.DEBUG) {
emptySet()
} else {
setOf(
"com.google.android.googlequicksearchbox",
"app.lawnchair.lawnfeed",
)
}
}
}

View File

@@ -0,0 +1,8 @@
package de.mm20.launcher2.feed
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val feedModule = module {
single { FeedService(androidContext()) }
}

View File

@@ -0,0 +1,18 @@
package de.mm20.launcher2.feed
import amirz.aidlbridge.IBridgeCallback
import android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
class ServiceConnection: IBridgeCallback.Stub(), ServiceConnection {
override fun onServiceConnected(
name: ComponentName?,
service: IBinder?
) {
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}

View File

@@ -68,3 +68,4 @@ include(":services:plugins")
include(":core:devicepose")
include(":core:profiles")
include(":data:i18n")
include(":services:feed")