androidkotlindagger-hiltandroid-workmanager

NoSuchMethodException thrown by HiltWorker with additional parameters


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 internal variable of Hilt 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,
    )
}

Solution

  • 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.