diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00de34d28a..d769563e80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -402,6 +402,7 @@ dependencies { implementation(libs.phrase) implementation(libs.copper.flow) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.guava) implementation(libs.kovenant) implementation(libs.kovenant.android) implementation(libs.opencsv) diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt index 8aacba08a4..c86568de4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt @@ -288,10 +288,6 @@ class RemoteFileDownloadWorker @AssistedInject constructor( return File(downloadsDirectory(context), remote.sha256Hash()) } - fun cancelAll(context: Context) { - WorkManager.getInstance(context).cancelAllWorkByTag(TAG) - } - private fun uniqueWorkName(remote: RemoteFile): String { return "download-remote-file-${remote.sha256Hash()}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt index dcce84bcd2..ffaa0e1ef8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.notifications import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -10,8 +12,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import org.session.libsession.database.userAuth @@ -25,28 +28,28 @@ import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton -private const val TAG = "PushRegistrationHandler" - /** - * A class that listens to the config, user's preference, token changes and - * register/unregister push notification accordingly. + * PN registration source of truth using per-account periodic workers. + * + * Periodic workers must be created with tags: + * - "pn-register-periodic" + * - "pn-acc-" + * - "pn-tfp-" * - * This class DOES NOT handle the legacy groups push notification. */ @Singleton -class PushRegistrationHandler -@Inject -constructor( +class PushRegistrationHandler @Inject constructor( private val configFactory: ConfigFactory, private val preferences: TextSecurePreferences, private val tokenFetcher: TokenFetcher, - @param:ApplicationContext private val context: Context, + @ApplicationContext private val context: Context, private val registry: PushRegistryV2, private val storage: Storage, - @param:ManagerScope private val scope: CoroutineScope + @ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { private var job: Job? = null @@ -62,83 +65,171 @@ constructor( .onStart { emit(Unit) }, preferences.watchLocalNumber(), preferences.pushEnabled, - tokenFetcher.token, - ) { _, myAccountId, enabled, token -> - if (!enabled || myAccountId == null || storage.getUserED25519KeyPair() == null || token.isNullOrEmpty()) { - return@combine emptySet() + tokenFetcher.token + ) { _, _, enabled, token -> + val desired = + if (enabled && hasCoreIdentity()) + desiredSubscriptions() + else emptySet() + Triple(enabled, token, desired) + } + .distinctUntilChanged() + .collect { (pushEnabled, token, desiredIds) -> + try { + reconcileWithWorkManager(pushEnabled, token, desiredIds) + } catch (t: Throwable) { + Log.e(TAG, "Reconciliation failed", t) } + } + } + } + + private suspend fun reconcileWithWorkManager( + pushEnabled: Boolean, + token: String?, + activeAccounts: Set + ) { + val wm = WorkManager.getInstance(context) + + // Read existing push periodic workers and parse (AccountId, tokenFingerprint) from tags. + val periodicInfos = wm.getWorkInfosByTag(TAG_PERIODIC).await() + .filter { it.state != WorkInfo.State.CANCELLED && it.state != WorkInfo.State.FAILED } - setOf(SubscriptionKey(AccountId(myAccountId), token)) + getGroupSubscriptions(token) + Log.d(TAG, "We currently have ${periodicInfos.size} push periodic workers") + + val accountsAlreadyRegistered: Map = buildMap { + for (info in periodicInfos) { + val id = parseAccountId(info) ?: continue + val token = parseTokenFingerprint(info) ?: continue + put(id, token) } - .scan(emptySet() to emptySet()) { acc, current -> - acc.second to current - } - .collect { (prev, current) -> - val added = current - prev - val removed = prev - current - if (added.isNotEmpty()) { - Log.d(TAG, "Adding ${added.size} new subscriptions") - } + } - if (removed.isNotEmpty()) { - Log.d(TAG, "Removing ${removed.size} subscriptions") + // If push disabled or identity missing → cancel all and try to deregister. + if (!pushEnabled || !hasCoreIdentity()) { + val toCancel = accountsAlreadyRegistered.keys + if (toCancel.isNotEmpty()) { + Log.d(TAG, "Push disabled/identity missing; cancelling ${toCancel.size} PN periodic works") + } + supervisorScope { + toCancel.forEach { id -> + launch { + PushRegistrationWorker.cancelAll(context, id) + tryUnregister(token, id) } + } + } + return + } - for (key in added) { - PushRegistrationWorker.schedule( - context = context, - token = key.token, - accountId = key.accountId, - ) - } + val currentFingerprint = token?.let { tokenFingerprint(it) } - supervisorScope { - for (key in removed) { - PushRegistrationWorker.cancelRegistration( - context = context, - accountId = key.accountId, - ) - - launch { - Log.d(TAG, "Unregistering push token for account: ${key.accountId}") - try { - val swarmAuth = swarmAuthForAccount(key.accountId) - ?: throw IllegalStateException("No SwarmAuth found for account: ${key.accountId}") - - registry.unregister( - token = key.token, - swarmAuth = swarmAuth, - ) - - Log.d(TAG, "Successfully unregistered push token for account: ${key.accountId}") - } catch (e: Exception) { - if (e !is CancellationException) { - Log.e(TAG, "Failed to unregister push token for account: ${key.accountId}", e) - } - } - } - } - } + // Add missing (ensure periodic + run now) — only if we have a token. + val accountsToAdd = activeAccounts - accountsAlreadyRegistered.keys + if (accountsToAdd.isNotEmpty()) Log.d(TAG, "Adding ${accountsToAdd.size} PN registrations") + if (!token.isNullOrEmpty()) { + accountsToAdd.forEach { id -> + PushRegistrationWorker.ensurePeriodic(context, id, token, replace = false) // KEEP + PushRegistrationWorker.scheduleImmediate(context, id, token) // run now + } + } + + // Token rotation: replace periodic where fingerprint mismatches. + if (!token.isNullOrEmpty()) { + var replaced = 0 + activeAccounts.forEach { id -> + val tokenFingerprint = accountsAlreadyRegistered[id] ?: return@forEach + if (tokenFingerprint != currentFingerprint) { + PushRegistrationWorker.ensurePeriodic(context, id, token, replace = true) // REPLACE + PushRegistrationWorker.scheduleImmediate(context, id, token) + replaced++ + } + } + if (replaced > 0) Log.d(TAG, "Replaced $replaced periodic PN workers due to token rotation") + } + + // Removed subscriptions: cancel workers & attempt deregister. + val accountToRemove = accountsAlreadyRegistered.keys - activeAccounts + if (accountToRemove.isNotEmpty()) Log.d(TAG, "Removing ${accountToRemove.size} PN registrations") + supervisorScope { + accountToRemove.forEach { id -> + launch { + PushRegistrationWorker.cancelAll(context, id) + tryUnregister(token, id) } + } + } + } + + /** + * Build desired subscriptions: self (local number) + any group that shouldPoll. + * */ + private fun desiredSubscriptions(): Set = buildSet { + preferences.getLocalNumber()?.let { add(AccountId(it)) } + val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + groups.filter { it.shouldPoll } + .mapTo(this) { AccountId(it.groupAccountId) } + } + + private fun hasCoreIdentity(): Boolean { + return preferences.getLocalNumber() != null && storage.getUserED25519KeyPair() != null + } + + /** + * Try to deregister if we still have credentials and a token to sign with. + * Safe to no-op if token/auth missing (e.g., keys already deleted). + */ + private suspend fun tryUnregister(token: String?, accountId: AccountId) { + if (token.isNullOrEmpty()) return + val auth = swarmAuthForAccount(accountId) ?: return + try { + Log.d(TAG, "Unregistering PN for $accountId") + registry.unregister(token = token, swarmAuth = auth) + Log.d(TAG, "Unregistered PN for $accountId") + } catch (e: Exception) { + if (e !is CancellationException) { + Log.e(TAG, "Unregister failed for $accountId", e) + } else { + throw e + } } } private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth? { return when (accountId.prefix) { IdPrefix.STANDARD -> storage.userAuth?.takeIf { it.accountId == accountId } - IdPrefix.GROUP -> configFactory.getGroupAuth(accountId) - else -> null // Unsupported account ID prefix + IdPrefix.GROUP -> configFactory.getGroupAuth(accountId) + else -> null } } - private fun getGroupSubscriptions( - token: String - ): Set { - return configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } - .asSequence() - .filter { it.shouldPoll } - .mapTo(hashSetOf()) { SubscriptionKey(accountId = AccountId(it.groupAccountId), token = token) } + private fun parseAccountId(info: WorkInfo): AccountId? { + val tag = info.tags.firstOrNull { it.startsWith(ARG_ACCOUNT_ID) } ?: return null + val hex = tag.removePrefix(ARG_ACCOUNT_ID) + return AccountId.fromStringOrNull(hex) } - private data class SubscriptionKey(val accountId: AccountId, val token: String) -} \ No newline at end of file + private fun parseTokenFingerprint(info: WorkInfo): String? { + val tag = info.tags.firstOrNull { it.startsWith(ARG_TOKEN) } ?: return null + return tag.removePrefix(ARG_TOKEN) + } + + companion object { + private const val TAG = "PushRegistrationHandler" + + const val TAG_PERIODIC = "pn-register-periodic" + const val ARG_ACCOUNT_ID = "pn-account-" + const val ARG_TOKEN = "pn-token-" + + fun tokenFingerprint(token: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(token.toByteArray(Charsets.UTF_8)) + val short = digest.copyOfRange(0, 8) // 64 bits is plenty for equality checks + @Suppress("InlinedApi") + return android.util.Base64.encodeToString( + short, + android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index f9c3810bbd..2122aaa359 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -6,10 +6,12 @@ import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await @@ -19,30 +21,41 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import network.loki.messenger.libsession_util.Namespace import org.session.libsession.database.userAuth +import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.ARG_ACCOUNT_ID +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.ARG_TOKEN +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.TAG_PERIODIC +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.tokenFingerprint import java.time.Duration +import java.util.concurrent.TimeUnit @HiltWorker class PushRegistrationWorker @AssistedInject constructor( - @Assisted val context: Context, - @Assisted val params: WorkerParameters, - val registry: PushRegistryV2, - val storage: Storage, - val configFactory: ConfigFactory, + @Assisted private val context: Context, + @Assisted params: WorkerParameters, + private val tokenFetcher: TokenFetcher, // this is only used as a stale-token GUARD + private val storage: Storage, + private val configFactory: ConfigFactory, + private val registry: PushRegistryV2, ) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - val accountId = checkNotNull(inputData.getString(ARG_ACCOUNT_ID) - ?.let(AccountId::fromStringOrNull)) { - "PushRegistrationWorker requires a valid account ID" - } - val token = checkNotNull(inputData.getString(ARG_TOKEN)) { - "PushRegistrationWorker requires a valid FCM token" + override suspend fun doWork(): Result { + val accountId = inputData.getString(ARG_ACCOUNT_ID)?.let(AccountId::fromStringOrNull) + ?: return Result.failure() + val token = inputData.getString(ARG_TOKEN) ?: return Result.failure() + + // Safety guard: if the current token changed, don't register the stale one. + tokenFetcher.token.value?.let { current -> + if (current.isNotEmpty() && current != token) { + Log.d(TAG, "Stale token for $accountId; skipping run.") + return Result.success() // no errors, we don't want to retry here + } } Log.d(TAG, "Registering push token for account: $accountId with token: ${token.substring(0..10)}") @@ -68,64 +81,81 @@ class PushRegistrationWorker @AssistedInject constructor( } } - try { + return try { registry.register(token = token, swarmAuth = swarmAuth, namespaces = namespaces) - Log.d(TAG, "Successfully registered push token for account: $accountId") - return Result.success() - } catch (e: CancellationException) { + Result.success() + } + catch (e: CancellationException) { Log.d(TAG, "Push registration cancelled for account: $accountId") throw e - } catch (e: Exception) { - Log.e(TAG, "Unexpected error while registering push token for account: $accountId", e) - return if (e is NonRetryableException) Result.failure() else Result.retry() + } + catch (e: NonRetryableException) { + Log.e(TAG, "Non retryable error while registering push token for account: $accountId", e) + Result.failure() + } + catch (_: Throwable){ + Log.e(TAG, "Error while registering push token for account: $accountId") + Result.retry() } } companion object { - private const val ARG_TOKEN = "token" - private const val ARG_ACCOUNT_ID = "account_id" - private const val TAG = "PushRegistrationWorker" - private val GROUP_PUSH_NAMESPACES = listOf( - Namespace.GROUP_MESSAGES(), - Namespace.GROUP_INFO(), - Namespace.GROUP_MEMBERS(), - Namespace.GROUP_KEYS(), - Namespace.REVOKED_GROUP_MESSAGES(), - ) - private val REGULAR_PUSH_NAMESPACES = listOf(Namespace.DEFAULT()) + private fun oneTimeName(id: AccountId) = "pn-register-once-${id.hexString}" + private fun periodicName(id: AccountId) = "pn-register-periodic-${id.hexString}" - private fun uniqueWorkName(accountId: AccountId): String { - return "push-registration-${accountId.hexString}" + suspend fun scheduleImmediate(context: Context, id: AccountId, token: String) { + val data = Data.Builder() + .putString(ARG_ACCOUNT_ID, id.hexString) + .putString(ARG_TOKEN, token) + .build() + val req = OneTimeWorkRequestBuilder() + .setInputData(data) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) + .setConstraints(Constraints(NetworkType.CONNECTED)) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(oneTimeName(id), ExistingWorkPolicy.REPLACE, req).await() } - fun schedule( - context: Context, - token: String, - accountId: AccountId, - ) { - val request = OneTimeWorkRequestBuilder() - .setInputData( - Data.Builder().putString(ARG_TOKEN, token) - .putString(ARG_ACCOUNT_ID, accountId.hexString).build() + suspend fun ensurePeriodic(context: Context, id: AccountId, token: String, replace: Boolean) { + val data = Data.Builder() + .putString(ARG_ACCOUNT_ID, id.hexString) + .putString(ARG_TOKEN, token) // immutable token snapshot + .build() + val req = PeriodicWorkRequestBuilder( + 7, TimeUnit.DAYS, + 1, TimeUnit.DAYS ) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) - .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .setInputData(data) + .addTag(TAG_PERIODIC) + .addTag(ARG_ACCOUNT_ID + id.hexString) + .addTag(ARG_TOKEN + tokenFingerprint(token)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .build() - WorkManager.getInstance(context).enqueueUniqueWork( - uniqueWorkName = uniqueWorkName(accountId), - existingWorkPolicy = ExistingWorkPolicy.REPLACE, - request = request - ) + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + periodicName(id), + if (replace) ExistingPeriodicWorkPolicy.REPLACE else ExistingPeriodicWorkPolicy.KEEP, + req + ).await() } - suspend fun cancelRegistration(context: Context, accountId: AccountId) { - WorkManager.getInstance(context) - .cancelUniqueWork(uniqueWorkName(accountId)) - .await() + suspend fun cancelAll(context: Context, id: AccountId) { + val wm = WorkManager.getInstance(context) + wm.cancelUniqueWork(oneTimeName(id)).await() + wm.cancelUniqueWork(periodicName(id)).await() } } -} \ No newline at end of file +} + +private val GROUP_PUSH_NAMESPACES = listOf( + Namespace.GROUP_MESSAGES(), + Namespace.GROUP_INFO(), + Namespace.GROUP_MEMBERS(), + Namespace.GROUP_KEYS(), + Namespace.REVOKED_GROUP_MESSAGES(), +) +private val REGULAR_PUSH_NAMESPACES = listOf(Namespace.DEFAULT()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index ca95318afd..bb554ba913 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -11,6 +11,7 @@ import android.graphics.PorterDuffXfermode; import android.graphics.Rect; import android.graphics.RectF; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.SpannableStringBuilder; @@ -129,16 +130,27 @@ public void setThread(@NonNull Recipient recipient) { recycleBitmap = true; } + setLargeIcon(getCircularBitmap(largeIconBitmap)); + if(recycleBitmap) largeIconBitmap.recycle(); + } else { setContentTitle(context.getString(R.string.app_name)); - largeIconBitmap = avatarUtils.generateTextBitmap(ICON_SIZE, "", "Unknown"); - recycleBitmap = true; - } + Drawable drawable = ContextCompat.getDrawable(context, R.drawable.ic_user_filled_custom_padded); + int iconWidth = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + int iconHeight = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + + Bitmap src = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(src); + canvas.drawColor(context.getColor(R.color.classic_dark_3)); + + int padding = (int) (iconWidth * 0.08); //add some padding to the icon + drawable.setBounds(padding, padding, iconWidth - padding, iconHeight - padding); + drawable.draw(canvas); - setLargeIcon(getCircularBitmap(largeIconBitmap)); - if (recycleBitmap) { - largeIconBitmap.recycle(); + setLargeIcon(getCircularBitmap(src)); + setColor(context.getColor(R.color.classic_dark_3)); + src.recycle(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt index dbd8da93d9..841e8930b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Application import android.content.Intent import androidx.core.content.edit +import androidx.work.WorkManager import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -55,10 +56,11 @@ class ClearDataUtils @Inject constructor( application.filesDir.deleteRecursively() configFactory.clearAll() - RemoteFileDownloadWorker.cancelAll(application) - persistentLogger.deleteAllLogs() + // clean up existing work manager + WorkManager.getInstance(application).cancelAllWork() + // The token deletion is nice but not critical, so don't let it block the rest of the process runCatching { tokenFetcher.resetToken() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed3b35a9ef..8b0aa5a801 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -143,6 +143,7 @@ glide-compose = { module = "com.github.bumptech.glide:compose", version = "1.0.0 glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glideVersion" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "daggerHiltVersion" } dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "daggerHiltVersion" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutinesVersion" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" }