google / dagger

A fast dependency injector for Android and Java.
https://dagger.dev
Apache License 2.0
17.45k stars 2.02k forks source link

WorkerAssistedFactories are not created on APK builds #4213

Closed Pezcraft closed 10 months ago

Pezcraft commented 10 months ago

I get the following exception for 2 of my CoroutineWorkers with additional parameters injected by Hilt. This does not happen for workers without additional parameters and only happens when I install the app via APK by my fastlane build. Android Studio builds work perfectly. But my build pipeline uses the same settings as Android Studio builds. By work, I mean that the workers get enqueued and do not throw the following exception...

Could not instantiate com.example.android.ActivitySyncWorker
    java.lang.NoSuchMethodException: 
       com.example.android.ActivitySyncWorker.<init> [class android.content.Context, class androidx.work.WorkerParameters]
       at java.lang.Class.getConstructor0(Class.java:3325)
       at java.lang.Class.getDeclaredConstructor(Class.java:3063)
       at androidx.work.WorkerFactory.createWorkerWithDefaultFallback(WorkerFactory.java:94)
       at androidx.work.impl.WorkerWrapper.runWorker(WorkerWrapper.java:243)
       at androidx.work.impl.WorkerWrapper.run(WorkerWrapper.java:144)
       at androidx.work.impl.utils.SerialExecutorImpl$Task.run(SerialExecutorImpl.java:96)
       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
       at java.lang.Thread.run(Thread.java:1012)

The title of this issue already states that the internal variable mWorkerFactories is empty. My worker cannot be found then. But with Android Studio builds it's not empty. Before I continue. What makes this question different from other similar ones is, that I have a multi-module project. Maybe I am missing something because of it.

This is the Worker inside the app module :core-ble

@HiltWorker
class ActivitySyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    val bleServiceModule: BleServiceModule,
    private val connectionPool: BleConnectionPool,
) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        return coroutineScope {
            try {
                // ...

                Result.success()
            } catch (exception: Exception) {
                SLog.e("[BLE_SYNC] worker error: $exception")
                if (runAttemptCount < 2) {
                    Result.retry()
                } else {
                    Result.failure()
                }
            }
        }
    }

    companion object {
        private const val ACTIVITY_SYNC_WORKER_TAG = "activity_sync"

        fun enqueue(context: Context, workManager: WorkManager, workerParams: Data = Data.EMPTY) {
            val workRequest = PeriodicWorkRequestBuilder<ActivitySyncWorker>(30, TimeUnit.MINUTES).apply {
                addTag(ACTIVITY_SYNC_WORKER_TAG)
                setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .build()
                )
                setInputData(
                    Data.Builder()
                        .putAll(workerParams)
                        .build()
                )
            }.build()

            workManager.enqueueUniquePeriodicWork(
                /* uniqueWorkName = */ ACTIVITY_SYNC_WORKER_TAG,
                /* existingPeriodicWorkPolicy = */ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
                /* periodicWork = */ workRequest
            )
        }

        fun stop(context: Context) {
            WorkManager.getInstance(context).cancelAllWorkByTag(ACTIVITY_SYNC_WORKER_TAG)
        }
    }
}

This worker gets enqueued always, but has no additional injected parameters. Because of reflection, it can be found.

@HiltWorker
class RemoteConfigSyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        return coroutineScope {
            try {
                // ...

                Result.success()
            } catch (exception: Exception) {
                if (runAttemptCount < 2) {
                    Result.retry()
                } else {
                    Result.failure()
                }
            }
        }
    }

    companion object {
        private const val REMOTE_CONFIG_SYNC_WORKER_TAG = "remote_config_sync"

        fun enqueue(workManager: WorkManager, workerParams: Data = Data.EMPTY) {
            val workRequest = PeriodicWorkRequestBuilder<RemoteConfigSyncWorker>(30, TimeUnit.MINUTES).apply {
                addTag(REMOTE_CONFIG_SYNC_WORKER_TAG)
                setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .build()
                )
                setInputData(
                    Data.Builder()
                        .putAll(workerParams)
                        .build()
                )
            }.build()

            workManager.enqueueUniquePeriodicWork(
                /* uniqueWorkName = */ REMOTE_CONFIG_SYNC_WORKER_TAG,
                /* existingPeriodicWorkPolicy = */ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
                /* periodicWork = */ workRequest
            )
        }

        fun stop(context: Context) {
            WorkManager.getInstance(context).cancelAllWorkByTag(REMOTE_CONFIG_SYNC_WORKER_TAG)
        }
    }
}

As you see in my Manifest, I removed the default Initializer and added my own...

        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="androidx.work.WorkManagerInitializer"
                android:value="androidx.startup"
                tools:node="remove" />
            <meta-data
                android:name="com.example.android.WorkManagerModule"
                android:value="androidx.startup" />
        </provider>

This is the new Initializer...

@Module
@InstallIn(SingletonComponent::class)
object WorkManagerModule : Initializer<WorkManager> {

    private var isInitialized = false

    @Provides
    @Singleton
    override fun create(@ApplicationContext context: Context): WorkManager {
        if (!isInitialized) { // just in case this gets called twice
            val entryPoint = EntryPointAccessors.fromApplication(
                context,
                HiltWorkerFactoryEntryPoint::class.java
            )

            val configuration = Configuration.Builder()
                .setWorkerFactory(entryPoint.workerFactory())
                .setMinimumLoggingLevel(Log.VERBOSE)
                .build()

            WorkManager.initialize(context, configuration)

            isInitialized = true
        }

        return WorkManager.getInstance(context)
    }

    override fun dependencies(): MutableList<Class<out Initializer<*>>> {
        return mutableListOf()
    }

    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface HiltWorkerFactoryEntryPoint {
        fun workerFactory(): HiltWorkerFactory
    }
}

I do this in onCreate of my MultiDexApplication which is located in :app

AppInitializer.getInstance(this).initializeComponent(WorkManagerModule::class.java)

My imports... (in both :core-ble and :app.

 implementation(libs.hilt)
 implementation(libs.hilt.work)
 implementation(libs.hilt.plugin)
 kapt(libs.hilt.compiler)
 kapt(libs.hilt.androidx.compiler)
 implementation(libs.work.runtime.ktx)
 implementation(libs.startup)
hilt = "2.44.2"
hiltAndroidX = "1.1.0"
hiltCompose = "1.0.0"
hiltWork = "1.0.0"
work = "2.9.0"
hiltKapt = "2.35"

hilt-plugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
hilt-kapt = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hiltKapt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-androidx-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltAndroidX" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" }
startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "startup" }
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "work" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }

I also tried adding these ProGuard rules, but why should it work with Android Studio builds but not with APK builds.

-keepclassmembers class * extends androidx.work.Worker {
    public <init>(android.content.Context,androidx.work.WorkerParameters);
}

-keepclassmembers class * extends androidx.work.CoroutineWorker {
    public <init>(android.content.Context,androidx.work.WorkerParameters);
}

This is my hilt module for one of the parameters of the WorkManagers. Same for the parameters inside provideBleServiceModule

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

    @Singleton
    @Provides
    fun provideBleServiceModule(
        @ApplicationContext context: Context,
        sharedPrefHelper: CommonsSharedPrefHelper,
        bluetoothAdapterStateModule: BluetoothAdapterStateModule,
        userDbService: UserDbService,
        bleEvents: BleEvents,
    ): BleServiceModule = BleServiceModule(
        context,
        sharedPrefHelper,
        bluetoothAdapterStateModule,
        userDbService,
        bleEvents,
    )
}
danysantiago commented 10 months ago

One thing that strikes me a bit odd is that your have implementation(libs.hilt.plugin) which I understand as your project depending on the Hilt Gradle plugin as if it was a library with APIs to be used in your project but thats not quite how a Gradle Plugin is applied. Can you confirm you are indeed applying the plugin as described in https://dagger.dev/hilt/gradle-setup.html, either with the apply plugin syntax or the newer plugins DSL.

In essence HiltWorkerFactory is a multibinding of all @HiltWorker annotated so if you debug and breakpoint inside the factory you should see a map where you can confirm if the worker is in the map a signal that the multibinding module was picked up and generated. If you don't see it then either the @InstallIn module based of the @HiltWorker is not being generated or the multibinding is not being discovered because it is not a direct dependency of the app module or it is a transitive dependency but the Hilt Gradle Plugin which does aggregation is not being applied.

Pezcraft commented 10 months ago

I am applying the plugin like this... root build.gradle

buildscript {
    repositories {
        google()
        mavenCentral()
        jitpack()
    }
    dependencies {
        classpath(libs.kotlingradleplugin)
        classpath(libs.gradleplugin)
        classpath(libs.play.services)
        classpath(libs.undercouch.download)
        classpath(libs.firebase.plugins)
        classpath(libs.hilt.plugin)
        classpath(libs.firebase.crashlytics.gradle)
        classpath(libs.paparazzi.gradle)
    }
}

plugins {
    id("com.google.dagger.hilt.android") version "2.44.2" apply false
}

And in :app and :core-ble module...

plugins {
    id("com.android.application") //this is just in :app
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
    id("kotlin-kapt")
    id("kotlin-parcelize")
    id("dagger.hilt.android.plugin")
    id("com.google.dagger.hilt.android")
}

I debugged HiltWorkerFactory and the map is empty. So how can this be fixed in feature modules?

danysantiago commented 10 months ago

Can you please clarify if :core-ble is a feature module as described in https://developer.android.com/guide/playcore/feature-delivery? If that is the case, then sadly having a Hilt worker in such module is not supported as those modules cannot be correctly aggregated as described in https://developer.android.com/training/dependency-injection/hilt-multi-module#dfm

If it is just a library module, as in a Gradle module, then can you try having :app directly depend on :core-ble ?

Pezcraft commented 10 months ago

Sorry it's not. It's just a module implemented by :app

Pezcraft commented 10 months ago

I ended up writing my own WorkerFactory and injecting the necessary parameters there...

class BleWorkerFactory @Inject constructor(
    private val bleServiceModule: BleServiceModule,
) : WorkerFactory() {
    override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? {
        return when (workerClassName) {
            RemoteConfigSyncWorker::class.java.name -> RemoteConfigSyncWorker(appContext, workerParameters)
            HardwareReportWorker::class.java.name -> HardwareReportWorker(appContext, workerParameters, bleServiceModule)
            ActivitySyncWorker::class.java.name -> ActivitySyncWorker(appContext, workerParameters, bleServiceModule)
            else -> null
        }
    }
}
@Singleton
class MyDelegatingWorkerFactory @Inject constructor(
    bleWorkerFactory: BleWorkerFactory
) : DelegatingWorkerFactory() {
    init {
        addFactory(bleWorkerFactory)
    }
}

In my application I do not implement Configuration.Provider since I got a RuntimeException: Unable to instantiate application when overriding the workManagerConfiguration val. Instead I set the configuration in onCreate().

@HiltAndroidApp
open class RandomHiltApplication : MyApplication() {

    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WorkerFactoryEntryPoint {
        fun workerFactory(): MyDelegatingWorkerFactory
    }

    override fun onCreate() {
        val workManagerConfiguration: Configuration = Configuration.Builder()
            .setWorkerFactory(EntryPoints.get(this, WorkerFactoryEntryPoint::class.java).workerFactory())
            .setMinimumLoggingLevel(Log.VERBOSE)
            .build()
        WorkManager.initialize(this, workManagerConfiguration)
        super.onCreate()
    }
}

My Fastlane builds are now working and I am happy with it. Sadly, I couldn't find out, why the HiltWorkerFactory is not working.

shivamsriva31093 commented 2 months ago

This is the only working suggestion. Thank you @Pezcraft