Skip to content

Commit

Permalink
Add feature flag for sending full installer package ID (#5075)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/608920331025315/1208315780180367/f 

### Description
Defines a feature flag for how to handle full installer package IDs. 

### Steps to test this PR

**QA-optional**
- no observable change of behaviour in this PR (branch above will cover full testing). 

If you do want to check:
- [x] Hardcode remote config download to use `https://jsonblob.com/api/1289196933024178176`
- [x] Fresh install. Launch app and wait for remote config to download.
- Check the contents of the data store (e.g., using `Device Explorer`), which will live in `com.duckduckgo.mobile.android.debug/files/datastore/com.duckduckgo.installation.impl.installer.preferences_pb`
  - [x] Note, the contents of the file won't render nicely but you should be able to see example packages `a.b.c` and `d.e.f` in there. e.g., 👇 

<img src="https://github.com/user-attachments/assets/46a60ae1-3f7c-406b-9f10-7c079b5312d8" width="50%" />
  • Loading branch information
CDRussell authored Sep 30, 2024
1 parent a043931 commit 949899d
Show file tree
Hide file tree
Showing 11 changed files with 459 additions and 0 deletions.
1 change: 1 addition & 0 deletions installation/installation-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
implementation JakeWharton.timber
implementation AndroidX.lifecycle.runtime.ktx
implementation AndroidX.lifecycle.commonJava8
implementation "androidx.datastore:datastore-preferences:_"

testImplementation Testing.junit4
testImplementation AndroidX.test.ext.junit
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.installation.impl.installer.di

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import dagger.SingleInstanceIn
import javax.inject.Qualifier

@Module
@ContributesTo(AppScope::class)
object InstallerModule {

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "com.duckduckgo.installation.impl.installer",
)

@Provides
@SingleInstanceIn(AppScope::class)
@InstallSourceFullPackageDataStore
fun provideInstallSourceFullPackageDataStore(context: Context): DataStore<Preferences> {
return context.dataStore
}

@Qualifier
annotation class InstallSourceFullPackageDataStore
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.installation.impl.installer.fullpackage

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.installation.impl.installer.di.InstallerModule.InstallSourceFullPackageDataStore
import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages
import com.duckduckgo.installation.impl.installer.fullpackage.feature.InstallSourceFullPackageListJsonParser
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext

interface InstallSourceFullPackageStore {
suspend fun updateInstallSourceFullPackages(json: String)
suspend fun getInstallSourceFullPackages(): IncludedPackages

data class IncludedPackages(val list: List<String> = emptyList()) {

fun hasWildcard(): Boolean {
return list.contains("*")
}
}
}

@ContributesBinding(AppScope::class, boundType = InstallSourceFullPackageStore::class)
@SingleInstanceIn(AppScope::class)
class InstallSourceFullPackageStoreImpl @Inject constructor(
private val dispatchers: DispatcherProvider,
private val jsonParser: InstallSourceFullPackageListJsonParser,
@InstallSourceFullPackageDataStore private val dataStore: DataStore<Preferences>,
) : InstallSourceFullPackageStore {

override suspend fun updateInstallSourceFullPackages(json: String) {
withContext(dispatchers.io()) {
val includedPackages = jsonParser.parseJson(json)
dataStore.edit {
it[packageInstallersKey] = includedPackages.list.toSet()
}
}
}

override suspend fun getInstallSourceFullPackages(): IncludedPackages {
return withContext(dispatchers.io()) {
val packageInstallers = dataStore.data.map { it[packageInstallersKey] }.firstOrNull()
return@withContext IncludedPackages(packageInstallers?.toList() ?: emptyList())
}
}

companion object {
val packageInstallersKey = stringSetPreferencesKey("package_installers")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.installation.impl.installer.fullpackage.feature

import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.FeatureSettings
import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

@ContributesRemoteFeature(
scope = AppScope::class,
boundType = InstallSourceFullPackageFeature::class,
featureName = "sendFullPackageInstallSource",
settingsStore = InstallSourceFullPackageFeatureSettingsStore::class,
)
/**
* This is the class that represents the feature flag for sending full installer package ID.
* This can be used to specify which app-installer package IDs we'd match on to send a pixel.
* A wildcard "*" can be used to match all package IDs.
*/
interface InstallSourceFullPackageFeature {
/**
* @return `true` when the remote config has the global "sendFullPackageInstallSource" feature flag enabled
*
* If the remote feature is not present defaults to `false`
*/

@Toggle.DefaultValue(false)
fun self(): Toggle
}

@ContributesBinding(AppScope::class)
@RemoteFeatureStoreNamed(InstallSourceFullPackageFeature::class)
class InstallSourceFullPackageFeatureSettingsStore @Inject constructor(
private val dataStore: InstallSourceFullPackageStore,
private val dispatchers: DispatcherProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : FeatureSettings.Store {

override fun store(jsonString: String) {
kotlin.runCatching {
appCoroutineScope.launch(dispatchers.io()) {
dataStore.updateInstallSourceFullPackages(jsonString)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.installation.impl.installer.fullpackage.feature

import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import javax.inject.Inject

interface InstallSourceFullPackageListJsonParser {
suspend fun parseJson(json: String?): IncludedPackages
}

@ContributesBinding(AppScope::class)
class InstallSourceFullPackageListJsonParserImpl @Inject constructor() : InstallSourceFullPackageListJsonParser {

private val jsonAdapter by lazy { buildJsonAdapter() }

private fun buildJsonAdapter(): JsonAdapter<SettingsJson> {
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
return moshi.adapter(SettingsJson::class.java)
}

override suspend fun parseJson(json: String?): IncludedPackages {
if (json == null) return IncludedPackages()

return kotlin.runCatching {
val parsed = jsonAdapter.fromJson(json)
return parsed?.asIncludedPackages() ?: IncludedPackages()
}.getOrDefault(IncludedPackages())
}

private fun SettingsJson.asIncludedPackages(): IncludedPackages {
return IncludedPackages(includedPackages.map { it })
}

private data class SettingsJson(
val includedPackages: List<String>,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.duckduckgo.installation.impl.installer.fullpackage

import com.duckduckgo.installation.impl.installer.fullpackage.InstallSourceFullPackageStore.IncludedPackages
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class IncludedPackagesTest {

@Test
fun whenEmptyThenDoesNotContainWildcard() {
val list = IncludedPackages(emptyList())
assertFalse(list.hasWildcard())
}

@Test
fun whenHasEntriesButNoWildcardThenDoesNotContainWildcard() {
val list = IncludedPackages(
listOf(
"not a wildcard",
"also not a wildcard",
),
)
assertFalse(list.hasWildcard())
}

@Test
fun whenHasMultipleEntriesAndOneIsWildcardEntryThenDoesContainWildcard() {
val list = IncludedPackages(
listOf(
"not a wildcard",
"*",
"also not a wildcard",
),
)
assertTrue(list.hasWildcard())
}

@Test
fun whenHasSingleWildcardEntryThenDoesContainWildcard() {
val list = IncludedPackages(listOf("*"))
assertTrue(list.hasWildcard())
}
}
Loading

0 comments on commit 949899d

Please sign in to comment.