[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

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?) {
}
}