BIT-2411: Add logic for managed device pre-configured URLs (#3358)

This commit is contained in:
David Perez 2024-06-25 16:18:05 -05:00 committed by GitHub
parent 63e7465433
commit d9b1809e58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 411 additions and 0 deletions

View file

@ -150,6 +150,11 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
</application>
<queries>

View file

@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@ -28,4 +29,7 @@ class BitwardenApplication : Application() {
@Inject
lateinit var organizationEventManager: OrganizationEventManager
@Inject
lateinit var restrictionManager: RestrictionManager
}

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager.di
import android.app.Application
import android.content.Context
import androidx.core.content.getSystemService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
@ -41,6 +42,8 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
@ -211,4 +214,19 @@ object PlatformManagerModule {
context = context,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideRestrictionManager(
@ApplicationContext context: Context,
appForegroundManager: AppForegroundManager,
dispatcherManager: DispatcherManager,
environmentRepository: EnvironmentRepository,
): RestrictionManager = RestrictionManagerImpl(
appForegroundManager = appForegroundManager,
dispatcherManager = dispatcherManager,
context = context,
environmentRepository = environmentRepository,
restrictionsManager = requireNotNull(context.getSystemService()),
)
}

View file

@ -0,0 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.restriction
/**
* A manager for handling restrictions.
*/
interface RestrictionManager

View file

@ -0,0 +1,123 @@
package com.x8bit.bitwarden.data.platform.manager.restriction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.RestrictionsManager
import android.os.Bundle
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* The default implementation of the [RestrictionManager].
*/
class RestrictionManagerImpl(
appForegroundManager: AppForegroundManager,
dispatcherManager: DispatcherManager,
private val context: Context,
private val environmentRepository: EnvironmentRepository,
private val restrictionsManager: RestrictionsManager,
) : RestrictionManager {
private val mainScope = CoroutineScope(dispatcherManager.main)
private val intentFilter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
private val restrictionsChangedReceiver = RestrictionsChangedReceiver()
private var isReceiverRegistered = false
init {
appForegroundManager
.appForegroundStateFlow
.onEach {
when (it) {
AppForegroundState.BACKGROUNDED -> handleBackground()
AppForegroundState.FOREGROUNDED -> handleForeground()
}
}
.launchIn(mainScope)
}
private fun handleBackground() {
if (isReceiverRegistered) {
context.unregisterReceiver(restrictionsChangedReceiver)
}
isReceiverRegistered = false
}
private fun handleForeground() {
context.registerReceiver(restrictionsChangedReceiver, intentFilter)
isReceiverRegistered = true
updatePreconfiguredRestrictionSettings()
}
private fun updatePreconfiguredRestrictionSettings() {
restrictionsManager
.applicationRestrictions
?.takeUnless { it.isEmpty }
?.let { setPreconfiguredSettings(it) }
}
private fun setPreconfiguredSettings(bundle: Bundle) {
bundle
.getString(BASE_ENVIRONMENT_URL_RESTRICTION_KEY)
?.let { url -> setPreconfiguredUrl(baseEnvironmentUrl = url) }
}
private fun setPreconfiguredUrl(baseEnvironmentUrl: String) {
environmentRepository.environment = when (val current = environmentRepository.environment) {
Environment.Us -> {
when (baseEnvironmentUrl) {
// If the base matches the predefined US environment, leave it alone
Environment.Us.environmentUrlData.base -> current
// If the base does not match the predefined US environment, create a
// self-hosted environment with the new base
else -> current.toSelfHosted(base = baseEnvironmentUrl)
}
}
Environment.Eu -> {
when (baseEnvironmentUrl) {
// If the base matches the predefined EU environment, leave it alone
Environment.Eu.environmentUrlData.base -> current
// If the base does not match the predefined EU environment, create a
// self-hosted environment with the new base
else -> current.toSelfHosted(base = baseEnvironmentUrl)
}
}
is Environment.SelfHosted -> current.toSelfHosted(base = baseEnvironmentUrl)
}
}
/**
* A [BroadcastReceiver] used to listen for [Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED]
* updates.
*
* Note: The `Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED` will only be received if the
* `BroadcastReceiver` is dynamically registered, so this cannot be registered in the manifest.
*/
private inner class RestrictionsChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) {
updatePreconfiguredRestrictionSettings()
}
}
}
}
private const val BASE_ENVIRONMENT_URL_RESTRICTION_KEY: String = "baseEnvironmentUrl"
/**
* Helper method for creating a new [Environment.SelfHosted] with a new base.
*/
private fun Environment.toSelfHosted(
base: String,
): Environment.SelfHosted =
Environment.SelfHosted(
environmentUrlData = environmentUrlData.copy(base = base),
)

View file

@ -18,4 +18,5 @@
<string name="continue_to_complete_web_authn_verfication" translatable="false">Continue to complete WebAuthn verification.</string>
<string name="launch_web_authn" translatable="false">Launch WebAuthn</string>
<string name="there_was_an_error_starting_web_authn_two_factor_authentication" translatable="false">There was an error starting WebAuthn two factor authentication</string>
<string name="self_hosted_server_url" translatable="false">Self-hosted server URL</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
<restriction
android:defaultValue="@null"
android:description="@string/server_url"
android:key="baseEnvironmentUrl"
android:restrictionType="string"
android:title="@string/self_hosted_server_url" />
</restrictions>

View file

@ -0,0 +1,245 @@
package com.x8bit.bitwarden.data.platform.manager.restriction
import android.annotation.SuppressLint
import android.content.Context
import android.content.RestrictionsManager
import android.os.Bundle
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.manager.util.FakeAppForegroundManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@SuppressLint("UnspecifiedRegisterReceiverFlag")
class RestrictionManagerTest {
private val context = mockk<Context> {
every { registerReceiver(any(), any()) } returns null
every { unregisterReceiver(any()) } just runs
}
private val fakeAppForegroundManager = FakeAppForegroundManager()
private val fakeDispatcherManager = FakeDispatcherManager().apply {
setMain(unconfined)
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
private val restrictionsManager = mockk<RestrictionsManager>()
private val restrictionManager: RestrictionManager = RestrictionManagerImpl(
appForegroundManager = fakeAppForegroundManager,
dispatcherManager = fakeDispatcherManager,
context = context,
environmentRepository = fakeEnvironmentRepository,
restrictionsManager = restrictionsManager,
)
@AfterEach
fun tearDown() {
fakeDispatcherManager.resetMain()
}
@Test
fun `on app foreground with a null bundle should register receiver and do nothing else`() {
every { restrictionsManager.applicationRestrictions } returns null
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(Environment.Us, fakeEnvironmentRepository.environment)
}
@Test
fun `on app foreground with an empty bundle should register receiver and do nothing else`() {
every { restrictionsManager.applicationRestrictions } returns mockBundle()
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(Environment.Us, fakeEnvironmentRepository.environment)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with unknown bundle data should register receiver and do nothing else`() {
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("key" to "unknown")
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(Environment.Us, fakeEnvironmentRepository.environment)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with baseEnvironmentUrl bundle data matching the current US environment should register receiver and set the environment to US`() {
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("baseEnvironmentUrl" to "https://vault.bitwarden.com")
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(Environment.Us, fakeEnvironmentRepository.environment)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with baseEnvironmentUrl bundle data not matching the current US environment should register receiver and set the environment to self-hosted`() {
val baseUrl = "https://other.bitwarden.com"
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("baseEnvironmentUrl" to baseUrl)
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(
Environment.SelfHosted(
environmentUrlData = Environment.Us.environmentUrlData.copy(base = baseUrl),
),
fakeEnvironmentRepository.environment,
)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with baseEnvironmentUrl bundle data matching the current EU environment should register receiver and set the environment to EU`() {
fakeEnvironmentRepository.environment = Environment.Eu
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("baseEnvironmentUrl" to "https://vault.bitwarden.eu")
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(Environment.Eu, fakeEnvironmentRepository.environment)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with baseEnvironmentUrl bundle data not matching the current EU environment should register receiver and set the environment to self-hosted`() {
val baseUrl = "https://other.bitwarden.eu"
fakeEnvironmentRepository.environment = Environment.Eu
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("baseEnvironmentUrl" to baseUrl)
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(
Environment.SelfHosted(
environmentUrlData = Environment.Eu.environmentUrlData.copy(base = baseUrl),
),
fakeEnvironmentRepository.environment,
)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with baseEnvironmentUrl bundle data matching the current self-hosted environment should register receiver and set the environment to self-hosted`() {
val baseUrl = "https://vault.qa.bitwarden.pw"
val environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(base = baseUrl),
)
fakeEnvironmentRepository.environment = environment
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("baseEnvironmentUrl" to baseUrl)
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(environment, fakeEnvironmentRepository.environment)
}
@Suppress("MaxLineLength")
@Test
fun `on app foreground with baseEnvironmentUrl bundle data not matching the current self-hosted environment should register receiver and set the environment to self-hosted`() {
val baseUrl = "https://other.qa.bitwarden.pw"
val environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.qa.bitwarden.pw"),
)
fakeEnvironmentRepository.environment = environment
every {
restrictionsManager.applicationRestrictions
} returns mockBundle("baseEnvironmentUrl" to baseUrl)
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
verify(exactly = 1) {
context.registerReceiver(any(), any())
}
assertEquals(
Environment.SelfHosted(
environmentUrlData = environment.environmentUrlData.copy(base = baseUrl),
),
fakeEnvironmentRepository.environment,
)
}
@Test
fun `on app background when not foregrounded should do nothing`() {
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
verify(exactly = 0) {
context.unregisterReceiver(any())
restrictionsManager.applicationRestrictions
}
}
@Test
fun `on app background after foreground should unregister receiver`() {
every { restrictionsManager.applicationRestrictions } returns null
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
clearMocks(context, restrictionsManager, answers = false)
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
verify(exactly = 1) {
context.unregisterReceiver(any())
}
verify(exactly = 0) {
restrictionsManager.applicationRestrictions
}
}
}
/**
* Helper method for constructing a simple mock bundle.
*/
private fun mockBundle(vararg pairs: Pair<String, String>): Bundle =
mockk<Bundle> {
every { isEmpty } returns pairs.isEmpty()
every { getString(any()) } returns null
pairs.forEach { (key, value) ->
every { getString(key) } returns value
}
}