Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ dependencies {
testImplementation "io.mockk:mockk:$mockk_version"
testImplementation "io.mockk:mockk-android:$mockk_version"
testImplementation "androidx.arch.core:core-testing:$android_arch_version"
// The Android stub of org.json returns defaults; the token authenticator parses error bodies with it.
testImplementation "org.json:json:20240303"
}

private def setupBranchConfigFields(buildType) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package org.openedx.app.room

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.JsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.openedx.core.data.model.room.CourseCalendarStateEntity
import org.openedx.core.data.model.room.OfflineXBlockProgress
import org.openedx.core.data.model.room.XBlockProgressData
import org.openedx.core.instance.Instance
import org.openedx.core.instance.InstanceProvider
import org.openedx.core.instance.StorageNamespace
import org.openedx.core.instance.StorageNamespaceProvider
import org.openedx.core.module.db.DownloadModelEntity
import java.util.UUID

@RunWith(AndroidJUnit4::class)
class InstanceDatabaseProviderTest {

private val context = ApplicationProvider.getApplicationContext<Context>()
private val runId = UUID.randomUUID().toString().take(8)

private fun instance(key: String) = requireNotNull(
Instance.fromJson(
JsonObject().apply {
addProperty("KEY", key)
addProperty("NAME", key)
addProperty("OAUTH_CLIENT_ID", "$key-client")
addProperty("API_HOST_URL", "https://$key.example.com")
}
)
)

private val alpha = instance("alpha-$runId")
private val beta = instance("beta-$runId")

private class Selection(initial: Instance?) : InstanceProvider, StorageNamespaceProvider {
val flow = MutableStateFlow(initial)
override val currentInstance: Instance? get() = flow.value
override val currentInstanceFlow: StateFlow<Instance?> = flow
override val hasMultipleInstances = true
override val current: StorageNamespace get() = namespaceFor(flow.value)
override fun namespaceFor(instance: Instance?) = StorageNamespace(instance?.key)
override fun namespaceFor(instanceKey: String?) = StorageNamespace(instanceKey)
}

private val selection = Selection(alpha)
private val provider = InstanceDatabaseProvider(context, selection, selection)

@After
fun tearDown() {
listOf(alpha, beta).forEach {
val name = InstanceDatabaseProvider.databaseName(StorageNamespace(it.key))
provider.database(StorageNamespace(it.key)).close()
context.deleteDatabase(name)
}
}

private fun download(id: String, title: String) = DownloadModelEntity(
id = id,
title = title,
courseId = "course-v1:edX+DemoX+Demo_Course",
size = 1L,
path = "/tmp/$id",
url = "https://cdn.example.com/$id.mp4",
type = "VIDEO",
downloadedState = "WAITING",
lastModified = null,
)

@Test
fun legacyNamespaceKeepsTheOriginalDatabaseFile() {
assertEquals("OpenEdX_db", InstanceDatabaseProvider.databaseName(StorageNamespace.LEGACY))
val name = InstanceDatabaseProvider.databaseName(StorageNamespace(alpha.key))
assertTrue(name, name.startsWith("OpenEdX_db_alpha-$runId"))
}

@Test
fun sameIdsOnTwoSitesNeverCollide() = runBlocking {
val downloads = InstanceScopedDownloadDao(provider)

downloads.insertDownloadModel(listOf(download("block-1", "Alpha video")))
downloads.insertOfflineXBlockProgress(
OfflineXBlockProgress("block-1", "course", XBlockProgressData("https://alpha/handler", "POST", "{}"))
)
selection.flow.value = beta
downloads.insertDownloadModel(listOf(download("block-1", "Beta video")))

assertEquals(listOf("Beta video"), downloads.readAllData().map { it.title })
assertNull(downloads.getOfflineXBlockProgress("block-1"))

downloads.clearOfflineProgress()
downloads.removeDownloadModel("block-1")
selection.flow.value = alpha

assertEquals(listOf("Alpha video"), downloads.readAllData().map { it.title })
assertEquals("block-1", downloads.getOfflineXBlockProgress("block-1")?.blockId)
}

@Test
fun flowsFollowTheSelectedSite() = runBlocking {
val downloads = InstanceScopedDownloadDao(provider)
downloads.insertDownloadModel(listOf(download("a", "Alpha")))
selection.flow.value = beta
downloads.insertDownloadModel(listOf(download("b", "Beta")))

assertEquals(listOf("Beta"), downloads.getAllDataFlow().first().map { it.title })
selection.flow.value = alpha
assertEquals(listOf("Alpha"), downloads.getAllDataFlow().first().map { it.title })
assertEquals(listOf("Alpha"), downloads.readAllDataByIds(listOf("a", "b")).first().map { it.title })
}

@Test
fun pinnedAccessReachesASiteThatIsNotSelected() = runBlocking {
val downloads = InstanceScopedDownloadDao(provider)
downloads.insertDownloadModel(listOf(download("a", "Alpha")))
selection.flow.value = beta

val alphaRows = provider.downloadDao(StorageNamespace(alpha.key)).readAllData()

assertEquals(listOf("Alpha"), alphaRows.map { it.title })
assertTrue(downloads.readAllData().isEmpty())
}

@Test
fun transactionalCleanupOnOneSiteLeavesTheOtherIntact() = runBlocking {
val calendar = InstanceScopedCalendarDao(provider)
calendar.insertCourseCalendarStateEntity(CourseCalendarStateEntity("course", 7, true))
selection.flow.value = beta
calendar.insertCourseCalendarStateEntity(CourseCalendarStateEntity("course", 9, false))

calendar.clearCachedData()
assertTrue(calendar.readAllCourseCalendarState().isEmpty())

selection.flow.value = alpha
assertEquals(7, calendar.readCourseCalendarStateById("course")?.checksum)
}
}
24 changes: 12 additions & 12 deletions app/src/main/java/org/openedx/app/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,20 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import androidx.room.RoomDatabase
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.openedx.app.deeplink.DeepLink
import org.openedx.app.deeplink.DeepLinkRouter
import org.openedx.app.system.push.PushRegistrations
import org.openedx.app.system.push.RefreshFirebaseTokenWorker
import org.openedx.app.system.push.SyncFirebaseTokenWorker
import org.openedx.core.config.Config
import org.openedx.core.data.model.User
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.instance.InstanceSessionManager
import org.openedx.core.instance.InstanceStore
import org.openedx.core.system.notifier.DownloadFailed
import org.openedx.core.system.notifier.DownloadNotifier
import org.openedx.core.system.notifier.app.AppNotifier
Expand All @@ -36,9 +36,10 @@ import org.openedx.foundation.utils.FileUtil
class AppViewModel(
private val config: Config,
private val appNotifier: AppNotifier,
private val room: RoomDatabase,
private val preferencesManager: CorePreferences,
private val dispatcher: CoroutineDispatcher,
private val sessionManager: InstanceSessionManager,
private val instanceStore: InstanceStore,
private val pushRegistrations: PushRegistrations,
private val analytics: AppAnalytics,
private val deepLinkRouter: DeepLinkRouter,
private val fileUtil: FileUtil,
Expand Down Expand Up @@ -69,8 +70,8 @@ class AppViewModel(

setUserId(user)

if (user != null && preferencesManager.pushToken.isNotEmpty()) {
SyncFirebaseTokenWorker.schedule(context)
if (preferencesManager.pushToken.isNotEmpty()) {
SyncFirebaseTokenWorker.scheduleForSignedInSites(context, pushRegistrations)
}

if (canResetAppDirectory) {
Expand All @@ -80,7 +81,8 @@ class AppViewModel(
viewModelScope.launch {
appNotifier.notifier.collect { event ->
if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) {
SyncFirebaseTokenWorker.schedule(context)
// Sign-in completes only on the site that is still selected.
SyncFirebaseTokenWorker.schedule(context, instanceStore.currentInstance?.key)
} else if (event is LogoutEvent) {
handleLogoutEvent(event)
}
Expand Down Expand Up @@ -123,10 +125,8 @@ class AppViewModel(
if (System.currentTimeMillis() - logoutHandledAt > LOGOUT_EVENT_THRESHOLD) {
if (event.isForced) {
logoutHandledAt = System.currentTimeMillis()
preferencesManager.clearCorePreferences()
withContext(dispatcher) {
room.clearAllTables()
}
// Signs out of the selected site only; other sites keep their sessions.
sessionManager.logoutCurrentInstance()
analytics.logoutEvent(true)
_logoutUser.value = Unit
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package org.openedx.app.data.api

import org.openedx.core.instance.RequestInstance
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
import retrofit2.http.Tag

interface NotificationsApi {
@POST("/api/mobile/v4/notifications/create-token/")
@FormUrlEncoded
suspend fun syncFirebaseToken(
@Field("registration_id") token: String,
@Field("active") active: Boolean = true
@Field("active") active: Boolean,
@Tag instance: RequestInstance,
)
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,59 @@
package org.openedx.app.data.networking

import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.openedx.core.config.Config
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.instance.Instance
import org.openedx.core.instance.InstanceProvider
import org.openedx.core.instance.RequestInstance
import org.openedx.core.presentation.global.AppData

class HeadersInterceptor(
private val appData: AppData,
private val config: Config,
private val preferencesManager: CorePreferences,
private val instanceProvider: InstanceProvider,
private val instanceByKey: (String) -> Instance? = { null },
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response = chain.run {
val request = request()
proceed(
request()
.newBuilder().apply {
val token = preferencesManager.accessToken
request.newBuilder().apply {
// The token of the site the request belongs to, and only to that site's host.
val site = request.tag(RequestInstance::class.java)
?: RequestInstance(instanceProvider.currentInstance?.key)
val token = preferencesManager.accessToken(site.instanceKey)
if (token.isNotEmpty() && request.header(AUTHORIZATION) == null && targets(request, site)) {
addHeader(AUTHORIZATION, "${config.getAccessTokenType()} $token")
}

if (token.isNotEmpty()) {
addHeader("Authorization", "${config.getAccessTokenType()} $token")
}
addHeader("Accept", "application/json")

addHeader("Accept", "application/json")

val httpAgent = System.getProperty("http.agent") ?: ""
addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}")
}.build()
val httpAgent = System.getProperty("http.agent") ?: ""
addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}")
}.build()
)
}

private fun targets(request: Request, site: RequestInstance): Boolean {
val siteUrl = when (val key = site.instanceKey) {
null -> config.getApiHostURL()
// A site no longer in the catalog has no known host: send its token nowhere.
else -> instanceByKey(key)?.apiHostUrl ?: return false
}
val siteHost = siteUrl.toHttpUrlOrNull()
return when {
// An unparseable configured host can't be compared; keep the previous behavior.
siteHost == null -> true
else -> request.url.isSameOrigin(siteHost)
}
}

private companion object {
const val AUTHORIZATION = "Authorization"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.openedx.app.data.networking

import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.openedx.core.instance.Instance
import org.openedx.core.instance.InstanceProvider
import org.openedx.core.instance.RequestInstance

/**
* Re-targets requests built against the app's base URL at the selected instance: scheme, host
* and port change, path and query stay. Requests to any other host (CDNs, third-party SDKs)
* and every request made while no instance is selected pass through untouched.
*/
class InstanceHostInterceptor(
appBaseUrl: String,
private val instanceProvider: InstanceProvider,
private val instanceByKey: (String) -> Instance? = { null },
) : Interceptor {

private val appBase: HttpUrl? = appBaseUrl.toHttpUrlOrNull()

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// A request pinned to a site by its caller goes there; any other goes to the selected one.
val pinned = request.tag(RequestInstance::class.java)
val target = if (pinned != null) pinned.instanceKey?.let(instanceByKey) else instanceProvider.currentInstance
val rewritten = rewriteHostIfNeeded(request, target?.apiHostUrl)
.newBuilder()
.tag(RequestInstance::class.java, pinned ?: RequestInstance(target?.key))
.build()
return chain.proceed(rewritten)
}

internal fun rewriteHostIfNeeded(request: Request, instanceHostUrl: String?): Request {
val target = instanceHostUrl?.toHttpUrlOrNull()
val base = appBase
val url = request.url
if (target == null || base == null || !url.isSameOrigin(base) || url.isSameOrigin(target)) {
return request
}
val newUrl = url.newBuilder()
.scheme(target.scheme)
.host(target.host)
.port(target.port)
.build()
return request.newBuilder().url(newUrl).build()
}
}

internal fun HttpUrl.isSameOrigin(other: HttpUrl): Boolean {
return scheme == other.scheme && host == other.host && port == other.port
}
Loading
Loading