diff --git a/app/build.gradle b/app/build.gradle index f4cdeccc9e..54b726711b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,6 @@ plugins { id "com.jetbrains.python.envs" version "0.0.26" + id "com.google.protobuf" version "0.8.17" } apply plugin: 'com.android.application' @@ -525,6 +526,8 @@ dependencies { implementation Deps.androidx_core_ktx implementation Deps.androidx_transition implementation Deps.androidx_work_ktx + implementation Deps.androidx_datastore + implementation Deps.protobuf_javalite implementation Deps.google_material implementation Deps.adjust @@ -589,6 +592,25 @@ dependencies { lintChecks project(":mozilla-lint-rules") } +protobuf { + protoc { + artifact = Deps.protobuf_compiler + } + + // Generates the java Protobuf-lite code for the Protobufs in this project. See + // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation + // for more information. + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option 'lite' + } + } + } + } +} + if (project.hasProperty("coverage")) { tasks.withType(Test).configureEach { jacoco.includeNoLocationClasses = true diff --git a/app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt b/app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt new file mode 100644 index 0000000000..df7d85d188 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore + +/** + * Application / process unique [DataStore] for IO operations related to Pocket recommended stories selected categories. + */ +internal val Context.pocketStoriesSelectedCategoriesDataStore: DataStore by dataStore( + fileName = "pocket_recommendations_selected_categories.pb", + serializer = SelectedPocketStoriesCategorySerializer +) diff --git a/app/src/main/java/org/mozilla/fenix/datastore/SelectedPocketStoriesCategorySerializer.kt b/app/src/main/java/org/mozilla/fenix/datastore/SelectedPocketStoriesCategorySerializer.kt new file mode 100644 index 0000000000..53acafe369 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/datastore/SelectedPocketStoriesCategorySerializer.kt @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.datastore + +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream + +/** + * Serializer for [SelectedPocketStoriesCategories] defined in selected_pocket_stories_categories.proto. + */ +@Suppress("BlockingMethodInNonBlockingContext") +object SelectedPocketStoriesCategorySerializer : Serializer { + override val defaultValue: SelectedPocketStoriesCategories = SelectedPocketStoriesCategories.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): SelectedPocketStoriesCategories { + return SelectedPocketStoriesCategories.parseFrom(input) + } + + override suspend fun writeTo(t: SelectedPocketStoriesCategories, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 6f89c3ea22..c2998bc21e 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -93,6 +93,7 @@ import org.mozilla.fenix.components.tips.providers.MasterPasswordTipProvider import org.mozilla.fenix.components.toolbar.FenixTabCounterMenu import org.mozilla.fenix.components.toolbar.ToolbarPosition import org.mozilla.fenix.databinding.FragmentHomeBinding +import org.mozilla.fenix.datastore.pocketStoriesSelectedCategoriesDataStore import org.mozilla.fenix.ext.asRecentTabs import org.mozilla.fenix.experiments.FeatureId import org.mozilla.fenix.ext.components @@ -248,7 +249,9 @@ class HomeFragment : Fragment() { ), listOf( PocketUpdatesMiddleware( - lifecycleScope, requireComponents.core.pocketStoriesService + lifecycleScope, + requireComponents.core.pocketStoriesService, + requireContext().pocketStoriesSelectedCategoriesDataStore ) ) ) diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt index 9415324565..dd18e0d121 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt @@ -103,6 +103,10 @@ sealed class HomeFragmentAction : Action { data class PocketStoriesChange(val pocketStories: List) : HomeFragmentAction() data class PocketStoriesCategoriesChange(val storiesCategories: List) : HomeFragmentAction() + data class PocketStoriesCategoriesSelectionsChange( + val storiesCategories: List, + val categoriesSelected: List + ) : HomeFragmentAction() object RemoveCollectionsPlaceholder : HomeFragmentAction() object RemoveSetDefaultBrowserCard : HomeFragmentAction() } @@ -172,8 +176,18 @@ private fun homeFragmentStateReducer( ) } is HomeFragmentAction.PocketStoriesCategoriesChange -> { - // Whenever categories change stories to be displayed needs to also be changed. val updatedCategoriesState = state.copy(pocketStoriesCategories = action.storiesCategories) + // Whenever categories change stories to be displayed needs to also be changed. + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.PocketStoriesCategoriesSelectionsChange -> { + val updatedCategoriesState = state.copy( + pocketStoriesCategories = action.storiesCategories, + pocketStoriesCategoriesSelections = action.categoriesSelected + ) + // Whenever categories change stories to be displayed needs to also be changed. return updatedCategoriesState.copy( pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) ) diff --git a/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt b/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt index 12b700ab2a..48534aa734 100644 --- a/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt @@ -4,25 +4,57 @@ package org.mozilla.fenix.home +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import mozilla.components.lib.state.Action import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Store import mozilla.components.service.pocket.PocketStoriesService +import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories +import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories.SelectedPocketStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory /** * [HomeFragmentStore] middleware reacting in response to Pocket related [Action]s. + * + * @param coroutineScope [CoroutineScope] used for long running operations like disk IO. + * @param pocketStoriesService [PocketStoriesService] used for updating details about the Pocket recommended stories. + * @param selectedPocketCategoriesDataStore [DataStore] used for reading or persisting details about the + * currently selected Pocket recommended stories categories. */ class PocketUpdatesMiddleware( private val coroutineScope: CoroutineScope, - private val pocketStoriesService: PocketStoriesService + private val pocketStoriesService: PocketStoriesService, + private val selectedPocketCategoriesDataStore: DataStore ) : Middleware { override fun invoke( context: MiddlewareContext, next: (HomeFragmentAction) -> Unit, action: HomeFragmentAction ) { + // Pre process actions + when (action) { + is HomeFragmentAction.PocketStoriesCategoriesChange -> { + // Intercept the original action which would only update categories and + // dispatch a new action which also updates which categories are selected by the user + // from previous locally persisted data. + restoreSelectedCategories( + coroutineScope = coroutineScope, + currentCategories = action.storiesCategories, + store = context.store, + selectedPocketCategoriesDataStore = selectedPocketCategoriesDataStore + ) + } + else -> { + // no-op + } + } + next(action) // Post process actions @@ -36,9 +68,80 @@ class PocketUpdatesMiddleware( ) } } + is HomeFragmentAction.SelectPocketStoriesCategory, + is HomeFragmentAction.DeselectPocketStoriesCategory -> { + persistSelectedCategories( + coroutineScope = coroutineScope, + currentCategoriesSelections = context.state.pocketStoriesCategoriesSelections, + selectedPocketCategoriesDataStore = selectedPocketCategoriesDataStore + ) + } else -> { // no-op } } } } + +/** + * Persist [currentCategoriesSelections] for making this details available in between app restarts. + * + * @param coroutineScope [CoroutineScope] used for reading the locally persisted data. + * @param currentCategoriesSelections Currently selected Pocket recommended stories categories. + * @param selectedPocketCategoriesDataStore - DataStore used for persisting [currentCategoriesSelections]. + */ +@VisibleForTesting +internal fun persistSelectedCategories( + coroutineScope: CoroutineScope, + currentCategoriesSelections: List, + selectedPocketCategoriesDataStore: DataStore +) { + val selectedCategories = currentCategoriesSelections + .map { + SelectedPocketStoriesCategory.newBuilder().apply { + name = it.name + selectionTimestamp = it.selectionTimestamp + }.build() + } + + // Irrespective of the current selections or their number overwrite everything we had. + coroutineScope.launch { + selectedPocketCategoriesDataStore.updateData { data -> + data.newBuilderForType().addAllValues(selectedCategories).build() + } + } +} + +/** + * Combines [currentCategories] with the locally persisted data about previously selected categories + * and emits a new [HomeFragmentAction.PocketStoriesCategoriesSelectionsChange] to update these in store. + * + * @param coroutineScope [CoroutineScope] used for reading the locally persisted data. + * @param currentCategories Stories categories currently available + * @param store [Store] that will be updated. + * @param selectedPocketCategoriesDataStore [DataStore] containing details about the previously selected + * stories categories. + */ +@VisibleForTesting +internal fun restoreSelectedCategories( + coroutineScope: CoroutineScope, + currentCategories: List, + store: Store, + selectedPocketCategoriesDataStore: DataStore +) { + coroutineScope.launch { + selectedPocketCategoriesDataStore.data.collect { persistedSelectedCategories -> + store.dispatch( + HomeFragmentAction.PocketStoriesCategoriesSelectionsChange( + currentCategories, + persistedSelectedCategories.valuesList.map { + PocketRecommendedStoriesSelectedCategory( + name = it.name, + selectionTimestamp = it.selectionTimestamp + ) + } + ) + ) + } + } +} diff --git a/app/src/main/proto/selected_pocket_stories_categories.proto b/app/src/main/proto/selected_pocket_stories_categories.proto new file mode 100644 index 0000000000..b65bb34625 --- /dev/null +++ b/app/src/main/proto/selected_pocket_stories_categories.proto @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +syntax = "proto3"; + +package proto; + +option java_package = "org.mozilla.fenix.datastore"; +option java_multiple_files = true; + +// List of currently selected Pocket recommended stories categories. +message SelectedPocketStoriesCategories { + + // Details about a selected Pocket recommended stories category. + // See [org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory] + message SelectedPocketStoriesCategory { + // Name of this category. + string name = 1; + // Timestamp for when this category was selected. + int64 selectionTimestamp = 2; + } + + // Currently selected Pocket stories categories. + repeated SelectedPocketStoriesCategory values = 1; +} diff --git a/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt b/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt index bfad8e3443..f5bb4db992 100644 --- a/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt @@ -195,8 +195,9 @@ class HomeFragmentStoreTest { val filteredStories = listOf(mockk()) homeFragmentStore = HomeFragmentStore( HomeFragmentState( - pocketStoriesCategories = listOf( - otherStoriesCategory, anotherStoriesCategory + pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory), + pocketStoriesCategoriesSelections = listOf( + PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name), ) ) ) @@ -204,13 +205,13 @@ class HomeFragmentStoreTest { mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") { every { any().getFilteredStories(any()) } returns filteredStories - homeFragmentStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory("other")).join() + homeFragmentStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory("another")).join() verify { any().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) } } val selectedCategories = homeFragmentStore.state.pocketStoriesCategoriesSelections - assertEquals(1, selectedCategories.size) + assertEquals(2, selectedCategories.size) assertTrue(otherStoriesCategory.name === selectedCategories[0].name) assertSame(filteredStories, homeFragmentStore.state.pocketStories) } @@ -293,4 +294,34 @@ class HomeFragmentStoreTest { assertSame(secondFilteredStories, homeFragmentStore.state.pocketStories) } } + + @Test + fun `Test updating the list of selected Pocket recommendations categories`() = runBlocking { + val otherStoriesCategory = PocketRecommendedStoriesCategory("other") + val anotherStoriesCategory = PocketRecommendedStoriesCategory("another") + val selectedCategory = PocketRecommendedStoriesSelectedCategory("selected") + homeFragmentStore = HomeFragmentStore(HomeFragmentState()) + + mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") { + val firstFilteredStories = listOf(mockk()) + every { any().getFilteredStories(any()) } returns firstFilteredStories + + homeFragmentStore.dispatch( + HomeFragmentAction.PocketStoriesCategoriesSelectionsChange( + storiesCategories = listOf(otherStoriesCategory, anotherStoriesCategory), + categoriesSelected = listOf(selectedCategory) + ) + ).join() + verify { any().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) } + assertTrue( + homeFragmentStore.state.pocketStoriesCategories.containsAll( + listOf(otherStoriesCategory, anotherStoriesCategory) + ) + ) + assertTrue( + homeFragmentStore.state.pocketStoriesCategoriesSelections.containsAll(listOf(selectedCategory)) + ) + assertSame(firstFilteredStories, homeFragmentStore.state.pocketStories) + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt b/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt index 591757ff79..97dfa0dd15 100644 --- a/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/PocketUpdatesMiddlewareTest.kt @@ -4,17 +4,26 @@ package org.mozilla.fenix.home +import androidx.datastore.core.DataStore import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScope import mozilla.components.service.pocket.PocketRecommendedStory import mozilla.components.service.pocket.PocketStoriesService import mozilla.components.support.test.ext.joinBlocking import org.junit.Test +import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories +import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories.SelectedPocketStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory +@ExperimentalCoroutinesApi class PocketUpdatesMiddlewareTest { - @ExperimentalCoroutinesApi @Test fun `WHEN PocketStoriesShown is dispatched THEN update PocketStoriesService`() { val story1 = PocketRecommendedStory("title", "url1", "imageUrl", "publisher", "category", 0, timesShown = 0) @@ -22,7 +31,7 @@ class PocketUpdatesMiddlewareTest { val story3 = story1.copy("title3", "url3") val coroutineScope = TestCoroutineScope() val pocketService: PocketStoriesService = mockk(relaxed = true) - val pocketMiddleware = PocketUpdatesMiddleware(coroutineScope, pocketService) + val pocketMiddleware = PocketUpdatesMiddleware(coroutineScope, pocketService, mockk()) val homeStore = HomeFragmentStore( HomeFragmentState( pocketStories = listOf(story1, story2, story3) @@ -34,4 +43,129 @@ class PocketUpdatesMiddlewareTest { coVerify { pocketService.updateStoriesTimesShown(listOf(story2.copy(timesShown = 1))) } } + + @Test + fun `WHEN PocketStoriesCategoriesChange is dispatched THEN intercept and dispatch PocketStoriesCategoriesSelectionsChange`() { + val persistedSelectedCategory: SelectedPocketStoriesCategory = mockk { + every { name } returns "testCategory" + every { selectionTimestamp } returns 123 + } + val persistedSelectedCategories: SelectedPocketStoriesCategories = mockk { + every { valuesList } returns mutableListOf(persistedSelectedCategory) + } + val dataStore: DataStore = mockk { + every { data } returns flowOf(persistedSelectedCategories) + } + val currentCategories = listOf(mockk()) + val pocketMiddleware = PocketUpdatesMiddleware(TestCoroutineScope(), mockk(), dataStore) + val homeStore = spyk( + HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = currentCategories + ), + listOf(pocketMiddleware) + ) + ) + + homeStore.dispatch(HomeFragmentAction.PocketStoriesCategoriesChange(currentCategories)).joinBlocking() + + verify { + homeStore.dispatch( + HomeFragmentAction.PocketStoriesCategoriesSelectionsChange( + storiesCategories = currentCategories, + categoriesSelected = listOf( + PocketRecommendedStoriesSelectedCategory("testCategory", 123) + ) + ) + ) + } + } + + @Test + fun `WHEN SelectPocketStoriesCategory is dispatched THEN persist details in DataStore`() { + val categ1 = PocketRecommendedStoriesCategory("categ1") + val categ2 = PocketRecommendedStoriesCategory("categ2") + val dataStore: DataStore = mockk(relaxed = true) + val pocketMiddleware = PocketUpdatesMiddleware(TestCoroutineScope(), mockk(), dataStore) + val homeStore = spyk( + HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = listOf(categ1, categ2) + ), + listOf(pocketMiddleware) + ) + ) + + homeStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(categ2.name)).joinBlocking() + + // Seems like the most we can test is that an update was made. + coVerify { dataStore.updateData(any()) } + } + + @Test + fun `WHEN DeselectPocketStoriesCategory is dispatched THEN persist details in DataStore`() { + val categ1 = PocketRecommendedStoriesCategory("categ1") + val categ2 = PocketRecommendedStoriesCategory("categ2") + val dataStore: DataStore = mockk(relaxed = true) + val pocketMiddleware = PocketUpdatesMiddleware(TestCoroutineScope(), mockk(), dataStore) + val homeStore = spyk( + HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = listOf(categ1, categ2) + ), + listOf(pocketMiddleware) + ) + ) + + homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(categ2.name)).joinBlocking() + + // Seems like the most we can test is that an update was made. + coVerify { dataStore.updateData(any()) } + } + + @Test + fun `WHEN persistCategories is called THEN update dataStore`() { + val dataStore: DataStore = mockk(relaxed = true) + + persistSelectedCategories(TestCoroutineScope(), listOf(mockk(relaxed = true)), dataStore) + + // Seems like the most we can test is that an update was made. + coVerify { dataStore.updateData(any()) } + } + + @Test + fun `WHEN restoreSelectedCategories is called THEN dispatch PocketStoriesCategoriesSelectionsChange with data read from the persistence layer`() { + val persistedSelectedCategory: SelectedPocketStoriesCategory = mockk { + every { name } returns "testCategory" + every { selectionTimestamp } returns 123 + } + val persistedSelectedCategories: SelectedPocketStoriesCategories = mockk { + every { valuesList } returns mutableListOf(persistedSelectedCategory) + } + val dataStore: DataStore = mockk { + every { data } returns flowOf(persistedSelectedCategories) + } + val currentCategories = listOf(mockk()) + val homeStore = spyk( + HomeFragmentStore(HomeFragmentState()) + ) + + restoreSelectedCategories( + coroutineScope = TestCoroutineScope(), + currentCategories = currentCategories, + store = homeStore, + selectedPocketCategoriesDataStore = dataStore + ) + + coVerify { + homeStore.dispatch( + HomeFragmentAction.PocketStoriesCategoriesSelectionsChange( + storiesCategories = currentCategories, + categoriesSelected = listOf( + PocketRecommendedStoriesSelectedCategory("testCategory", 123) + ) + ) + ) + } + } } diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 8cf74fc072..42f259f37c 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -35,6 +35,7 @@ object Versions { const val androidx_paging = "2.1.2" const val androidx_transition = "1.4.0" const val androidx_work = "2.5.0" + const val androidx_datastore = "1.0.0" const val google_material = "1.2.1" const val mozilla_android_components = AndroidComponents.VERSION @@ -52,6 +53,8 @@ object Versions { const val google_ads_id_version = "16.0.0" const val google_play_store_version = "1.8.0" + + const val protobuf = "3.11.4" // keep in sync with the version used in AS. } @Suppress("unused") @@ -199,8 +202,12 @@ object Deps { const val androidx_transition = "androidx.transition:transition:${Versions.androidx_transition}" const val androidx_work_ktx = "androidx.work:work-runtime-ktx:${Versions.androidx_work}" const val androidx_work_testing = "androidx.work:work-testing:${Versions.androidx_work}" + const val androidx_datastore = "androidx.datastore:datastore:${Versions.androidx_datastore}" const val google_material = "com.google.android.material:material:${Versions.google_material}" + const val protobuf_javalite = "com.google.protobuf:protobuf-javalite:${Versions.protobuf}" + const val protobuf_compiler = "com.google.protobuf:protoc:${Versions.protobuf}" + const val adjust = "com.adjust.sdk:adjust-android:${Versions.adjust}" const val installreferrer = "com.android.installreferrer:installreferrer:${Versions.installreferrer}"