diff --git a/app/build.gradle b/app/build.gradle index f3d047808..8b8f7e9e7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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) { diff --git a/app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt b/app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt new file mode 100644 index 000000000..c9bc68304 --- /dev/null +++ b/app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt @@ -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() + 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 = 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) + } +} diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index bafddb19b..042aa8683 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -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 @@ -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, @@ -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) { @@ -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) } @@ -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 } diff --git a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt index 9106944c3..0c005e076 100644 --- a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt +++ b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt @@ -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, ) } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index baafe5a86..e2d03191e 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -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" + } } diff --git a/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt new file mode 100644 index 000000000..76012b687 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt @@ -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 +} diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index a60a3a988..70e524000 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -23,25 +23,33 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT import org.openedx.core.BuildConfig 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.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.IOException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit class OauthRefreshTokenAuthenticator( private val config: Config, private val preferencesManager: CorePreferences, private val appNotifier: AppNotifier, + private val instanceProvider: InstanceProvider, + private val okHttpClient: OkHttpClient = defaultRefreshClient(), ) : Authenticator, Interceptor { - private val authApi: AuthApi - private var lastTokenRefreshRequestTime = 0L + private val authApis = ConcurrentHashMap() + + /** Per instance key ("" for none): one site's refresh mustn't hold back another's. */ + private val lastTokenRefreshRequestTimes = ConcurrentHashMap() override fun intercept(chain: Interceptor.Chain): Response { - if (isTokenExpired()) { + if (isTokenExpired() && isForActiveInstance(chain.request())) { val response = createUnauthorizedResponse(chain) val request = authenticate(chain.connection()?.route(), response) @@ -50,35 +58,48 @@ class OauthRefreshTokenAuthenticator( return chain.proceed(chain.request()) } - init { - val okHttpClient = OkHttpClient.Builder().apply { - writeTimeout(timeout = 60, TimeUnit.SECONDS) - readTimeout(timeout = 60, TimeUnit.SECONDS) - if (BuildConfig.DEBUG) { - addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) - } - }.build() - authApi = Retrofit.Builder() - .baseUrl(config.getApiHostURL()) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(Gson())) - .build() - .create(AuthApi::class.java) + /** Token endpoint of [instance], or of the app-level host when there is none. */ + private fun authApi(instance: Instance?): AuthApi { + val baseUrl = instance?.apiHostUrl ?: config.getApiHostURL() + return authApis.getOrPut(baseUrl) { + Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(Gson())) + .build() + .create(AuthApi::class.java) + } + } + + /** + * A 401 for a request sent before the learner switched sites says nothing about the site + * that is selected now: refreshing it, or logging out, would act on the wrong session. + */ + private fun isForActiveInstance(request: Request): Boolean = isFor(request, instanceProvider.currentInstance) + + private fun isFor(request: Request, instance: Instance?): Boolean { + val sentFor = request.tag(RequestInstance::class.java) ?: return true + return sentFor.instanceKey == instance?.key } @Suppress("ReturnCount") @Synchronized override fun authenticate(route: Route?, response: Response): Request? { - val accessToken = preferencesManager.accessToken - val refreshToken = preferencesManager.refreshToken + // Resolved once: the tokens read, the endpoint called and the place the result is saved + // all belong to this site, even if the learner switches while the refresh is in flight. + val instance = instanceProvider.currentInstance + if (!isFor(response.request, instance)) return null + + val accessToken = preferencesManager.accessToken(instance?.key) + val refreshToken = preferencesManager.refreshToken(instance?.key) if (refreshToken.isEmpty()) return null val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) ?: return null - return when (errorCode) { + val retry = when (errorCode) { TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { - handleTokenExpired(response, refreshToken, accessToken) + handleTokenExpired(response, refreshToken, accessToken, instance) } TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE, JWT_INVALID_TOKEN -> { @@ -91,6 +112,9 @@ class OauthRefreshTokenAuthenticator( else -> null } + // Re-checked after the refresh: a switch during it must not retry this request with the + // new site's token. + return retry?.takeIf { isForActiveInstance(response.request) } } private fun handleDisabledUser(): Request? { @@ -99,9 +123,14 @@ class OauthRefreshTokenAuthenticator( } // Helper function for handling token expiration logic - private fun handleTokenExpired(response: Response, refreshToken: String, accessToken: String): Request? { + private fun handleTokenExpired( + response: Response, + refreshToken: String, + accessToken: String, + instance: Instance?, + ): Request? { return try { - val newAuth = refreshAccessToken(refreshToken) + val newAuth = refreshAccessToken(refreshToken, instance) if (newAuth != null) { response.request.newBuilder() .header( @@ -110,7 +139,7 @@ class OauthRefreshTokenAuthenticator( ) .build() } else { - val actualToken = preferencesManager.accessToken + val actualToken = preferencesManager.accessToken(instance?.key) if (actualToken != accessToken) { response.request.newBuilder() .header( @@ -150,18 +179,18 @@ class OauthRefreshTokenAuthenticator( return time >= preferencesManager.accessTokenExpiresAt } - private fun canRequestTokenRefresh(): Boolean { - return TimeUtils.getCurrentTime() - lastTokenRefreshRequestTime > - REFRESH_TOKEN_INTERVAL_MINIMUM + private fun canRequestTokenRefresh(instance: Instance?): Boolean { + val last = lastTokenRefreshRequestTimes[instance?.key.orEmpty()] ?: 0L + return TimeUtils.getCurrentTime() - last > REFRESH_TOKEN_INTERVAL_MINIMUM } @Throws(IOException::class) - private fun refreshAccessToken(refreshToken: String): AuthResponse? { + private fun refreshAccessToken(refreshToken: String, instance: Instance?): AuthResponse? { var authResponse: AuthResponse? = null - if (canRequestTokenRefresh()) { - val response = authApi.refreshAccessToken( + if (canRequestTokenRefresh(instance)) { + val response = authApi(instance).refreshAccessToken( ApiConstants.TOKEN_TYPE_REFRESH, - config.getOAuthClientId(), + instance?.oAuthClientId ?: config.getOAuthClientId(), refreshToken, config.getAccessTokenType() ).execute() @@ -172,11 +201,13 @@ class OauthRefreshTokenAuthenticator( val newExpireTime = authResponse.getTokenExpiryTime() if (newAccessToken.isNotEmpty() && newRefreshToken.isNotEmpty()) { - preferencesManager.accessToken = newAccessToken - preferencesManager.refreshToken = newRefreshToken - preferencesManager.accessTokenExpiresAt = newExpireTime - lastTokenRefreshRequestTime = TimeUtils.getCurrentTime() + // Saved for the site that issued them even if the learner switched away while + // the refresh was in flight: the server has already rotated the refresh token, + // so dropping these would sign that site out. + preferencesManager.saveTokens(instance?.key, newAccessToken, newRefreshToken, newExpireTime) + lastTokenRefreshRequestTimes[instance?.key.orEmpty()] = TimeUtils.getCurrentTime() } + if (instance?.key != instanceProvider.currentInstance?.key) return null } else if (response.code() == 400) { // another refresh already in progress Thread.sleep(REFRESH_TOKEN_THREAD_SLEEP) @@ -244,6 +275,14 @@ class OauthRefreshTokenAuthenticator( companion object { private const val HEADER_AUTHORIZATION = "Authorization" + private fun defaultRefreshClient(): OkHttpClient = OkHttpClient.Builder().apply { + writeTimeout(timeout = 60, TimeUnit.SECONDS) + readTimeout(timeout = 60, TimeUnit.SECONDS) + if (BuildConfig.DEBUG) { + addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + }.build() + private const val TOKEN_EXPIRED_ERROR_MESSAGE = "token_expired" private const val TOKEN_NONEXISTENT_ERROR_MESSAGE = "token_nonexistent" private const val TOKEN_INVALID_GRANT_ERROR_MESSAGE = "invalid_grant" diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 48b0d58a1..d7ba7c4d6 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -24,6 +24,8 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -43,6 +45,7 @@ private val Context.dataStore: DataStore by preferencesDataStore( class PreferencesManager( private val context: Context, private val appNotifier: AppNotifier, + private val namespaces: StorageNamespaceProvider, ) : CorePreferences, ProfilePreferences, @@ -61,33 +64,47 @@ class PreferencesManager( @Volatile private var isKeyInvalidated = false + /** + * Keys tied to a learner on one site: they are namespaced per instance, so each site keeps + * its own session, profile and calendar state. Everything in [Keys] is device-wide. + */ + @Suppress("TooManyFunctions") + private object InstanceKeys { + fun accessToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("access_token")) + fun refreshToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("refresh_token")) + fun expiresIn(ns: StorageNamespace) = longPreferencesKey(ns.key("expires_in")) + fun user(ns: StorageNamespace) = stringPreferencesKey(ns.key("user")) + fun account(ns: StorageNamespace) = stringPreferencesKey(ns.key("account")) + fun appConfig(ns: StorageNamespace) = stringPreferencesKey(ns.key("app_config")) + fun lastSignedInUsername(ns: StorageNamespace) = stringPreferencesKey(ns.key("last_signed_in_username")) + fun isRelativeDatesEnabled(ns: StorageNamespace) = + booleanPreferencesKey(ns.key("is_relative_dates_enabled")) + fun calendarId(ns: StorageNamespace) = longPreferencesKey(ns.key("calendar_id")) + fun calendarUser(ns: StorageNamespace) = stringPreferencesKey(ns.key("calendar_user")) + fun calendarType(ns: StorageNamespace) = stringPreferencesKey(ns.key("calendar_type")) + fun isCalendarSyncEnabled(ns: StorageNamespace) = + booleanPreferencesKey(ns.key("is_calendar_sync_enabled")) + fun isHideInactiveCourses(ns: StorageNamespace) = + booleanPreferencesKey(ns.key("hide_inactive_courses")) + fun calendarSyncDialogShown(ns: StorageNamespace, courseName: String) = + booleanPreferencesKey(ns.key("calendar_sync_dialog_${courseName.replaceSpace("_")}")) + } + + private val ns: StorageNamespace + get() = namespaces.current + private object Keys { - val ACCESS_TOKEN = stringPreferencesKey("access_token") - val REFRESH_TOKEN = stringPreferencesKey("refresh_token") val PUSH_TOKEN = stringPreferencesKey("push_token") - val EXPIRES_IN = longPreferencesKey("expires_in") - val USER = stringPreferencesKey("user") - val ACCOUNT = stringPreferencesKey("account") val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = booleanPreferencesKey("video_settings_wifi_download_only") val VIDEO_SETTINGS_STREAMING_QUALITY = stringPreferencesKey("video_settings_streaming_quality") val VIDEO_SETTINGS_DOWNLOAD_QUALITY = stringPreferencesKey("video_settings_download_quality") - val APP_CONFIG = stringPreferencesKey("app_config") val CAN_RESET_APP_DIRECTORY = booleanPreferencesKey("reset_app_directory") - val IS_RELATIVE_DATES_ENABLED = booleanPreferencesKey("is_relative_dates_enabled") - val CALENDAR_ID = longPreferencesKey("calendar_id") - val CALENDAR_USER = stringPreferencesKey("calendar_user") - val CALENDAR_TYPE = stringPreferencesKey("calendar_type") - val IS_CALENDAR_SYNC_ENABLED = booleanPreferencesKey("is_calendar_sync_enabled") - val IS_HIDE_INACTIVE_COURSES = booleanPreferencesKey("hide_inactive_courses") val LAST_WHATS_NEW_VERSION = stringPreferencesKey("last_whats_new_version") val LAST_REVIEW_VERSION = stringPreferencesKey("last_review_version") val WAS_POSITIVE_RATED = booleanPreferencesKey("app_was_positive_rated") - - fun calendarSyncDialogShown(courseName: String) = - booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") } private fun getValue(key: Preferences.Key, defaultValue: T): T = @@ -121,47 +138,84 @@ class PreferencesManager( } override suspend fun clearCorePreferences() { + clearSession(namespaces.current) + } + + override fun hasSession(instanceKey: String?): Boolean { + val namespace = namespaces.namespaceFor(instanceKey) + return getValue(InstanceKeys.accessToken(namespace), "").isNotEmpty() && + getValue(InstanceKeys.user(namespace), "").isNotEmpty() + } + + override suspend fun clearSession(instanceKey: String?) { + clearSession(namespaces.namespaceFor(instanceKey)) + } + + private suspend fun clearSession(namespace: StorageNamespace) { dataStore.edit { prefs -> - prefs.remove(Keys.ACCESS_TOKEN) - prefs.remove(Keys.REFRESH_TOKEN) - prefs.remove(Keys.PUSH_TOKEN) - prefs.remove(Keys.EXPIRES_IN) - prefs.remove(Keys.USER) - prefs.remove(Keys.ACCOUNT) + prefs.remove(InstanceKeys.accessToken(namespace)) + prefs.remove(InstanceKeys.refreshToken(namespace)) + prefs.remove(InstanceKeys.expiresIn(namespace)) + prefs.remove(InstanceKeys.user(namespace)) + prefs.remove(InstanceKeys.account(namespace)) } } + override fun accessToken(instanceKey: String?): String { + return getEncryptedString(InstanceKeys.accessToken(namespaces.namespaceFor(instanceKey)), "") + } + + override fun refreshToken(instanceKey: String?): String { + return getEncryptedString(InstanceKeys.refreshToken(namespaces.namespaceFor(instanceKey)), "") + } + + override fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) { + val namespace = namespaces.namespaceFor(instanceKey) + setEncryptedString(InstanceKeys.accessToken(namespace), accessToken) + setEncryptedString(InstanceKeys.refreshToken(namespace), refreshToken) + setValue(InstanceKeys.expiresIn(namespace), expiresAt) + } + + override fun saveAccessToken(instanceKey: String?, accessToken: String) { + setEncryptedString(InstanceKeys.accessToken(namespaces.namespaceFor(instanceKey)), accessToken) + } + + override fun saveUser(instanceKey: String?, user: User?) { + setEncryptedString(InstanceKeys.user(namespaces.namespaceFor(instanceKey)), Gson().toJson(user)) + } + override suspend fun clearCalendarPreferences() { + val namespace = ns dataStore.edit { prefs -> - prefs.remove(Keys.CALENDAR_ID) - prefs.remove(Keys.CALENDAR_TYPE) - prefs.remove(Keys.IS_CALENDAR_SYNC_ENABLED) - prefs.remove(Keys.IS_HIDE_INACTIVE_COURSES) + prefs.remove(InstanceKeys.calendarId(namespace)) + prefs.remove(InstanceKeys.calendarType(namespace)) + prefs.remove(InstanceKeys.isCalendarSyncEnabled(namespace)) + prefs.remove(InstanceKeys.isHideInactiveCourses(namespace)) } } override var accessToken: String - get() = getEncryptedString(Keys.ACCESS_TOKEN, "") - set(value) = setEncryptedString(Keys.ACCESS_TOKEN, value) + get() = getEncryptedString(InstanceKeys.accessToken(ns), "") + set(value) = setEncryptedString(InstanceKeys.accessToken(ns), value) override var refreshToken: String - get() = getEncryptedString(Keys.REFRESH_TOKEN, "") - set(value) = setEncryptedString(Keys.REFRESH_TOKEN, value) + get() = getEncryptedString(InstanceKeys.refreshToken(ns), "") + set(value) = setEncryptedString(InstanceKeys.refreshToken(ns), value) override var pushToken: String get() = getEncryptedString(Keys.PUSH_TOKEN, "") set(value) = setEncryptedString(Keys.PUSH_TOKEN, value) override var accessTokenExpiresAt: Long - get() = getValue(Keys.EXPIRES_IN, 0L) - set(value) = setValue(Keys.EXPIRES_IN, value) + get() = getValue(InstanceKeys.expiresIn(ns), 0L) + set(value) = setValue(InstanceKeys.expiresIn(ns), value) override var user: User? get() { - val json = getEncryptedString(Keys.USER, "") + val json = getEncryptedString(InstanceKeys.user(ns), "") return if (json.isEmpty()) null else Gson().fromJson(json, User::class.java) } - set(value) = setEncryptedString(Keys.USER, Gson().toJson(value)) + set(value) = setEncryptedString(InstanceKeys.user(ns), Gson().toJson(value)) override var videoSettings: VideoSettings get() { @@ -188,25 +242,33 @@ class PreferencesManager( override var appConfig: AppConfig get() { - val json = getValue(Keys.APP_CONFIG, Gson().toJson(AppConfig())) + val json = getValue(InstanceKeys.appConfig(ns), Gson().toJson(AppConfig())) return Gson().fromJson(json, AppConfig::class.java) } - set(value) = setValue(Keys.APP_CONFIG, Gson().toJson(value)) + set(value) = setValue(InstanceKeys.appConfig(ns), Gson().toJson(value)) override var canResetAppDirectory: Boolean get() = getValue(Keys.CAN_RESET_APP_DIRECTORY, true) set(value) = setValue(Keys.CAN_RESET_APP_DIRECTORY, value) override var isRelativeDatesEnabled: Boolean - get() = getValue(Keys.IS_RELATIVE_DATES_ENABLED, true) - set(value) = setValue(Keys.IS_RELATIVE_DATES_ENABLED, value) + get() = getValue(InstanceKeys.isRelativeDatesEnabled(ns), true) + set(value) = setValue(InstanceKeys.isRelativeDatesEnabled(ns), value) + + override fun lastSignedInUsername(instanceKey: String?): String { + return getValue(InstanceKeys.lastSignedInUsername(namespaces.namespaceFor(instanceKey)), "") + } + + override fun setLastSignedInUsername(instanceKey: String?, username: String) { + setValue(InstanceKeys.lastSignedInUsername(namespaces.namespaceFor(instanceKey)), username) + } override var profile: Account? get() { - val json = getEncryptedString(Keys.ACCOUNT, "") + val json = getEncryptedString(InstanceKeys.account(ns), "") return if (json.isEmpty()) null else Gson().fromJson(json, Account::class.java) } - set(value) = setEncryptedString(Keys.ACCOUNT, Gson().toJson(value)) + set(value) = setEncryptedString(InstanceKeys.account(ns), Gson().toJson(value)) override var lastWhatsNewVersion: String get() = getValue(Keys.LAST_WHATS_NEW_VERSION, "") @@ -229,33 +291,33 @@ class PreferencesManager( set(value) = setValue(Keys.WAS_POSITIVE_RATED, value) override var calendarId: Long - get() = getValue(Keys.CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) - set(value) = setValue(Keys.CALENDAR_ID, value) + get() = getValue(InstanceKeys.calendarId(ns), CalendarManager.CALENDAR_DOES_NOT_EXIST) + set(value) = setValue(InstanceKeys.calendarId(ns), value) override var calendarUser: String - get() = getValue(Keys.CALENDAR_USER, "") - set(value) = setValue(Keys.CALENDAR_USER, value) + get() = getValue(InstanceKeys.calendarUser(ns), "") + set(value) = setValue(InstanceKeys.calendarUser(ns), value) override var calendarType: CalendarType get() { - val stored = getValue(Keys.CALENDAR_TYPE, CalendarType.LOCAL.name) + val stored = getValue(InstanceKeys.calendarType(ns), CalendarType.LOCAL.name) return runCatching { CalendarType.valueOf(stored) }.getOrDefault(CalendarType.LOCAL) } - set(value) = setValue(Keys.CALENDAR_TYPE, value.name) + set(value) = setValue(InstanceKeys.calendarType(ns), value.name) override var isCalendarSyncEnabled: Boolean - get() = getValue(Keys.IS_CALENDAR_SYNC_ENABLED, true) - set(value) = setValue(Keys.IS_CALENDAR_SYNC_ENABLED, value) + get() = getValue(InstanceKeys.isCalendarSyncEnabled(ns), true) + set(value) = setValue(InstanceKeys.isCalendarSyncEnabled(ns), value) override var isHideInactiveCourses: Boolean - get() = getValue(Keys.IS_HIDE_INACTIVE_COURSES, true) - set(value) = setValue(Keys.IS_HIDE_INACTIVE_COURSES, value) + get() = getValue(InstanceKeys.isHideInactiveCourses(ns), true) + set(value) = setValue(InstanceKeys.isHideInactiveCourses(ns), value) override fun setCalendarSyncEventsDialogShown(courseName: String) { - setValue(Keys.calendarSyncDialogShown(courseName), true) + setValue(InstanceKeys.calendarSyncDialogShown(ns, courseName), true) } override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean { - return getValue(Keys.calendarSyncDialogShown(courseName), false) + return getValue(InstanceKeys.calendarSyncDialogShown(ns, courseName), false) } } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 267b73432..401334918 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -1,8 +1,6 @@ package org.openedx.app.di import androidx.appcompat.app.AppCompatActivity -import androidx.room.Room -import androidx.room.RoomDatabase import com.google.android.play.core.review.ReviewManagerFactory import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -17,9 +15,15 @@ import org.openedx.app.BuildConfig import org.openedx.app.PluginManager import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter -import org.openedx.app.room.AppDatabase -import org.openedx.app.room.DATABASE_NAME import org.openedx.app.room.DatabaseManager +import org.openedx.app.room.InstanceDatabaseProvider +import org.openedx.app.room.InstanceScopedCalendarDao +import org.openedx.app.room.InstanceScopedCourseDao +import org.openedx.app.room.InstanceScopedDashboardDao +import org.openedx.app.room.InstanceScopedDatesDao +import org.openedx.app.room.InstanceScopedDiscoveryDao +import org.openedx.app.room.InstanceScopedDownloadDao +import org.openedx.app.system.push.PushRegistrations import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -33,10 +37,27 @@ import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.helper.VideoPreviewHelper +import org.openedx.core.instance.DefaultInstanceSessionManager +import org.openedx.core.instance.InstanceApiService +import org.openedx.core.instance.InstanceCatalogCache +import org.openedx.core.instance.InstanceConfigLoader +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSelectionStorage +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceStorageNamespaces +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.OkHttpInstanceApiService +import org.openedx.core.instance.SharedPrefsInstanceCatalogCache +import org.openedx.core.instance.SharedPrefsInstanceSelectionStorage +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.db.CalendarDao +import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics @@ -62,10 +83,13 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler +import org.openedx.dashboard.data.DashboardDao import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.data.storage.DatesDao import org.openedx.dates.presentation.DatesAnalytics import org.openedx.dates.presentation.DatesRouter +import org.openedx.discovery.data.storage.DiscoveryDao import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -87,7 +111,37 @@ import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { single { Config(get()) } - single { PreferencesManager(get(), get()) } + single { SharedPrefsInstanceSelectionStorage(get()) } + single { get() } + single { + val config = get() + InstanceStorageNamespaces( + instanceStore = get(), + ownerStorage = get(), + appApiHostUrl = config.getApiHostURL(), + appOAuthClientId = config.getOAuthClientId(), + ) + } + single { + val bundledInstances by lazy { get().getInstancesConfig() } + InstanceStore(get()) { bundledInstances } + } + single { get() } + single { SharedPrefsInstanceCatalogCache(get()) } + single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } + single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } + single { PushRegistrations(get(), get(), get(), get()) } + single { + DefaultInstanceSessionManager( + get(), + get(), + get(), + get(), + get(), + pushTokenUnregistrar = get() + ) + } + single { PreferencesManager(get(), get(), get()) } single { get() } single { get() } single { get() } @@ -100,7 +154,7 @@ val appModule = module { single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get()) } single { DownloadDialogManager(get(), get(), get(), get()) } - single { DatabaseManager(get(), get(), get(), get()) } + single { DatabaseManager(get(), get()) } single { get() } single { ImageProcessor(get()) } @@ -140,56 +194,22 @@ val appModule = module { Dispatchers.IO } - single { - Room.databaseBuilder( - androidApplication(), - AppDatabase::class.java, - DATABASE_NAME - ).fallbackToDestructiveMigration() - .fallbackToDestructiveMigrationOnDowngrade() - .build() - } - - single { - get() - } + single { InstanceDatabaseProvider(androidApplication(), get(), get()) } + single { get() } - single { - val room = get() - room.discoveryDao() - } - - single { - val room = get() - room.courseDao() - } - - single { - val room = get() - room.dashboardDao() - } - - single { - val room = get() - room.downloadDao() - } - - single { - val room = get() - room.calendarDao() - } - - single { - val room = get() - room.datesDao() - } + single { InstanceScopedDiscoveryDao(get()) } + single { InstanceScopedCourseDao(get()) } + single { InstanceScopedDashboardDao(get()) } + single { InstanceScopedDownloadDao(get()) } + single { InstanceScopedCalendarDao(get()) } + single { InstanceScopedDatesDao(get()) } single { FileDownloader() } single { - DownloadWorkerController(get(), get(), get()) + DownloadWorkerController(get(), get(), get(), get()) } single { @@ -227,7 +247,7 @@ val appModule = module { factory { VideoPreviewHelper(get(), get()) } factory { FileUtil(get(), get().getString(R.string.app_name)) } - single { DownloadHelper(get(), get()) } + single { DownloadHelper(get(), get(), get()) } factory { OfflineProgressSyncScheduler(get()) } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index 6360e7fba..b4ea69c02 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -7,12 +7,14 @@ import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor +import org.openedx.app.data.networking.InstanceHostInterceptor import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator import org.openedx.auth.data.api.AuthApi import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.api.CookiesApi import org.openedx.core.data.api.CourseApi +import org.openedx.core.instance.InstanceStore import org.openedx.discovery.data.api.DiscoveryApi import org.openedx.discussion.data.api.DiscussionApi import org.openedx.profile.data.api.ProfileApi @@ -22,13 +24,18 @@ import java.util.concurrent.TimeUnit val networkingModule = module { - single { OauthRefreshTokenAuthenticator(get(), get(), get()) } + single { OauthRefreshTokenAuthenticator(get(), get(), get(), get()) } + single { + val store = get() + InstanceHostInterceptor(get().getApiHostURL(), store) { store.instance(it) } + } single { OkHttpClient.Builder().apply { writeTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) - addInterceptor(HeadersInterceptor(get(), get(), get())) + addInterceptor(get()) + addInterceptor(HeadersInterceptor(get(), get(), get(), get()) { get().instance(it) }) if (BuildConfig.DEBUG) { addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 1799dafc6..129c5b61c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -2,7 +2,6 @@ package org.openedx.app.di import android.content.res.Resources import org.koin.core.module.dsl.viewModel -import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AppViewModel import org.openedx.app.MainViewModel @@ -90,9 +89,10 @@ val screenModule = module { AppViewModel( config = get(), appNotifier = get(), - room = get(), preferencesManager = get(), - dispatcher = get(named("IODispatcher")), + sessionManager = get(), + instanceStore = get(), + pushRegistrations = get(), analytics = get(), deepLinkRouter = get(), fileUtil = get(), @@ -103,7 +103,7 @@ val screenModule = module { } viewModel { MainViewModel(get(), get(), get(), get(), get()) } - factory { AuthRepository(get(), get(), get()) } + factory { AuthRepository(get(), get(), get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } @@ -205,7 +205,7 @@ val screenModule = module { ) } - factory { ProfileRepository(get(), get(), get(), get(), get()) } + factory { ProfileRepository(get(), get(), get(), get(), get(), get()) } factory { ProfileInteractor(get()) } viewModel { ProfileViewModel( @@ -250,7 +250,6 @@ val screenModule = module { get(), get(), get(), - get(), ) } viewModel { @@ -269,7 +268,7 @@ val screenModule = module { factory { CalendarRepository(get(), get(), get()) } factory { CalendarInteractor(get()) } - single { CourseRepository(get(), get(), get(), get(), get()) } + single { CourseRepository(get(), get(), get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } single { get() } diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index 0c3087abf..b1be65c97 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -4,23 +4,28 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.openedx.core.DatabaseManager -import org.openedx.core.data.storage.CourseDao -import org.openedx.core.module.db.DownloadDao -import org.openedx.dashboard.data.DashboardDao -import org.openedx.discovery.data.storage.DiscoveryDao +import org.openedx.core.instance.StorageNamespaceProvider class DatabaseManager( - private val courseDao: CourseDao, - private val dashboardDao: DashboardDao, - private val downloadDao: DownloadDao, - private val discoveryDao: DiscoveryDao + private val databases: InstanceDatabaseProvider, + private val namespaces: StorageNamespaceProvider, ) : DatabaseManager { override fun clearTables() { + // Resolved now, not when the coroutine runs: a switch in between must not clear the + // other site's cache. + clear(databases.current) + } + + override fun clearTables(instanceKey: String?) { + clear(databases.database(namespaces.namespaceFor(instanceKey))) + } + + private fun clear(database: AppDatabase) { CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCachedData() - dashboardDao.clearCachedData() - downloadDao.clearOfflineProgress() - discoveryDao.clearCachedData() + database.courseDao().clearCachedData() + database.dashboardDao().clearCachedData() + database.downloadDao().clearOfflineProgress() + database.discoveryDao().clearCachedData() } } } diff --git a/app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt b/app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt new file mode 100644 index 000000000..32e4eeee4 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt @@ -0,0 +1,58 @@ +package org.openedx.app.room + +import android.content.Context +import androidx.room.Room +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import org.openedx.core.module.db.DownloadDao + +/** + * One Room database file per instance, so cached courses, offline progress and download + * records can't collide between sites, even when two sites use the same course and block IDs. + * + * The legacy namespace keeps the original `OpenEdX_db` file, so an upgraded single-site install + * keeps its data without a migration. Databases open lazily and stay open for the process + * lifetime: a catalog lists a handful of sites, and closing one could break a flow still + * collecting from it. + */ +class InstanceDatabaseProvider( + private val context: Context, + instanceProvider: InstanceProvider, + private val namespaces: StorageNamespaceProvider, +) : InstanceDatabases { + + private val databases = HashMap() + + /** The selected instance's database. Resolve it per operation; don't hold on to it. */ + val current: AppDatabase + get() = database(namespaces.current) + + /** Emits the selected instance's database, again on every switch. */ + val currentFlow: Flow = instanceProvider.currentInstanceFlow + .map { database(namespaces.namespaceFor(it)) } + .distinctUntilChanged() + + fun database(namespace: StorageNamespace): AppDatabase = synchronized(databases) { + databases.getOrPut(namespace) { build(namespace) } + } + + override fun downloadDao(namespace: StorageNamespace): DownloadDao = database(namespace).downloadDao() + + private fun build(namespace: StorageNamespace): AppDatabase { + return Room.databaseBuilder(context, AppDatabase::class.java, databaseName(namespace)) + .fallbackToDestructiveMigration() + .fallbackToDestructiveMigrationOnDowngrade() + .build() + } + + companion object { + fun databaseName(namespace: StorageNamespace): String { + return namespace.fileSegment?.let { "${DATABASE_NAME}_$it" } ?: DATABASE_NAME + } + } +} diff --git a/app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt b/app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt new file mode 100644 index 000000000..277edcf24 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt @@ -0,0 +1,180 @@ +package org.openedx.app.room + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseDateEntity +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.DownloadCoursePreview +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity +import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.storage.CourseDao +import org.openedx.core.module.db.CalendarDao +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.dashboard.data.DashboardDao +import org.openedx.dates.data.storage.DatesDao +import org.openedx.discovery.data.model.room.CourseEntity +import org.openedx.discovery.data.storage.DiscoveryDao + +/* + * The DAOs the rest of the app injects. Each call goes to the selected instance's database, + * resolved once per call so one call never spans two databases. Default @Transaction methods + * are forwarded explicitly to the generated DAO, which is what runs them in a transaction. + * Flows follow the selection: they switch to the new database after every site switch. + */ + +class InstanceScopedCourseDao(private val databases: InstanceDatabaseProvider) : CourseDao { + private val dao get() = databases.current.courseDao() + + override suspend fun getCourseStructureById(id: String) = dao.getCourseStructureById(id) + + override suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) = + dao.insertCourseStructureEntity(*courseStructureEntity) + + override suspend fun clearCachedData() = dao.clearCachedData() + + override suspend fun clearCourseStructure() = dao.clearCourseStructure() + + override suspend fun clearVideoProgress() = dao.clearVideoProgress() + + override suspend fun clearEnrollmentCachedData() = dao.clearEnrollmentCachedData() + + override suspend fun clearCourseProgressData() = dao.clearCourseProgressData() + + override suspend fun insertCourseEnrollmentDetailsEntity( + vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity + ) = dao.insertCourseEnrollmentDetailsEntity(*courseEnrollmentDetailsEntity) + + override suspend fun getCourseEnrollmentDetailsById(id: String) = dao.getCourseEnrollmentDetailsById(id) + + override suspend fun insertVideoProgressEntity(vararg videoProgressEntity: VideoProgressEntity) = + dao.insertVideoProgressEntity(*videoProgressEntity) + + override suspend fun getVideoProgressByBlockId(blockId: String) = dao.getVideoProgressByBlockId(blockId) + + override suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) = + dao.insertCourseProgressEntity(*courseProgressEntity) + + override suspend fun getCourseProgressById(id: String) = dao.getCourseProgressById(id) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InstanceScopedDownloadDao(private val databases: InstanceDatabaseProvider) : DownloadDao { + private val dao get() = databases.current.downloadDao() + + override suspend fun removeDownloadModel(id: String) = dao.removeDownloadModel(id) + + override suspend fun insertDownloadModel(downloadModelEntities: List) = + dao.insertDownloadModel(downloadModelEntities) + + override suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) = + dao.updateDownloadModel(downloadModelEntity) + + override fun getAllDataFlow(): Flow> = + databases.currentFlow.flatMapLatest { it.downloadDao().getAllDataFlow() } + + override suspend fun readAllData() = dao.readAllData() + + override fun readAllDataByIds(ids: List): Flow> = + databases.currentFlow.flatMapLatest { it.downloadDao().readAllDataByIds(ids) } + + override suspend fun removeAllDownloadModels(ids: List) = dao.removeAllDownloadModels(ids) + + override suspend fun getDownloadModelsByCourseIds(courseId: String) = dao.getDownloadModelsByCourseIds(courseId) + + override suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) = + dao.insertOfflineXBlockProgress(offlineXBlockProgress) + + override suspend fun getOfflineXBlockProgress(id: String) = dao.getOfflineXBlockProgress(id) + + override suspend fun getAllOfflineXBlockProgress() = dao.getAllOfflineXBlockProgress() + + override suspend fun removeOfflineXBlockProgress(ids: List) = dao.removeOfflineXBlockProgress(ids) + + override suspend fun clearOfflineProgress() = dao.clearOfflineProgress() + + override suspend fun insertDownloadCoursePreview(downloadCoursePreview: List) = + dao.insertDownloadCoursePreview(downloadCoursePreview) + + override fun getDownloadCoursesPreview() = dao.getDownloadCoursesPreview() +} + +class InstanceScopedCalendarDao(private val databases: InstanceDatabaseProvider) : CalendarDao { + private val dao get() = databases.current.calendarDao() + + override suspend fun insertCourseCalendarEntity(vararg courseCalendarEntity: CourseCalendarEventEntity) = + dao.insertCourseCalendarEntity(*courseCalendarEntity) + + override suspend fun deleteCourseCalendarEntitiesById(courseId: String) = + dao.deleteCourseCalendarEntitiesById(courseId) + + override suspend fun readCourseCalendarEventsById(courseId: String) = dao.readCourseCalendarEventsById(courseId) + + override suspend fun readAllCourseCalendarEvents() = dao.readAllCourseCalendarEvents() + + override suspend fun clearCourseCalendarEventsCachedData() = dao.clearCourseCalendarEventsCachedData() + + override suspend fun insertCourseCalendarStateEntity(vararg courseCalendarStateEntity: CourseCalendarStateEntity) = + dao.insertCourseCalendarStateEntity(*courseCalendarStateEntity) + + override suspend fun readCourseCalendarStateById(courseId: String) = dao.readCourseCalendarStateById(courseId) + + override suspend fun readAllCourseCalendarState() = dao.readAllCourseCalendarState() + + override suspend fun clearCourseCalendarStateCachedData() = dao.clearCourseCalendarStateCachedData() + + override suspend fun deleteCourseCalendarStateById(courseId: String) = dao.deleteCourseCalendarStateById(courseId) + + override suspend fun resetChecksums() = dao.resetChecksums() + + override suspend fun updateCourseCalendarStateById( + courseId: String, + checksum: Int?, + isCourseSyncEnabled: Boolean?, + ) = dao.updateCourseCalendarStateById(courseId, checksum, isCourseSyncEnabled) + + override suspend fun clearCachedData() = dao.clearCachedData() +} + +class InstanceScopedDiscoveryDao(private val databases: InstanceDatabaseProvider) : DiscoveryDao { + private val dao get() = databases.current.discoveryDao() + + override suspend fun getCourseById(id: String) = dao.getCourseById(id) + + override suspend fun insertCourseEntity(vararg courseEntity: CourseEntity) = dao.insertCourseEntity(*courseEntity) + + override suspend fun updateCourseEntity(courseEntity: CourseEntity) = dao.updateCourseEntity(courseEntity) + + override suspend fun clearCachedData() = dao.clearCachedData() + + override suspend fun readAllData() = dao.readAllData() +} + +class InstanceScopedDashboardDao(private val databases: InstanceDatabaseProvider) : DashboardDao { + private val dao get() = databases.current.dashboardDao() + + override suspend fun insertEnrolledCourseEntity(vararg courseEntity: EnrolledCourseEntity) = + dao.insertEnrolledCourseEntity(*courseEntity) + + override suspend fun clearCachedData() = dao.clearCachedData() + + override suspend fun readAllData() = dao.readAllData() +} + +class InstanceScopedDatesDao(private val databases: InstanceDatabaseProvider) : DatesDao { + private val dao get() = databases.current.datesDao() + + override suspend fun getCourseDates() = dao.getCourseDates() + + override suspend fun getCourseDates(limit: Int) = dao.getCourseDates(limit) + + override suspend fun insertCourseDates(courseDates: List) = dao.insertCourseDates(courseDates) + + override suspend fun clearCachedData() = dao.clearCachedData() +} diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt index 52caf4de7..339cffb02 100644 --- a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -20,6 +20,7 @@ import org.openedx.core.data.storage.CorePreferences class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { private val preferences: CorePreferences by inject() + private val registrations: PushRegistrations by inject() private val config: Config by inject() override fun onMessageReceived(message: RemoteMessage) { @@ -37,9 +38,8 @@ class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) preferences.pushToken = token - if (preferences.user != null) { - SyncFirebaseTokenWorker.schedule(this) - } + // The device token changed: every signed-in site needs the new one. + SyncFirebaseTokenWorker.scheduleForSignedInSites(this, registrations) } private fun handlePushNotification(message: RemoteMessage) { diff --git a/app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt b/app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt new file mode 100644 index 000000000..02402c79a --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt @@ -0,0 +1,51 @@ +package org.openedx.app.system.push + +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.PushTokenUnregistering +import org.openedx.core.instance.RequestInstance + +/** + * The device has one push token; each site the learner is signed in to registers it with its + * own backend. Registering and unregistering are pinned to one site, so they reach that site's + * host with that site's session even if the learner switches sites while they run. + */ +class PushRegistrations( + private val api: NotificationsApi, + private val preferences: CorePreferences, + private val config: Config, + private val instanceStore: InstanceStore, +) : PushTokenUnregistering { + + private val isEnabled: Boolean + get() = config.getFirebaseConfig().isCloudMessagingEnabled + + /** The sites with a session: catalog sites, or the single-site session (`null`) with no catalog. */ + fun signedInSites(): List { + val catalog = instanceStore.instancesConfig.instances + return if (catalog.isEmpty()) { + if (preferences.hasSession(null)) listOf(null) else emptyList() + } else { + catalog.map { it.key }.filter { preferences.hasSession(it) } + } + } + + /** Registers this device's token with one site; does nothing for a site without a session. */ + suspend fun register(instanceKey: String?): Boolean { + val token = preferences.pushToken + if (!isEnabled || token.isEmpty() || !preferences.hasSession(instanceKey)) return false + api.syncFirebaseToken(token, active = true, instance = RequestInstance(instanceKey)) + return true + } + + override suspend fun unregisterPushToken(instanceKey: String?) { + val token = preferences.pushToken + if (!isEnabled || token.isEmpty() || !preferences.hasSession(instanceKey)) return + // Best effort: signing out must not fail because the site is unreachable. + runCatching { + api.syncFirebaseToken(token, active = false, instance = RequestInstance(instanceKey)) + } + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt index 0f37f36e3..cbd8fe1ee 100644 --- a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt +++ b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt @@ -12,19 +12,27 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.openedx.core.data.storage.CorePreferences +/** + * Rotates the device's push token after a sign-out, so a backend that missed the unregistration + * can no longer reach this device. The token is device-wide, so this is device-level work with + * one unique name. It rotates only when no site is still signed in: rotating would cut off the + * sites that still use the token. Any site signed in by the time the new token arrives gets it. + */ class RefreshFirebaseTokenWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params), KoinComponent { private val preferences: CorePreferences by inject() + private val registrations: PushRegistrations by inject() override suspend fun doWork(): Result { - FirebaseMessaging.getInstance().deleteToken().await() + if (registrations.signedInSites().isNotEmpty()) return Result.success() + FirebaseMessaging.getInstance().deleteToken().await() val newPushToken = FirebaseMessaging.getInstance().getToken().await() - preferences.pushToken = newPushToken + SyncFirebaseTokenWorker.scheduleForSignedInSites(applicationContext, registrations) return Result.success() } diff --git a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt index 6c45a3ab4..953314843 100644 --- a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt +++ b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt @@ -6,40 +6,43 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters +import androidx.work.workDataOf import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.openedx.app.data.api.NotificationsApi -import org.openedx.core.data.storage.CorePreferences +/** Registers the device's push token with one site, the one it was scheduled for. */ class SyncFirebaseTokenWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params), KoinComponent { - private val preferences: CorePreferences by inject() - private val api: NotificationsApi by inject() + private val registrations: PushRegistrations by inject() override suspend fun doWork(): Result { - if (preferences.user != null && preferences.pushToken.isNotEmpty()) { - api.syncFirebaseToken(preferences.pushToken) - - return Result.success() - } - return Result.failure() + val instanceKey = inputData.getString(KEY_INSTANCE_KEY) + registrations.register(instanceKey) + return Result.success() } companion object { private const val WORKER_TAG = "SyncFirebaseTokenWorker" + private const val KEY_INSTANCE_KEY = "instance_key" - fun schedule(context: Context) { + /** One unique work per site: a sync for one site never replaces another site's. */ + fun schedule(context: Context, instanceKey: String?) { val work = OneTimeWorkRequest .Builder(SyncFirebaseTokenWorker::class.java) .addTag(WORKER_TAG) + .setInputData(workDataOf(KEY_INSTANCE_KEY to instanceKey)) .build() WorkManager.getInstance(context).beginUniqueWork( - WORKER_TAG, + "$WORKER_TAG:${instanceKey.orEmpty()}", ExistingWorkPolicy.REPLACE, work ).enqueue() } + + fun scheduleForSignedInSites(context: Context, registrations: PushRegistrations) { + registrations.signedInSites().forEach { schedule(context, it) } + } } } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index d661a1c17..c2a914b83 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -27,10 +27,12 @@ import org.openedx.app.AppAnalytics import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter -import org.openedx.app.room.AppDatabase +import org.openedx.app.system.push.PushRegistrations import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceStore import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -47,7 +49,9 @@ class AppViewModelTest { private val config = mockk() private val notifier = mockk() - private val room = mockk() + private val sessionManager = mockk() + private val instanceStore = mockk(relaxed = true) + private val pushRegistrations = mockk(relaxed = true) private val preferencesManager = mockk() private val analytics = mockk() private val fileUtil = mockk() @@ -78,9 +82,10 @@ class AppViewModelTest { val viewModel = AppViewModel( config, notifier, - room, preferencesManager, - dispatcher, + sessionManager, + instanceStore, + pushRegistrations, analytics, deepLinkRouter, fileUtil, @@ -103,10 +108,9 @@ class AppViewModelTest { every { notifier.notifier } returns flow { emit(LogoutEvent(true)) } - coEvery { preferencesManager.clearCorePreferences() } returns Unit + coEvery { sessionManager.logoutCurrentInstance() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns CoreMocks.mockUser - every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false every { preferencesManager.pushToken } returns "" @@ -115,9 +119,10 @@ class AppViewModelTest { val viewModel = AppViewModel( config, notifier, - room, preferencesManager, - dispatcher, + sessionManager, + instanceStore, + pushRegistrations, analytics, deepLinkRouter, fileUtil, @@ -142,10 +147,9 @@ class AppViewModelTest { emit(LogoutEvent(true)) emit(LogoutEvent(true)) } - coEvery { preferencesManager.clearCorePreferences() } returns Unit + coEvery { sessionManager.logoutCurrentInstance() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns CoreMocks.mockUser - every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false every { preferencesManager.pushToken } returns "" @@ -154,9 +158,10 @@ class AppViewModelTest { val viewModel = AppViewModel( config, notifier, - room, preferencesManager, - dispatcher, + sessionManager, + instanceStore, + pushRegistrations, analytics, deepLinkRouter, fileUtil, @@ -172,10 +177,9 @@ class AppViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logoutEvent(true) } - coVerify(exactly = 1) { preferencesManager.clearCorePreferences() } + coVerify(exactly = 1) { sessionManager.logoutCurrentInstance() } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { preferencesManager.user } - verify(exactly = 1) { room.clearAllTables() } verify(exactly = 1) { analytics.logoutEvent(true) } } } diff --git a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt new file mode 100644 index 000000000..46f0ed0c9 --- /dev/null +++ b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt @@ -0,0 +1,340 @@ +package org.openedx.app.data.networking + +import com.google.gson.JsonObject +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +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 +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent + +class InstanceAwareNetworkingTest { + + private class FakeInstanceProvider(instance: Instance?) : InstanceProvider { + private val flow = MutableStateFlow(instance) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances: Boolean = true + fun select(instance: Instance?) { + flow.value = instance + } + } + + private fun instance(key: String, host: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", host) + } + ) + ) + + private val appBase = "https://app.example.com" + private val alpha = instance("alpha", "https://alpha.example.com") + private val beta = instance("beta", "http://beta.example.com:8080") + + /** Runs [interceptor] on [request] and returns the request it forwarded. */ + private fun forward(interceptor: Interceptor, request: Request): Request { + var forwarded: Request? = null + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .addInterceptor { chain -> + forwarded = chain.request() + ok(chain.request(), "{}") + } + .build() + client.newCall(request).execute().close() + return requireNotNull(forwarded) + } + + private fun ok(request: Request, body: String, code: Int = 200) = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("status $code") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + + // region InstanceHostInterceptor + + @Test + fun `app base requests are re-targeted at the selected instance, path and query preserved`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(beta)) + + val forwarded = forward( + interceptor, + Request.Builder().url("https://app.example.com/api/courses/v1/?page=2&size=10").build() + ) + + assertEquals("http://beta.example.com:8080/api/courses/v1/?page=2&size=10", forwarded.url.toString()) + assertEquals(RequestInstance("beta"), forwarded.tag(RequestInstance::class.java)) + } + + @Test + fun `no selected instance passes requests through`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(null)) + + val forwarded = forward(interceptor, Request.Builder().url("https://app.example.com/api/x").build()) + + assertEquals("https://app.example.com/api/x", forwarded.url.toString()) + assertEquals(RequestInstance(null), forwarded.tag(RequestInstance::class.java)) + } + + @Test + fun `third party hosts are left untouched`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(alpha)) + + listOf( + "https://cdn.example.net/video.mp4", + "http://app.example.com/api/x", + "https://app.example.com:8443/api/x", + ).forEach { url -> + assertEquals(url, forward(interceptor, Request.Builder().url(url).build()).url.toString()) + } + } + + @Test + fun `requests already built for the instance host stay as they are`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(alpha)) + val request = Request.Builder().url("https://alpha.example.com/api/x").build() + + assertSame(request, interceptor.rewriteHostIfNeeded(request, alpha.apiHostUrl)) + } + + // endregion + + // region HeadersInterceptor + + private val catalog = listOf(alpha, beta).associateBy { it.key } + + private fun headersInterceptor(provider: InstanceProvider): HeadersInterceptor { + val config = mockk { + every { getAccessTokenType() } returns "JWT" + every { getApiHostURL() } returns appBase + } + val preferences = mockk { + every { accessToken(any()) } answers { "token-${firstArg() ?: "app"}" } + } + val appData = AppData(appName = "app", versionName = "1", applicationId = "org.openedx.app") + return HeadersInterceptor(appData, config, preferences, provider) { catalog[it] } + } + + @Test + fun `token is sent to the active site only`() { + val interceptor = headersInterceptor(FakeInstanceProvider(alpha)) + + val toSite = forward(interceptor, Request.Builder().url("https://alpha.example.com/api/x").build()) + val elsewhere = forward(interceptor, Request.Builder().url("https://beta.example.com/api/x").build()) + + assertEquals("JWT token-alpha", toSite.header("Authorization")) + assertNull(elsewhere.header("Authorization")) + } + + @Test + fun `without a selection the app level host gets the token`() { + val interceptor = headersInterceptor(FakeInstanceProvider(null)) + + val forwarded = forward(interceptor, Request.Builder().url("https://app.example.com/api/x").build()) + + assertEquals("JWT token-app", forwarded.header("Authorization")) + } + + @Test + fun `a request pinned to a site goes there with that site's token whichever site is selected`() { + val provider = FakeInstanceProvider(beta) + val chain = OkHttpClient.Builder() + .addInterceptor(InstanceHostInterceptor(appBase, provider) { catalog[it] }) + .addInterceptor(headersInterceptor(provider)) + var forwarded: Request? = null + chain.addInterceptor { + forwarded = it.request() + ok(it.request(), "{}") + } + val request = Request.Builder() + .url("https://app.example.com/api/mobile/v4/notifications/create-token/") + .tag(RequestInstance::class.java, RequestInstance("alpha")) + .build() + + chain.build().newCall(request).execute().close() + + assertEquals("alpha.example.com", forwarded?.url?.host) + assertEquals("JWT token-alpha", forwarded?.header("Authorization")) + assertEquals(RequestInstance("alpha"), forwarded?.tag(RequestInstance::class.java)) + } + + @Test + fun `a request pinned to a site no longer in the catalog carries no token`() { + val interceptor = headersInterceptor(FakeInstanceProvider(beta)) + val request = Request.Builder() + .url("https://app.example.com/api/x") + .tag(RequestInstance::class.java, RequestInstance("gone")) + .build() + + assertNull(forward(interceptor, request).header("Authorization")) + } + + // endregion + + // region OauthRefreshTokenAuthenticator + + private class Prefs : CorePreferences by mockk(relaxed = true) { + override var accessToken: String = "old-access" + override var refreshToken: String = "old-refresh" + override var accessTokenExpiresAt: Long = Long.MAX_VALUE + val savedTokens = mutableMapOf() + + override fun accessToken(instanceKey: String?) = savedTokens[instanceKey] ?: accessToken + + override fun refreshToken(instanceKey: String?) = refreshToken + + override fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) { + savedTokens[instanceKey] = accessToken + } + } + + private fun unauthorized(request: Request) = ok(request, """{"detail": "Token has expired."}""", code = 401) + + private fun taggedRequest(url: String, key: String?) = Request.Builder() + .url(url) + .header("Authorization", "JWT old-access") + .tag(RequestInstance::class.java, RequestInstance(key)) + .build() + + @Test + fun `refresh uses the selected instance host and OAuth client`() { + val tokenRequests = mutableListOf() + val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> + tokenRequests += chain.request() + ok( + chain.request(), + """{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600}""" + ) + }.build() + val prefs = Prefs() + val config = mockk(relaxed = true) { + every { getAccessTokenType() } returns "JWT" + every { getApiHostURL() } returns appBase + every { getOAuthClientId() } returns "app-client" + } + val authenticator = OauthRefreshTokenAuthenticator( + config, + prefs, + mockk(relaxed = true), + FakeInstanceProvider(beta), + refreshClient + ) + + val retry = authenticator.authenticate( + null, + unauthorized(taggedRequest("http://beta.example.com:8080/api/x", "beta")) + ) + + val tokenRequest = tokenRequests.single() + assertEquals("http://beta.example.com:8080/oauth2/access_token/", tokenRequest.url.toString()) + val form = Buffer().also { tokenRequest.body!!.writeTo(it) }.readUtf8() + assertTrue(form, form.contains("client_id=beta-client")) + assertEquals("JWT new-access", retry?.header("Authorization")) + assertEquals(mapOf("beta" to "new-access"), prefs.savedTokens) + } + + @Test + fun `a refresh on one site does not hold back the other site's refresh`() { + val provider = FakeInstanceProvider(alpha) + val tokenHosts = mutableListOf() + val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> + tokenHosts += chain.request().url.host + ok(chain.request(), """{"access_token": "new", "refresh_token": "rotated", "expires_in": 3600}""") + }.build() + val authenticator = OauthRefreshTokenAuthenticator( + mockk(relaxed = true) { every { getAccessTokenType() } returns "JWT" }, + Prefs(), + mockk(relaxed = true), + provider, + refreshClient + ) + + authenticator.authenticate(null, unauthorized(taggedRequest("https://alpha.example.com/api/x", "alpha"))) + provider.select(beta) + authenticator.authenticate(null, unauthorized(taggedRequest("http://beta.example.com:8080/api/x", "beta"))) + + assertEquals(listOf("alpha.example.com", "beta.example.com"), tokenHosts) + } + + @Test + fun `a 401 for a request sent before a switch neither refreshes nor logs out`() { + val refreshClient = OkHttpClient.Builder() + .addInterceptor { throw AssertionError("no refresh expected") } + .build() + val notifier = mockk(relaxed = true) + val prefs = Prefs() + val authenticator = OauthRefreshTokenAuthenticator( + mockk(relaxed = true), + prefs, + notifier, + FakeInstanceProvider(beta), + refreshClient + ) + + val retry = authenticator.authenticate( + null, + unauthorized(taggedRequest("https://alpha.example.com/api/x", "alpha")) + ) + + assertNull(retry) + assertEquals("old-access", prefs.accessToken) + coVerify(exactly = 0) { notifier.send(any()) } + } + + @Test + fun `tokens refreshed while the learner switched sites are kept for the site that issued them`() { + val provider = FakeInstanceProvider(alpha) + val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> + provider.select(beta) + ok( + chain.request(), + """{"access_token": "alpha-new", "refresh_token": "alpha-refresh", "expires_in": 3600}""" + ) + }.build() + val prefs = Prefs() + val authenticator = OauthRefreshTokenAuthenticator( + mockk(relaxed = true) { every { getAccessTokenType() } returns "JWT" }, + prefs, + mockk(relaxed = true), + provider, + refreshClient + ) + + val retry = authenticator.authenticate( + null, + unauthorized(taggedRequest("https://alpha.example.com/api/x", "alpha")) + ) + + assertNull(retry) + assertEquals(mapOf("alpha" to "alpha-new"), prefs.savedTokens) + assertEquals("beta", provider.currentInstance?.key) + } + + // endregion +} diff --git a/app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt b/app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt new file mode 100644 index 000000000..5a04e642a --- /dev/null +++ b/app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt @@ -0,0 +1,99 @@ +package org.openedx.app.system.push + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.config.Config +import org.openedx.core.config.FirebaseConfig +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceSelectionStorage +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.InstancesConfig +import org.openedx.core.instance.RequestInstance +import java.io.IOException + +class PushRegistrationsTest { + + private fun site(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 = site("alpha") + private val beta = site("beta") + private val api = mockk(relaxed = true) + private val preferences = mockk { + every { pushToken } returns "device-token" + every { hasSession(any()) } answers { firstArg() in setOf("alpha", "beta") } + } + private val config = mockk { + every { getFirebaseConfig() } returns FirebaseConfig(isCloudMessagingEnabled = true) + } + + private fun store(vararg sites: Instance) = InstanceStore( + object : InstanceSelectionStorage { + override var selectedInstanceKey: String? = null + } + ) { InstancesConfig(sites.toList()) } + + @Test + fun `unregistering targets the site it is for, not the selected one`() = runBlocking { + val store = store(alpha, beta) + store.select(beta) + + PushRegistrations(api, preferences, config, store).unregisterPushToken("alpha") + + coVerify { api.syncFirebaseToken("device-token", active = false, instance = RequestInstance("alpha")) } + coVerify(exactly = 0) { api.syncFirebaseToken(any(), any(), RequestInstance("beta")) } + } + + @Test + fun `an unreachable site does not fail the sign-out`() = runBlocking { + coEvery { api.syncFirebaseToken(any(), any(), any()) } throws IOException("offline") + + PushRegistrations(api, preferences, config, store(alpha)).unregisterPushToken("alpha") + } + + @Test + fun `registering is pinned to its site and skipped without a session`() = runBlocking { + val registrations = PushRegistrations(api, preferences, config, store(alpha, beta, site("gamma"))) + + registrations.register("beta") + registrations.register("gamma") + + coVerify { api.syncFirebaseToken("device-token", active = true, instance = RequestInstance("beta")) } + coVerify(exactly = 0) { api.syncFirebaseToken(any(), any(), RequestInstance("gamma")) } + } + + @Test + fun `nothing is sent when cloud messaging is off`() = runBlocking { + every { config.getFirebaseConfig() } returns FirebaseConfig(isCloudMessagingEnabled = false) + + PushRegistrations(api, preferences, config, store(alpha)).unregisterPushToken("alpha") + + coVerify(exactly = 0) { api.syncFirebaseToken(any(), any(), any()) } + } + + @Test + fun `signed in sites come from the catalog, or the single-site session without one`() { + assertEquals( + listOf("alpha", "beta"), + PushRegistrations(api, preferences, config, store(alpha, site("gamma"), beta)).signedInSites() + ) + every { preferences.hasSession(null) } returns true + assertEquals(listOf(null), PushRegistrations(api, preferences, config, store()).signedInSites()) + } +} diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index 673168c57..a9cc4f20f 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -6,6 +6,7 @@ import org.openedx.auth.data.model.RegistrationFields import org.openedx.auth.data.model.ValidationFields import org.openedx.core.ApiConstants import org.openedx.core.data.model.User +import org.openedx.core.instance.RequestInstance import retrofit2.Call import retrofit2.http.Field import retrofit2.http.FieldMap @@ -13,6 +14,7 @@ import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path +import retrofit2.http.Tag interface AuthApi { @@ -24,6 +26,7 @@ interface AuthApi { @Field("token_type") tokenType: String, @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, @Path("auth_type") authType: String, + @Tag instance: RequestInstance, ): AuthResponse @FormUrlEncoded @@ -35,6 +38,7 @@ interface AuthApi { @Field("password") password: String, @Field("token_type") tokenType: String, @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, + @Tag instance: RequestInstance, ): AuthResponse @FormUrlEncoded @@ -59,7 +63,7 @@ interface AuthApi { suspend fun validateRegistrationFields(@FieldMap fields: Map): ValidationFields @GET(ApiConstants.GET_USER_PROFILE) - suspend fun getProfile(): User + suspend fun getProfile(@Tag instance: RequestInstance): User @FormUrlEncoded @POST(ApiConstants.URL_PASSWORD_RESET) diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 31a869703..1f807aec5 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -8,49 +8,64 @@ import org.openedx.core.ApiConstants import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceSwitchedException +import org.openedx.core.instance.RequestInstance import org.openedx.core.system.EdxError +/** + * Sign-in belongs to the site selected when it starts. Every request is pinned to that site, + * every credential is written to that site's storage, and if the learner selects another site + * before it finishes, it stops with [InstanceSwitchedException] instead of completing. + */ class AuthRepository( private val config: Config, private val api: AuthApi, private val preferencesManager: CorePreferences, + private val sessionManager: InstanceSessionManager, + private val instanceProvider: InstanceProvider, ) { suspend fun login( username: String, password: String, ) { + val site = currentSite() api.getAccessToken( - ApiConstants.GRANT_TYPE_PASSWORD, - config.getOAuthClientId(), - username, - password, - config.getAccessTokenType(), + grantType = ApiConstants.GRANT_TYPE_PASSWORD, + clientId = config.getOAuthClientId(), + username = username, + password = password, + tokenType = config.getAccessTokenType(), + instance = site, ) .mapToDomain() - .processAuthResponse() + .processAuthResponse(site) } suspend fun ssoLogin( jwtToken: String ) { - if (preferencesManager.accessToken.isBlank()) { - preferencesManager.accessToken = jwtToken + val site = currentSite() + if (preferencesManager.accessToken(site.instanceKey).isBlank()) { + preferencesManager.saveAccessToken(site.instanceKey, jwtToken) } - val user = api.getProfile() - preferencesManager.user = user + saveProfile(site) } suspend fun socialLogin(token: String?, authType: AuthType) { require(!token.isNullOrBlank()) { "Token is null" } + val site = currentSite() api.exchangeAccessToken( accessToken = token, clientId = config.getOAuthClientId(), tokenType = config.getAccessTokenType(), - authType = authType.postfix + authType = authType.postfix, + instance = site, ) .mapToDomain() - .processAuthResponse() + .processAuthResponse(site) } suspend fun getRegistrationFields(): List { @@ -69,14 +84,32 @@ class AuthRepository( return api.passwordReset(email).success } - private suspend fun AuthResponse.processAuthResponse() { + private fun currentSite() = RequestInstance(instanceProvider.currentInstance?.key) + + private fun ensureStillSelected(site: RequestInstance) { + if (instanceProvider.currentInstance?.key != site.instanceKey) { + throw InstanceSwitchedException(site.instanceKey) + } + } + + private suspend fun AuthResponse.processAuthResponse(site: RequestInstance) { if (error != null) { throw EdxError.UnknownException(error!!) } - preferencesManager.accessToken = accessToken ?: "" - preferencesManager.refreshToken = refreshToken ?: "" - preferencesManager.accessTokenExpiresAt = getTokenExpiryTime() - val user = api.getProfile() - preferencesManager.user = user + ensureStillSelected(site) + preferencesManager.saveTokens( + site.instanceKey, + accessToken.orEmpty(), + refreshToken.orEmpty(), + getTokenExpiryTime(), + ) + saveProfile(site) + } + + private suspend fun saveProfile(site: RequestInstance) { + val user = api.getProfile(site) + ensureStillSelected(site) + preferencesManager.saveUser(site.instanceKey, user) + user.username?.let { sessionManager.onSignedIn(site.instanceKey, it) } } } diff --git a/auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt b/auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt new file mode 100644 index 000000000..5b1f7c806 --- /dev/null +++ b/auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt @@ -0,0 +1,113 @@ +package org.openedx.auth.data.repository + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.openedx.auth.data.api.AuthApi +import org.openedx.auth.data.model.AuthResponse +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.Instance +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceSwitchedException +import org.openedx.core.instance.RequestInstance + +class AuthRepositoryInstanceTest { + + private class Selection(instance: Instance?) : InstanceProvider { + val flow = MutableStateFlow(instance) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances = true + } + + private fun site(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 = site("alpha") + private val beta = site("beta") + private val selection = Selection(alpha) + private val api = mockk() + private val preferences = mockk(relaxed = true) + private val sessionManager = mockk(relaxed = true) + private val config = mockk { + every { getOAuthClientId() } returns "alpha-client" + every { getAccessTokenType() } returns "JWT" + } + private val tokens = AuthResponse("access-a", "JWT", 3600, null, null, "refresh-a") + private val learner = User(1, "learner_a", "a@example.com", "Learner A") + + private fun repository() = AuthRepository(config, api, preferences, sessionManager, selection) + + @Test + fun `sign-in pins every request and write to the site it started on`() = runTest { + coEvery { + api.getAccessToken(any(), any(), any(), any(), any(), any(), RequestInstance("alpha")) + } returns tokens + coEvery { api.getProfile(RequestInstance("alpha")) } returns learner + + repository().login("learner_a", "secret") + + verify { preferences.saveTokens("alpha", "access-a", "refresh-a", any()) } + verify { preferences.saveUser("alpha", learner) } + verify { sessionManager.onSignedIn("alpha", "learner_a") } + } + + @Test + fun `a sign-in answer that arrives after another site was selected writes nothing`() = runTest { + coEvery { api.getAccessToken(any(), any(), any(), any(), any(), any(), any()) } answers { + selection.flow.value = beta + tokens + } + + try { + repository().login("learner_a", "secret") + fail("the sign-in must not complete on another site") + } catch (e: InstanceSwitchedException) { + assertEquals("alpha", e.instanceKey) + } + + verify(exactly = 0) { preferences.saveTokens(any(), any(), any(), any()) } + verify(exactly = 0) { preferences.saveUser(any(), any()) } + verify(exactly = 0) { sessionManager.onSignedIn(any(), any()) } + coVerify(exactly = 0) { api.getProfile(any()) } + } + + @Test + fun `a switch while the profile loads leaves the new site without a user`() = runTest { + coEvery { api.getAccessToken(any(), any(), any(), any(), any(), any(), any()) } returns tokens + coEvery { api.getProfile(RequestInstance("alpha")) } answers { + selection.flow.value = beta + learner + } + + try { + repository().login("learner_a", "secret") + fail("the sign-in must not complete on another site") + } catch (_: InstanceSwitchedException) { + // expected + } + + verify(exactly = 0) { preferences.saveUser(any(), any()) } + verify(exactly = 0) { preferences.saveTokens("beta", any(), any(), any()) } + } +} diff --git a/core/build.gradle b/core/build.gradle index 19be1f57a..d84b68884 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -124,6 +124,7 @@ dependencies { debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" androidTestImplementation "androidx.test.ext:junit:$test_ext_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/core/src/main/java/org/openedx/core/DatabaseManager.kt b/core/src/main/java/org/openedx/core/DatabaseManager.kt index d7bc7d025..6be731f33 100644 --- a/core/src/main/java/org/openedx/core/DatabaseManager.kt +++ b/core/src/main/java/org/openedx/core/DatabaseManager.kt @@ -1,5 +1,9 @@ package org.openedx.core interface DatabaseManager { + /** Clears the selected site's cached course data. */ fun clearTables() + + /** Clears one site's cached course data, whichever site is selected. */ + fun clearTables(instanceKey: String?) } diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 1f07f43ad..6982c2854 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -6,6 +6,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.instance.InstancesConfig import java.io.InputStreamReader @Suppress("TooManyFunctions") @@ -146,6 +147,16 @@ class Config(context: Context) { return getBoolean(REGISTRATION_ENABLED, true) } + /** Where the remote instance catalog is fetched from; empty means bundled catalog only. */ + fun getInstancesCatalogUrl(): String { + return getString(INSTANCES_CATALOG_URL, "") + } + + /** The bundled `INSTANCES` catalog, before any remote catalog replaces it. */ + fun getInstancesConfig(): InstancesConfig { + return InstancesConfig.fromAppConfig(configProperties) + } + private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { return getObjectOrNewInstance(EXPERIMENTAL_FEATURES, ExperimentalFeaturesConfig::class.java) } @@ -217,6 +228,7 @@ class Config(context: Context) { private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" + private const val INSTANCES_CATALOG_URL = "INSTANCES_CATALOG_URL" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 9e42a5273..f858c0d99 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -7,6 +7,8 @@ import org.openedx.core.domain.model.VideoSettings interface CorePreferences { var accessToken: String var refreshToken: String + + /** This device's push token. Device-wide: each signed-in site registers the same token. */ var pushToken: String var accessTokenExpiresAt: Long var user: User? @@ -15,5 +17,28 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean + /** Clears the selected instance's session: tokens, user and profile. */ suspend fun clearCorePreferences() + + /** Whether [instanceKey]'s site has a stored session, without selecting it. */ + fun hasSession(instanceKey: String?): Boolean + + /** Clears one instance's session without touching the selected one. */ + suspend fun clearSession(instanceKey: String?) + + fun accessToken(instanceKey: String?): String + + fun refreshToken(instanceKey: String?): String + + /** Stores refreshed tokens for the site that issued them, whichever site is selected now. */ + fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) + + fun saveAccessToken(instanceKey: String?, accessToken: String) + + fun saveUser(instanceKey: String?, user: User?) + + /** Who signed in to [instanceKey]'s site last. Survives logout, unlike [user]. */ + fun lastSignedInUsername(instanceKey: String?): String + + fun setLastSignedInUsername(instanceKey: String?, username: String) } diff --git a/core/src/main/java/org/openedx/core/instance/Instance.kt b/core/src/main/java/org/openedx/core/instance/Instance.kt new file mode 100644 index 000000000..670b13216 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/Instance.kt @@ -0,0 +1,185 @@ +package org.openedx.core.instance + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import java.net.URI +import java.text.Normalizer +import java.util.Locale + +/** + * One Open edX platform the app can sign in to: its own host, OAuth client, branding and + * per-platform overrides of the app-level configuration. + * + * Instances are identified by their stable [key], never by their (possibly localized) + * display name or their position in the catalog. + */ +data class Instance( + /** Machine-readable identifier, e.g. "niepd". Falls back to a slugified [name]. */ + val key: String, + val name: String, + /** Localized display name; falls back to the English entry, then to [name]. */ + val instanceName: String, + val color: String, + val oAuthClientId: String, + val apiHostUrl: String, + val ssoUrl: String?, + val ssoFinishedUrl: String?, + val environmentDisplayName: String?, + val isSwitchInstanceLoginEnabled: Boolean, + /** Remote URL, or the name of a bundled drawable. `null` keeps the default logo. */ + val logoUrl: String?, + /** Remote URL, or the name of a bundled drawable. `null` keeps the default header. */ + val headerBackgroundUrl: String?, + /** `null` derives the palette from [color] instead. */ + val themeColors: InstanceThemeColors?, + /** Defaults to [instanceName] when unset, so it is never blank. */ + val platformName: String, + val isRoundedCorners: Boolean, + val buttonCornersRadius: Double, + /** + * The entry exactly as configured, in UPPER_SNAKE_CASE. Instance-level overrides of + * app-level keys (`AGREEMENT_URLS`, `DISCOVERY`, `UI_COMPONENTS`, ...) are read from here. + */ + val config: JsonObject, +) { + + companion object { + const val KEY = "KEY" + const val NAME = "NAME" + const val INSTANCE_NAME = "INSTANCE_NAME" + const val COLOR = "COLOR" + const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" + const val API_HOST_URL = "API_HOST_URL" + const val SSO_URL = "SSO_URL" + const val SSO_FINISHED_URL = "SSO_FINISHED_URL" + const val SSO_URL_SUCCESSFUL_LOGIN = "SSO_URL_SUCCESSFUL_LOGIN" + const val ENVIRONMENT_DISPLAY_NAME = "ENVIRONMENT_DISPLAY_NAME" + const val IS_SWITCH_INSTANCE_LOGIN_ENABLED = "IS_SWITCH_INSTANCE_LOGIN_ENABLED" + const val PLATFORM_NAME = "PLATFORM_NAME" + const val THEME = "THEME" + const val THEME_LIGHT = "LIGHT" + const val THEME_DARK = "DARK" + const val THEME_LOGO_URL = "LOGO_URL" + const val THEME_HEADER_BACKGROUND_URL = "HEADER_BACKGROUND_URL" + const val THEME_ROUNDED_CORNERS_STYLE = "ROUNDED_CORNERS_STYLE" + const val THEME_BUTTON_CORNERS_RADIUS = "BUTTON_CORNERS_RADIUS" + + private const val DEFAULT_COLOR = "#007AFF" + private const val DEFAULT_BUTTON_CORNERS_RADIUS = 8.0 + private const val DEFAULT_LANGUAGE = "en" + + /** + * Parses one `INSTANCES` entry. Returns `null` when a required field (`NAME`, + * `API_HOST_URL`, `OAUTH_CLIENT_ID`) is missing or `API_HOST_URL` isn't an absolute + * URL, so a malformed entry is dropped instead of producing a half-populated instance. + */ + @Suppress("ReturnCount") + fun fromJson( + json: JsonObject, + languageCode: String = Locale.getDefault().language, + ): Instance? { + val name = json.string(NAME)?.takeIf { it.isNotBlank() } ?: return null + val apiHostUrl = json.string(API_HOST_URL)?.takeIf { it.isAbsoluteUrl() } ?: return null + val oAuthClientId = json.string(OAUTH_CLIENT_ID)?.takeIf { it.isNotBlank() } ?: return null + + val key = json.string(KEY)?.takeIf { it.isNotBlank() } ?: slugify(name) + val localizedNames = json.stringMap(INSTANCE_NAME) + val instanceName = localizedNames[languageCode] + ?: localizedNames[DEFAULT_LANGUAGE] + ?: name + + // Logo and header live inside THEME, as siblings of LIGHT/DARK. The palette maps' + // own field names (accent_color, ...) stay lowercase: they are the theme engine's + // contract, shared with iOS, not part of this schema. + val theme = json.obj(THEME) + val themeColors = theme?.let { + InstanceThemeColors( + light = it.stringMap(THEME_LIGHT), + dark = it.stringMap(THEME_DARK), + ) + } + + return Instance( + key = key, + name = name, + instanceName = instanceName, + color = json.nonBlankString(COLOR) ?: DEFAULT_COLOR, + oAuthClientId = oAuthClientId, + apiHostUrl = apiHostUrl, + ssoUrl = json.nonBlankString(SSO_URL), + ssoFinishedUrl = json.nonBlankString(SSO_FINISHED_URL) + ?: json.nonBlankString(SSO_URL_SUCCESSFUL_LOGIN), + environmentDisplayName = json.nonBlankString(ENVIRONMENT_DISPLAY_NAME), + isSwitchInstanceLoginEnabled = json.boolean(IS_SWITCH_INSTANCE_LOGIN_ENABLED) ?: false, + logoUrl = theme?.nonBlankString(THEME_LOGO_URL), + headerBackgroundUrl = theme?.nonBlankString(THEME_HEADER_BACKGROUND_URL), + themeColors = themeColors, + platformName = json.nonBlankString(PLATFORM_NAME) ?: instanceName, + // Absent or non-boolean means rounded, same as the app-level theme default. + isRoundedCorners = theme?.boolean(THEME_ROUNDED_CORNERS_STYLE) != false, + buttonCornersRadius = theme?.double(THEME_BUTTON_CORNERS_RADIUS) + ?: DEFAULT_BUTTON_CORNERS_RADIUS, + config = json.deepCopy(), + ) + } + + internal fun slugify(name: String): String { + return Normalizer.normalize(name.lowercase(Locale.ROOT), Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") + .replace("[^a-z0-9]+".toRegex(), "-") + .trim('-') + } + + private fun String.isAbsoluteUrl(): Boolean { + return try { + val uri = URI(this) + !uri.scheme.isNullOrEmpty() && !uri.host.isNullOrEmpty() + } catch (_: Exception) { + false + } + } + } +} + +/** + * Per-mode color overrides from an instance's `THEME` block. Keys are the shared palette + * field names (`accent_color`, `text_primary`, ...); values are hex strings. + */ +data class InstanceThemeColors( + val light: Map, + val dark: Map, +) + +internal fun JsonObject.primitive(key: String): JsonElement? { + val element = get(key) ?: return null + return if (element.isJsonPrimitive) element else null +} + +internal fun JsonObject.string(key: String): String? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isString) element.asString else null +} + +internal fun JsonObject.nonBlankString(key: String): String? = string(key)?.takeIf { it.isNotBlank() } + +internal fun JsonObject.boolean(key: String): Boolean? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isBoolean) element.asBoolean else null +} + +internal fun JsonObject.double(key: String): Double? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isNumber) element.asDouble else null +} + +internal fun JsonObject.obj(key: String): JsonObject? { + val element = get(key) ?: return null + return if (element.isJsonObject) element.asJsonObject else null +} + +internal fun JsonObject.stringMap(key: String): Map { + val obj = obj(key) ?: return emptyMap() + return obj.entrySet() + .filter { (_, value) -> value.isJsonPrimitive && value.asJsonPrimitive.isString } + .associate { (entryKey, value) -> entryKey to value.asString } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceApiService.kt b/core/src/main/java/org/openedx/core/instance/InstanceApiService.kt new file mode 100644 index 000000000..a97f7a28a --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceApiService.kt @@ -0,0 +1,51 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit + +interface InstanceApiService { + /** The catalog's raw JSON. Throws when the request fails or no catalog URL is configured. */ + suspend fun fetchCatalog(): String +} + +class InstanceCatalogNotConfiguredException : IOException("INSTANCES_CATALOG_URL is not configured") + +class InstanceCatalogHttpException(val code: Int) : IOException("Catalog request failed with HTTP $code") + +/** + * Fetches the remote catalog with a plain client of its own. It is a fixed, anonymous endpoint, + * so it deliberately bypasses the app's API client and never carries credentials. + */ +class OkHttpInstanceApiService( + private val catalogUrl: String?, + private val client: OkHttpClient = defaultClient(), +) : InstanceApiService { + + override suspend fun fetchCatalog(): String = withContext(Dispatchers.IO) { + val url = catalogUrl?.takeIf { it.isNotBlank() } ?: throw InstanceCatalogNotConfiguredException() + val request = Request.Builder() + .url(url) + .cacheControl(CacheControl.FORCE_NETWORK) + .header("Accept", "application/json") + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw InstanceCatalogHttpException(response.code) + response.body?.string() ?: throw InstanceCatalogHttpException(response.code) + } + } + + companion object { + private const val TIMEOUT_SECONDS = 10L + + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .callTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt b/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt new file mode 100644 index 000000000..73a5f12f9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt @@ -0,0 +1,76 @@ +package org.openedx.core.instance + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import kotlinx.coroutines.CancellationException + +/** Keeps the last remote catalog that replaced the baseline. */ +interface InstanceCatalogCache { + var cachedCatalogJson: String? +} + +class SharedPrefsInstanceCatalogCache(context: Context) : InstanceCatalogCache { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + override var cachedCatalogJson: String? + get() = prefs.getString(KEY_CATALOG_JSON, null) + set(value) = prefs.edit { putString(KEY_CATALOG_JSON, value) } + + private companion object { + const val PREFS_NAME = "openedx_instance_catalog" + const val KEY_CATALOG_JSON = "cached_catalog_json" + } +} + +/** + * Decides which catalog the app runs with. + * + * The baseline is the last catalog fetched successfully, else the bundled one, else empty. + * Every launch tries a live fetch: a response with at least one instance replaces the baseline + * and becomes the new cache. A failed fetch, or one that returns no instances, leaves the + * baseline untouched, so a reachable but empty endpoint can never wipe a good cache, and an + * offline launch shows the catalog the learner saw last time. + */ +class InstanceConfigLoader( + private val apiService: InstanceApiService, + private val cache: InstanceCatalogCache, + private val bundledConfig: () -> InstancesConfig, + private val logger: (String) -> Unit = { Log.w(TAG, it) }, +) { + + suspend fun load(): InstancesConfig { + val baseline = loadCached() ?: bundledConfig().takeIf { it.instances.isNotEmpty() } ?: InstancesConfig() + return try { + val json = apiService.fetchCatalog() + val fetched = InstancesConfig.fromRemoteJson(json) + if (fetched.instances.isEmpty()) { + baseline + } else { + cache.cachedCatalogJson = json + fetched + } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + // No configured URL is the normal single-catalog setup, not a failure worth logging. + if (e !is InstanceCatalogNotConfiguredException) { + logger("Live catalog fetch failed (${e.message}), using the baseline") + } + baseline + } + } + + /** `null` if nothing was cached, or if the cached payload no longer parses. */ + private fun loadCached(): InstancesConfig? { + val json = cache.cachedCatalogJson ?: return null + return runCatching { InstancesConfig.fromRemoteJson(json) } + .getOrNull() + ?.takeIf { it.instances.isNotEmpty() } + } + + private companion object { + const val TAG = "InstanceConfigLoader" + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt b/core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt new file mode 100644 index 000000000..5bb4c1c31 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt @@ -0,0 +1,12 @@ +package org.openedx.core.instance + +import org.openedx.core.module.db.DownloadDao + +/** + * Per-instance database access for work that outlives a screen, such as the download worker: + * it handles each queued file in the database of the site that queued it, whichever site is + * selected by the time the file finishes. + */ +interface InstanceDatabases { + fun downloadDao(namespace: StorageNamespace): DownloadDao +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceProvider.kt b/core/src/main/java/org/openedx/core/instance/InstanceProvider.kt new file mode 100644 index 000000000..12b96dba6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceProvider.kt @@ -0,0 +1,17 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.flow.StateFlow + +/** + * Read-only, thread-safe access to the selected instance. + */ +interface InstanceProvider { + /** `null` when nothing is selected, e.g. a build with no `INSTANCES` catalog. */ + val currentInstance: Instance? + + /** Emits the selected instance on every change, including deselection. */ + val currentInstanceFlow: StateFlow + + /** Whether the catalog has more than one instance to switch between. */ + val hasMultipleInstances: Boolean +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt new file mode 100644 index 000000000..74f99c72c --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt @@ -0,0 +1,38 @@ +package org.openedx.core.instance + +import android.content.Context +import androidx.core.content.edit + +/** + * Where [InstanceStore] persists the key of the selected instance. + */ +interface InstanceSelectionStorage { + var selectedInstanceKey: String? +} + +class SharedPrefsInstanceSelectionStorage(context: Context) : + InstanceSelectionStorage, + LegacyNamespaceOwnerStorage { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Written synchronously: a selection lost to a process kill right after a switch would + // reopen the app on the previous site. + override var selectedInstanceKey: String? + get() = prefs.getString(KEY_SELECTED_INSTANCE, null) + set(value) = prefs.edit(commit = true) { + if (value == null) remove(KEY_SELECTED_INSTANCE) else putString(KEY_SELECTED_INSTANCE, value) + } + + override var legacyOwnerInstanceKey: String? + get() = prefs.getString(KEY_LEGACY_OWNER, null) + set(value) = prefs.edit(commit = true) { + if (value == null) remove(KEY_LEGACY_OWNER) else putString(KEY_LEGACY_OWNER, value) + } + + private companion object { + const val PREFS_NAME = "openedx_instance_store" + const val KEY_SELECTED_INSTANCE = "selected_instance_key" + const val KEY_LEGACY_OWNER = "legacy_storage_owner_instance_key" + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt new file mode 100644 index 000000000..6a91f5a25 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt @@ -0,0 +1,122 @@ +package org.openedx.core.instance + +import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.system.AppCookieManager +import org.openedx.core.ui.theme.InstanceTheme +import org.openedx.core.ui.theme.InstanceThemeManager + +/** Stops one site's backend from sending push notifications to this device. */ +interface PushTokenUnregistering { + /** Needs the site's session, so it runs before the session is cleared. */ + suspend fun unregisterPushToken(instanceKey: String?) +} + +interface InstanceSessionManager { + /** + * Makes [instance] the active site. Nothing is torn down: the outgoing site keeps its + * session, cached content and downloads for when the learner comes back. + */ + suspend fun switchActiveInstance(instance: Instance) + + /** + * Signs out of the site selected when it is called: cancels downloads, unregisters push, + * clears its tokens and user, then clears the selection unless the learner has switched to + * another site in the meantime. Cached courses and downloaded files stay, so signing back in + * to the same site is fast. + */ + suspend fun logoutCurrentInstance() + + /** + * Signs out of [instance]. The selected site goes through [logoutCurrentInstance]; any other + * site has push unregistered and loses its stored session, and the active one is untouched. + */ + suspend fun logout(instance: Instance) + + /** + * [logout] by key, for a sign-out that started on one site and must finish there even if + * the learner switched in between. `null` is the no-instance (single-site) session. + */ + suspend fun logout(instanceKey: String?) + + /** + * Records who signed in to [instanceKey]'s site. When it isn't the learner who used that site + * last, that learner's cached course data is cleared first. + */ + fun onSignedIn(instanceKey: String?, username: String) + + /** + * Applies the selected instance's branding, or the default theme when nothing is selected. + * Restoring a saved selection at launch doesn't go through [switchActiveInstance], so launch + * calls this once the catalog is resolved. + */ + fun applyThemeForCurrentInstance() +} + +class DefaultInstanceSessionManager( + private val instanceStore: InstanceStore, + private val preferences: CorePreferences, + private val downloadWorkerController: DownloadWorkerController, + private val databaseManager: DatabaseManager, + private val cookieManager: AppCookieManager, + private val pushTokenUnregistrar: PushTokenUnregistering? = null, +) : InstanceSessionManager { + + override suspend fun switchActiveInstance(instance: Instance) { + // Web content signs in with session cookies fetched for one site; the next page has to + // fetch the new site's (refetching replaces the old ones). + cookieManager.invalidateSessionCookie() + instanceStore.select(instance) + applyThemeForCurrentInstance() + } + + override suspend fun logoutCurrentInstance() { + signOut(instanceStore.currentInstance?.key, isSelected = true) + } + + override suspend fun logout(instance: Instance) = logout(instance.key) + + override suspend fun logout(instanceKey: String?) { + signOut(instanceKey, isSelected = instanceStore.currentInstance?.key == instanceKey) + } + + /** + * The site is fixed when the sign-out starts: unregistering push waits on the network, and the + * learner can switch sites meanwhile. Only that site's session is cleared, and the selection, + * web cookies and theme are reset only while it is still the selected site. + */ + private suspend fun signOut(instanceKey: String?, isSelected: Boolean) { + if (isSelected) downloadWorkerController.cancelUnfinishedDownloads() + // Before the tokens go: unregistering is an authenticated call. + pushTokenUnregistrar?.unregisterPushToken(instanceKey) + preferences.clearSession(instanceKey) + if (isSelected && instanceStore.currentInstance?.key == instanceKey) { + cookieManager.clearWebViewCookie() + // Last, so nothing observes a half signed-out site. + instanceStore.select(null) + applyThemeForCurrentInstance() + } + } + + override fun onSignedIn(instanceKey: String?, username: String) { + val previous = preferences.lastSignedInUsername(instanceKey) + if (previous.isNotEmpty() && previous != username) { + databaseManager.clearTables(instanceKey) + } + preferences.setLastSignedInUsername(instanceKey, username) + } + + override fun applyThemeForCurrentInstance() { + InstanceThemeManager.applyTheme(instanceStore.currentInstance?.toTheme()) + } +} + +fun Instance.toTheme() = InstanceTheme( + key = key, + accentColorHex = color, + light = themeColors?.light.orEmpty(), + dark = themeColors?.dark.orEmpty(), + logoUrl = logoUrl, + headerBackgroundUrl = headerBackgroundUrl, +) diff --git a/core/src/main/java/org/openedx/core/instance/InstanceStore.kt b/core/src/main/java/org/openedx/core/instance/InstanceStore.kt new file mode 100644 index 000000000..f2a2b8c2e --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceStore.kt @@ -0,0 +1,74 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Single source of truth for the instance catalog and the selected instance. + * + * The selection is persisted by key, so it survives restarts and catalog reordering. A + * catalog with exactly one instance selects it automatically: there is no picker to choose + * it through. + */ +class InstanceStore( + private val storage: InstanceSelectionStorage, + private val localInstancesConfig: () -> InstancesConfig = { InstancesConfig() }, +) : InstanceProvider { + + private val lock = Any() + private var remoteInstancesConfig: InstancesConfig? = null + private val _currentInstance = MutableStateFlow(null) + + override val currentInstance: Instance? + get() = _currentInstance.value + + override val currentInstanceFlow: StateFlow = _currentInstance.asStateFlow() + + override val hasMultipleInstances: Boolean + get() = instancesConfig.instances.size > 1 + + /** The catalog in effect: the one handed to [updateInstancesConfig], else the bundled one. */ + val instancesConfig: InstancesConfig + get() = synchronized(lock) { remoteInstancesConfig ?: localInstancesConfig() } + + init { + restoreSelection() + } + + fun instance(withKey: String): Instance? = instancesConfig.instance(withKey) + + /** Replaces the catalog in effect and re-resolves the persisted selection against it. */ + fun updateInstancesConfig(instancesConfig: InstancesConfig) { + synchronized(lock) { remoteInstancesConfig = instancesConfig } + restoreSelection() + } + + /** Selects [instance], or clears the selection with `null`, and persists the choice. */ + fun select(instance: Instance?) { + synchronized(lock) { + storage.selectedInstanceKey = instance?.key + _currentInstance.value = instance + } + // A deselect on a single-instance catalog must not leave nothing selected until the + // next launch. + if (instance == null) { + restoreSelection() + } + } + + fun reset() = select(null) + + private fun restoreSelection() { + val catalog = instancesConfig + val resolved = storage.selectedInstanceKey?.let { catalog.instance(it) } + val onlyInstance = catalog.instances.singleOrNull() + if (resolved == null && onlyInstance != null) { + select(onlyInstance) + return + } + // A saved key that the catalog no longer contains resolves to nothing, but stays saved: + // if a later catalog brings that site back, it is selected again. + _currentInstance.value = resolved + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt b/core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt new file mode 100644 index 000000000..5a0f4870f --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt @@ -0,0 +1,8 @@ +package org.openedx.core.instance + +/** + * The learner selected another site while an operation that belongs to one site was waiting on + * the network. Its results were kept away from the newly selected site. + */ +class InstanceSwitchedException(val instanceKey: String?) : + IllegalStateException("The selected site changed during an operation for $instanceKey") diff --git a/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt b/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt new file mode 100644 index 000000000..f96879b10 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt @@ -0,0 +1,51 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import java.util.Locale + +/** + * The parsed instance catalog: every platform the app can sign in to, in catalog order. + */ +data class InstancesConfig( + val instances: List = emptyList(), +) { + + fun instance(withKey: String): Instance? = instances.firstOrNull { it.key == withKey } + + companion object { + const val INSTANCES = "INSTANCES" + + /** + * Builds the catalog from an `INSTANCES` array. Entries that aren't objects or fail + * [Instance.fromJson] are dropped. + * + * @param fallback values used for the fields an entry omits; the entry always wins. + */ + fun fromJson( + array: JsonArray, + fallback: JsonObject = JsonObject(), + languageCode: String = Locale.getDefault().language, + ): InstancesConfig { + val instances = array.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val merged = fallback.deepCopy() + element.asJsonObject.entrySet().forEach { (key, value) -> merged.add(key, value) } + Instance.fromJson(merged, languageCode) + } + return InstancesConfig(instances) + } + + /** The `INSTANCES` array of the bundled `config.json`, or an empty catalog. */ + fun fromAppConfig( + appConfig: JsonObject, + languageCode: String = Locale.getDefault().language, + ): InstancesConfig { + val array = appConfig.get(INSTANCES) + ?.takeIf { it.isJsonArray } + ?.asJsonArray + ?: return InstancesConfig() + return fromJson(array, languageCode = languageCode) + } + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt b/core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt new file mode 100644 index 000000000..5b925856c --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt @@ -0,0 +1,4 @@ +package org.openedx.core.instance + +/** The payload is neither a bare instance array nor an object wrapping one. */ +class InstancesJsonException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt b/core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt new file mode 100644 index 000000000..b03cc1ae5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt @@ -0,0 +1,77 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.util.Locale + +/** + * Remote catalogs are sent in snake_case. These are the keys renamed to the bundled schema's + * UPPER_SNAKE_CASE before parsing; every other key passes through unchanged, so a remote field + * outside this map has to be sent in UPPER_SNAKE_CASE already. Shared with iOS. + */ +internal val remoteToConfigKeyMap: Map = mapOf( + "key" to "KEY", + "name" to "NAME", + "color" to "COLOR", + "instance_name" to "INSTANCE_NAME", + "oauth_client_id" to "OAUTH_CLIENT_ID", + "api_host_url" to "API_HOST_URL", + "sso_url" to "SSO_URL", + "sso_url_successful_login" to "SSO_URL_SUCCESSFUL_LOGIN", + "environment_display_name" to "ENVIRONMENT_DISPLAY_NAME", + "is_switch_instance_login_enabled" to "IS_SWITCH_INSTANCE_LOGIN_ENABLED", + "ui_components" to "UI_COMPONENTS", + "course_dropdown_navigation_enabled" to "COURSE_DROPDOWN_NAVIGATION_ENABLED", + "course_unit_progress_enabled" to "COURSE_UNIT_PROGRESS_ENABLED", + "login_registration_enabled" to "LOGIN_REGISTRATION_ENABLED", + "saml_sso_login_enabled" to "SAML_SSO_LOGIN_ENABLED", + "saml_sso_default_login_button" to "SAML_SSO_DEFAULT_LOGIN_BUTTON", + "theme" to "THEME", + "light" to "LIGHT", + "dark" to "DARK", + "logo_url" to "LOGO_URL", + "header_background_url" to "HEADER_BACKGROUND_URL", +) + +/** Renames mapped keys at every nesting depth; unmapped keys and all values stay as they are. */ +internal fun JsonObject.normalizedRemoteKeys(): JsonObject { + val result = JsonObject() + entrySet().forEach { (key, value) -> + val mappedKey = remoteToConfigKeyMap[key.lowercase(Locale.ROOT)] ?: key + result.add(mappedKey, if (value.isJsonObject) value.asJsonObject.normalizedRemoteKeys() else value) + } + return result +} + +/** + * Parses a remote catalog: a bare array, or an object with the array under `instances` or + * `INSTANCES`. Keys are normalized, then entries go through the same rules as the bundled + * catalog. + */ +fun InstancesConfig.Companion.fromRemoteJson( + json: String, + languageCode: String = Locale.getDefault().language, +): InstancesConfig { + val root: JsonElement = try { + JsonParser.parseString(json) + } catch (e: JsonParseException) { + throw InstancesJsonException("Catalog is not valid JSON", e) + } + val array = root.instancesArray() ?: throw InstancesJsonException("Catalog has no instances array") + val normalized = JsonArray().apply { + array.forEach { add(if (it.isJsonObject) it.asJsonObject.normalizedRemoteKeys() else it) } + } + return fromJson(normalized, languageCode = languageCode) +} + +private fun JsonElement.instancesArray(): JsonArray? = when { + isJsonArray -> asJsonArray + isJsonObject -> (asJsonObject.get(InstancesConfig.INSTANCES) ?: asJsonObject.get("instances")) + ?.takeIf { it.isJsonArray } + ?.asJsonArray + + else -> null +} diff --git a/core/src/main/java/org/openedx/core/instance/RequestInstance.kt b/core/src/main/java/org/openedx/core/instance/RequestInstance.kt new file mode 100644 index 000000000..70288e2fd --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/RequestInstance.kt @@ -0,0 +1,12 @@ +package org.openedx.core.instance + +/** + * Which instance a request belongs to, as an OkHttp tag. + * + * Requests built through the app's API client are tagged with the selected instance when they + * start. A caller that must stay on one site across suspensions (sign-in, sign-out, push + * registration) passes the tag itself through a Retrofit `@Tag` parameter: the request then goes + * to that site's host with that site's token, whichever site is selected by the time it is sent. + * A `null` key means no instance: the app-level host and the legacy session. + */ +data class RequestInstance(val instanceKey: String?) diff --git a/core/src/main/java/org/openedx/core/instance/StorageNamespace.kt b/core/src/main/java/org/openedx/core/instance/StorageNamespace.kt new file mode 100644 index 000000000..f1d824e0b --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/StorageNamespace.kt @@ -0,0 +1,125 @@ +package org.openedx.core.instance + +import java.net.URI +import java.security.MessageDigest +import java.util.Locale + +/** + * Where one instance's local data lives: preference keys, the database file and the + * downloads folder. + * + * The legacy namespace ([instanceKey] `null`) is the storage a single-site build has always + * used, unprefixed. It belongs to "no instance selected" and to the one instance that is the + * same site as the app-level configuration, so upgrading keeps a signed-in learner's session, + * cached courses and downloads in place. + */ +data class StorageNamespace(val instanceKey: String?) { + + val isLegacy: Boolean get() = instanceKey == null + + /** e.g. `access_token` for the legacy namespace, `instance.acme.access_token` otherwise. */ + fun key(rawKey: String): String = if (instanceKey == null) rawKey else "$PREFIX$instanceKey.$rawKey" + + /** + * A file-system-safe, collision-resistant name segment, or `null` for the legacy namespace. + * The readable part is lossy; the hash keeps two keys that sanitize alike apart. + */ + val fileSegment: String? + get() = instanceKey?.let { key -> + val readable = key.replace(UNSAFE_FILE_CHARS, "_").take(MAX_READABLE_LENGTH) + "${readable}_${sha256(key).take(HASH_LENGTH)}" + } + + companion object { + val LEGACY = StorageNamespace(null) + + private const val PREFIX = "instance." + private const val MAX_READABLE_LENGTH = 32 + private const val HASH_LENGTH = 12 + private val UNSAFE_FILE_CHARS = "[^A-Za-z0-9_-]".toRegex() + + private fun sha256(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } + } +} + +/** Resolves instances to the namespace their local data lives under. */ +interface StorageNamespaceProvider { + /** The selected instance's namespace; legacy when nothing is selected. */ + val current: StorageNamespace + + fun namespaceFor(instance: Instance?): StorageNamespace + + /** Resolves by key through the catalog; a key no longer in the catalog keeps its own namespace. */ + fun namespaceFor(instanceKey: String?): StorageNamespace +} + +/** Persists which instance owns the legacy namespace once that has been decided. */ +interface LegacyNamespaceOwnerStorage { + var legacyOwnerInstanceKey: String? +} + +/** + * The instance whose API host and OAuth client are the app-level ones is the site a + * single-site build of this app was signing in to, so it inherits the legacy namespace. That + * decision is recorded the first time it is made and never revisited: a later catalog or + * configuration change can't hand existing data to a different site. + */ +class InstanceStorageNamespaces( + private val instanceStore: InstanceStore, + private val ownerStorage: LegacyNamespaceOwnerStorage, + private val appApiHostUrl: String, + private val appOAuthClientId: String, +) : StorageNamespaceProvider { + + private val lock = Any() + + override val current: StorageNamespace + get() = namespaceFor(instanceStore.currentInstance) + + override fun namespaceFor(instance: Instance?): StorageNamespace { + if (instance == null) return StorageNamespace.LEGACY + val owner = synchronized(lock) { + ownerStorage.legacyOwnerInstanceKey ?: instance.key + .takeIf { isAppLevelSite(instance) } + ?.also { ownerStorage.legacyOwnerInstanceKey = it } + } + return if (owner == instance.key) StorageNamespace.LEGACY else StorageNamespace(instance.key) + } + + override fun namespaceFor(instanceKey: String?): StorageNamespace { + val instance = instanceKey?.let { instanceStore.instance(it) } + val owner = synchronized(lock) { ownerStorage.legacyOwnerInstanceKey } + return when { + instanceKey == null -> StorageNamespace.LEGACY + instance != null -> namespaceFor(instance) + owner == instanceKey -> StorageNamespace.LEGACY + else -> StorageNamespace(instanceKey) + } + } + + private fun isAppLevelSite(instance: Instance): Boolean { + return instance.oAuthClientId == appOAuthClientId && + normalizedOrigin(instance.apiHostUrl) != null && + normalizedOrigin(instance.apiHostUrl) == normalizedOrigin(appApiHostUrl) + } + + private fun normalizedOrigin(url: String): String? = runCatching { + val uri = URI(url.trim()) + val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return null + val host = uri.host?.lowercase(Locale.ROOT) ?: return null + val port = when { + uri.port != -1 -> uri.port + scheme == "https" -> HTTPS_PORT + else -> HTTP_PORT + } + "$scheme://$host:$port${uri.path.orEmpty().trimEnd('/')}" + }.getOrNull() + + private companion object { + const val HTTPS_PORT = 443 + const val HTTP_PORT = 80 + } +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index f91a19c6a..db90a51c0 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -8,13 +8,18 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker +import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import androidx.work.workDataOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import org.openedx.core.R +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity @@ -30,6 +35,7 @@ import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged import org.openedx.foundation.utils.FileUtil +import java.io.File class DownloadWorker( val context: Context, @@ -41,7 +47,8 @@ class DownloadWorker( private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) - private val downloadDao: DownloadDao by inject(DownloadDao::class.java) + private val databases: InstanceDatabases by inject(InstanceDatabases::class.java) + private val namespaces: StorageNamespaceProvider by inject(StorageNamespaceProvider::class.java) private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private val analytics: DownloadsAnalytics by inject(DownloadsAnalytics::class.java) @@ -52,6 +59,13 @@ class DownloadWorker( private val folder = fileUtil.getExternalAppDir() private var currentDownload: DownloadModel? = null + + /** The site whose queue is being worked through, starting with the one that enqueued us. */ + private var namespace = StorageNamespace(inputData.getString(KEY_INSTANCE_KEY)) + private val downloadDao: DownloadDao + get() = databases.downloadDao(namespace) + private val isForSelectedSite: Boolean + get() = namespace == namespaces.current private var lastUpdateTime = 0L private val fileDownloader by inject(FileDownloader::class.java) @@ -99,8 +113,11 @@ class DownloadWorker( lastUpdateTime = System.currentTimeMillis() currentDownload?.let { - launch { - notifier.send(DownloadProgressChanged(it.id, value, size)) + // Another site can use the same block IDs; only its own screens may see this. + if (isForSelectedSite) { + launch { + notifier.send(DownloadProgressChanged(it.id, value, size)) + } } notificationManager.notify( @@ -140,6 +157,7 @@ class DownloadWorker( ) ) logEvent(DownloadsAnalyticsEvent.DOWNLOAD_STARTED) + File(downloadTask.path).parentFile?.mkdirs() val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) when (downloadResult) { DownloadResult.SUCCESS -> { @@ -168,10 +186,16 @@ class DownloadWorker( } newDownload() } else { - if (downloadError.isNotEmpty()) { + if (downloadError.isNotEmpty() && isForSelectedSite) { notifier.send(DownloadFailed(downloadError)) } - return + downloadError.clear() + // Files queued on the site the learner switched to while this queue was running + // would otherwise wait for the next change to their queue. + if (!isForSelectedSite) { + namespace = namespaces.current + newDownload() + } } } @@ -193,6 +217,9 @@ class DownloadWorker( companion object { const val WORKER_TAG = "downloadWorker" + private const val KEY_INSTANCE_KEY = "instance_key" + + fun inputData(namespace: StorageNamespace): Data = workDataOf(KEY_INSTANCE_KEY to namespace.instanceKey) private const val CHANNEL_ID = "download_channel_ID" private const val CHANNEL_NAME = "download_channel_name" diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index 39612ae10..54a734700 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -7,6 +7,7 @@ import androidx.work.WorkManager import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity @@ -17,7 +18,8 @@ import java.io.File class DownloadWorkerController( context: Context, private val downloadDao: DownloadDao, - private val fileDownloader: FileDownloader + private val fileDownloader: FileDownloader, + private val namespaces: StorageNamespaceProvider, ) { private val workManager = WorkManager.getInstance(context) @@ -34,6 +36,7 @@ class DownloadWorkerController( if (!isWorkScheduled(DownloadWorker.WORKER_TAG)) { val request = OneTimeWorkRequestBuilder() .addTag(DownloadWorker.WORKER_TAG) + .setInputData(DownloadWorker.inputData(namespaces.current)) .build() workManager.enqueue(request) } @@ -81,11 +84,27 @@ class DownloadWorkerController( updateList() - if (downloadTaskList.isEmpty()) { + // Only when the worker was on one of these: it may be busy with another site's queue, + // which this site has no say over. + if (hasDownloading && downloadTaskList.isEmpty()) { workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) } } + /** + * Cancels every download the selected site hasn't finished: the active one and the queue. + * Finished files stay. + */ + suspend fun cancelUnfinishedDownloads() { + val unfinished = downloadDao.readAllData() + .map { it.mapToDomain() } + .filter { + it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING + } + .map { it.id } + if (unfinished.isNotEmpty()) removeModels(unfinished) + } + suspend fun removeModels() { fileDownloader.cancelDownloading() workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt index 327d4814e..32981df9a 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -2,6 +2,7 @@ package org.openedx.core.module.download import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -13,13 +14,27 @@ import java.io.File class DownloadHelper( private val preferencesManager: CorePreferences, private val fileUtil: FileUtil, + private val namespaces: StorageNamespaceProvider, ) { + /** + * Files are named after their URL, and two sites can serve the same URL (a shared CDN, the + * same video). Each instance therefore downloads into a folder of its own; the legacy + * namespace keeps using [baseFolder] itself, where existing downloads already are. + */ + internal fun instanceFolder(baseFolder: String): String { + val segment = namespaces.current.fileSegment ?: return baseFolder + val folder = File(File(baseFolder, INSTANCES_FOLDER), segment) + folder.mkdirs() + return folder.path + } + fun generateDownloadModelFromBlock( - folder: String, + baseFolder: String, block: Block, courseId: String ): DownloadModel? { + val folder = instanceFolder(baseFolder) return when (val downloadableType = block.downloadableType) { FileType.VIDEO -> { val videoInfo = @@ -92,6 +107,10 @@ class DownloadHelper( } } + private companion object { + const val INSTANCES_FOLDER = "instances" + } + private fun calculateDirectorySize(directory: File): Long { if (!directory.exists()) return 0 diff --git a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt index 7df19c627..6f24c701a 100644 --- a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt +++ b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt @@ -36,6 +36,14 @@ class AppCookieManager(private val config: Config, private val api: CookiesApi) authSessionCookieExpiration = -1 } + /** + * Forgets that the session cookies are fresh, so the next web page fetches them again, for + * the site selected then. Cheap enough for the main thread: it doesn't touch the WebView. + */ + fun invalidateSessionCookie() { + authSessionCookieExpiration = -1 + } + fun isSessionCookieMissingOrExpired(): Boolean { return authSessionCookieExpiration < System.currentTimeMillis() } diff --git a/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt b/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt new file mode 100644 index 000000000..c9bb67550 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt @@ -0,0 +1,124 @@ +package org.openedx.core.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Branding of one instance: an accent color plus optional per-field palette overrides for + * light and dark mode, and an optional logo and header image. + * + * Takes primitives rather than the instance model, so the theme layer stays independent of + * how instances are configured. + */ +data class InstanceTheme( + /** Stable instance key. Never a display name, which can be renamed or localized. */ + val key: String, + val accentColorHex: String? = null, + val light: Map = emptyMap(), + val dark: Map = emptyMap(), + /** Remote URL, or the name of a bundled drawable. */ + val logoUrl: String? = null, + /** Remote URL, or the name of a bundled drawable. */ + val headerBackgroundUrl: String? = null, +) { + + /** + * The default palette with this instance's colors layered on top. Only the fields the + * instance sets change; everything else keeps the compiled value. A dark override falls + * back to the light one for the same field. + */ + fun applyTo(base: AppColors, isDark: Boolean): AppColors { + val modeAccent = if (isDark) dark[ACCENT_COLOR] ?: light[ACCENT_COLOR] else light[ACCENT_COLOR] + var colors = (modeAccent ?: accentColorHex)?.toColorOrNull()?.let(base::withAccent) ?: base + light.forEach { (field, lightHex) -> + if (field == ACCENT_COLOR) return@forEach + val hex = if (isDark) dark[field] ?: lightHex else lightHex + val color = hex.toColorOrNull() ?: return@forEach + colors = paletteFields[field]?.invoke(colors, color) ?: colors + } + return colors + } + + companion object { + const val ACCENT_COLOR = "accent_color" + + /** + * Palette field names shared with the iOS app, mapped to the colors they override here. + * Unknown names are ignored rather than treated as errors. + */ + internal val paletteFields: Map AppColors> = mapOf( + "accent_x_color" to { c, v -> c.copy(material3 = c.material3.copy(secondary = v)) }, + "alert" to { c, v -> c.copy(material3 = c.material3.copy(error = v)) }, + "background" to { c, v -> c.copy(material3 = c.material3.copy(background = v)) }, + "card_view_background" to { c, v -> c.copy(cardViewBackground = v) }, + "card_view_stroke" to { c, v -> c.copy(cardViewBorder = v) }, + "certificate_foreground" to { c, v -> c.copy(certificateForeground = v) }, + "text_primary" to { c, v -> c.copy(textPrimary = v) }, + "text_secondary" to { c, v -> c.copy(textSecondary = v) }, + "text_input_background" to { c, v -> c.copy(textFieldBackground = v) }, + "text_input_stroke" to { c, v -> c.copy(textFieldBorder = v) }, + "text_input_text_color" to { c, v -> c.copy(textFieldText = v) }, + "text_input_placeholder_color" to { c, v -> c.copy(textFieldHint = v) }, + "warning" to { c, v -> c.copy(warning = v) }, + "success" to { c, v -> c.copy(successGreen = v) }, + "info_color" to { c, v -> c.copy(info = v) }, + "primary_button_text_color" to { c, v -> c.copy(primaryButtonText = v) }, + "secondary_button_border_color" to { c, v -> c.copy(secondaryButtonBorder = v) }, + "secondary_button_text_color" to { c, v -> c.copy(secondaryButtonBorderedText = v) }, + "past_due_timeline_color" to { c, v -> c.copy(datesSectionBarPastDue = v) }, + "today_timeline_color" to { c, v -> c.copy(datesSectionBarToday = v) }, + "this_week_timeline_color" to { c, v -> c.copy(datesSectionBarThisWeek = v) }, + "next_week_timeline_color" to { c, v -> c.copy(datesSectionBarNextWeek = v) }, + "upcoming_timeline_color" to { c, v -> c.copy(datesSectionBarUpcoming = v) }, + ) + } +} + +/** + * The accent drives every color the default palette derives from `primary`: links, primary + * buttons, bordered button text and the unselected tab content. + */ +internal fun AppColors.withAccent(accent: Color): AppColors = copy( + material3 = material3.copy(primary = accent), + textAccent = accent, + textHyperLink = accent, + infoVariant = accent, + primaryButtonBackground = accent, + primaryButtonBorderedText = accent, + secondaryButtonBackground = accent, + secondaryButtonBorderedText = accent, + tabUnselectedBtnContent = accent, +) + +/** Parses `#RGB`, `#RRGGBB` or `#AARRGGBB`. */ +@Suppress("MagicNumber") +internal fun String.toColorOrNull(): Color? { + val hex = trim().removePrefix("#") + val argb = when (hex.length) { + 3 -> "FF" + hex.map { "$it$it" }.joinToString("") + 6 -> "FF$hex" + 8 -> hex + else -> null + } + return argb + ?.takeIf { value -> value.all { Character.digit(it, 16) >= 0 } } + ?.let { Color(it.toLong(16)) } +} + +/** + * Holds the theme of the selected instance; `null` means the compiled default theme. + * [OpenEdXTheme] observes it, so applying a theme restyles every screen. + */ +object InstanceThemeManager { + + private val _theme = MutableStateFlow(null) + val theme: StateFlow = _theme.asStateFlow() + + fun applyTheme(theme: InstanceTheme?) { + _theme.value = theme + } + + fun reset() = applyTheme(null) +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index ec7997c72..bdeb480f6 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -9,13 +9,16 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf internal val LocalAppColors = staticCompositionLocalOf { error("No AppColors provided") } -private val DarkColorPalette = AppColors( +internal val DarkColorPalette = AppColors( material3 = darkColorScheme( primary = dark_primary, onPrimary = dark_onPrimary, @@ -115,7 +118,7 @@ private val DarkColorPalette = AppColors( assignmentCardBorder = dark_assignment_card_border, ) -private val LightColorPalette = AppColors( +internal val LightColorPalette = AppColors( material3 = lightColorScheme( primary = light_primary, onPrimary = light_onPrimary, @@ -223,10 +226,10 @@ val MaterialTheme.appColors: AppColors @OptIn(ExperimentalFoundationApi::class) @Composable fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { - DarkColorPalette - } else { - LightColorPalette + val instanceTheme by InstanceThemeManager.theme.collectAsState() + val colors = remember(darkTheme, instanceTheme) { + val base = if (darkTheme) DarkColorPalette else LightColorPalette + instanceTheme?.applyTo(base, isDark = darkTheme) ?: base } MaterialTheme( diff --git a/core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt new file mode 100644 index 000000000..6a0d88579 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt @@ -0,0 +1,256 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.IOException + +class InstanceConfigLoaderTest { + + private fun resource(name: String): String { + val stream = requireNotNull(javaClass.classLoader?.getResourceAsStream("instances/$name")) + return stream.reader().use { it.readText() } + } + + private class FakeApi(private val result: () -> String) : InstanceApiService { + var calls = 0 + override suspend fun fetchCatalog(): String { + calls++ + return result() + } + } + + private class InMemoryCache(override var cachedCatalogJson: String? = null) : InstanceCatalogCache + + private val bundled = InstancesConfig.fromAppConfig( + com.google.gson.JsonParser.parseString( + """{"INSTANCES": [{"KEY": "bundled", "NAME": "Bundled", "OAUTH_CLIENT_ID": "c", + "API_HOST_URL": "https://bundled.example.com"}]}""" + ).asJsonObject + ) + + private val onlineCatalog = """[{"key": "online", "name": "Online", "oauth_client_id": "c", + "api_host_url": "https://online.example.com"}]""" + + private fun loader(api: InstanceApiService, cache: InstanceCatalogCache, bundledConfig: InstancesConfig = bundled) = + InstanceConfigLoader(api, cache, { bundledConfig }, logger = {}) + + // region Remote JSON + + @Test + fun `remote snake_case keys are normalized at every depth`() { + val catalog = InstancesConfig.fromRemoteJson(resource("remote_catalog.json"), languageCode = "en") + val instance = requireNotNull(catalog.instance("niepd")) + + assertEquals("NIEPD", instance.name) + assertEquals("National Institute", instance.instanceName) + assertEquals("#1C355E", instance.color) + assertEquals("niepd-client", instance.oAuthClientId) + assertEquals("https://lms.niepd.example.com", instance.apiHostUrl) + assertEquals("https://lms.niepd.example.com/auth/complete/tpa-saml", instance.ssoFinishedUrl) + assertTrue(instance.isSwitchInstanceLoginEnabled) + assertEquals("https://cdn.example.com/niepd/logo.png", instance.logoUrl) + assertEquals("https://cdn.example.com/niepd/header.png", instance.headerBackgroundUrl) + val uiComponents = instance.config.getAsJsonObject("UI_COMPONENTS") + assertTrue(uiComponents.get("COURSE_UNIT_PROGRESS_ENABLED").asBoolean) + assertFalse(uiComponents.get("LOGIN_REGISTRATION_ENABLED").asBoolean) + } + + @Test + fun `palette field names inside THEME are left as they are`() { + val instance = InstancesConfig.fromRemoteJson(resource("remote_catalog.json")).instances.single() + + assertEquals(mapOf("accent_color" to "#3C68FF", "text_primary" to "#18202E"), instance.themeColors?.light) + assertEquals(mapOf("accent_color" to "#1C355E"), instance.themeColors?.dark) + } + + @Test + fun `unmapped remote keys pass through unchanged`() { + val instance = InstancesConfig.fromRemoteJson(resource("remote_catalog.json")).instances.single() + + assertEquals("help@niepd.example.com", instance.config.get("FEEDBACK_EMAIL_ADDRESS").asString) + assertEquals( + "ignored-because-unmapped@example.com", + instance.config.get("feedback_email_address").asString + ) + } + + @Test + fun `bare and wrapped arrays are both accepted`() { + val entry = """{"key": "a", "name": "A", "oauth_client_id": "c", "api_host_url": "https://a.example.com"}""" + + listOf("[$entry]", """{"instances": [$entry]}""", """{"INSTANCES": [$entry]}""").forEach { json -> + assertEquals(listOf("a"), InstancesConfig.fromRemoteJson(json).instances.map { it.key }) + } + } + + @Test + fun `unrecognized shapes are rejected`() { + listOf("""{"sites": []}""", "\"text\"", "{not json").forEach { json -> + try { + InstancesConfig.fromRemoteJson(json) + fail("$json should be rejected") + } catch (_: InstancesJsonException) { + // expected + } + } + } + + @Test + fun `remote entries follow the same required field rules`() { + val catalog = InstancesConfig.fromRemoteJson(resource("remote_catalog.json")) + + assertNull(catalog.instance("broken")) + } + + // endregion + + // region Loader + + @Test + fun `non empty live catalog replaces the baseline and is cached`() = runBlocking { + val cache = InMemoryCache() + + val result = loader(FakeApi { onlineCatalog }, cache).load() + + assertEquals(listOf("online"), result.instances.map { it.key }) + assertEquals(onlineCatalog, cache.cachedCatalogJson) + } + + @Test + fun `failed fetch falls back to the cache before the bundled catalog`() = runBlocking { + val cache = InMemoryCache(onlineCatalog) + + val result = loader(FakeApi { throw IOException("offline") }, cache).load() + + assertEquals(listOf("online"), result.instances.map { it.key }) + } + + @Test + fun `failed fetch without a cache uses the bundled catalog`() = runBlocking { + val result = loader(FakeApi { throw IOException("timeout") }, InMemoryCache()).load() + + assertEquals(listOf("bundled"), result.instances.map { it.key }) + } + + @Test + fun `failed fetch with nothing cached or bundled gives an empty catalog`() = runBlocking { + val result = loader(FakeApi { throw IOException("offline") }, InMemoryCache(), InstancesConfig()).load() + + assertTrue(result.instances.isEmpty()) + } + + @Test + fun `reachable but empty catalog never wipes a good cache`() = runBlocking { + val cache = InMemoryCache(onlineCatalog) + + listOf("[]", """{"instances": []}""", """[{"key": "x"}]""").forEach { empty -> + val result = loader(FakeApi { empty }, cache).load() + assertEquals(listOf("online"), result.instances.map { it.key }) + } + assertEquals(onlineCatalog, cache.cachedCatalogJson) + } + + @Test + fun `malformed response keeps the baseline`() = runBlocking { + val cache = InMemoryCache(onlineCatalog) + + val result = loader(FakeApi { "502" }, cache).load() + + assertEquals(listOf("online"), result.instances.map { it.key }) + assertEquals(onlineCatalog, cache.cachedCatalogJson) + } + + @Test + fun `unparseable cache is ignored`() = runBlocking { + val result = loader(FakeApi { throw IOException("offline") }, InMemoryCache("{broken")).load() + + assertEquals(listOf("bundled"), result.instances.map { it.key }) + } + + @Test + fun `no configured URL uses the baseline without a network call`() = runBlocking { + val service = OkHttpInstanceApiService( + catalogUrl = "", + client = OkHttpClient.Builder() + .addInterceptor { throw AssertionError("no request expected") } + .build() + ) + + val result = loader(service, InMemoryCache()).load() + + assertEquals(listOf("bundled"), result.instances.map { it.key }) + } + + // endregion + + // region Service + + private fun capturingClient(code: Int, body: String, captured: MutableList) = OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + captured += chain.request() + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("status $code") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + } + ) + .build() + + @Test + fun `catalog request is anonymous and bypasses caches`() = runBlocking { + val captured = mutableListOf() + val service = OkHttpInstanceApiService( + catalogUrl = "https://catalog.example.com/instances.json", + client = capturingClient(200, onlineCatalog, captured), + ) + + assertEquals(onlineCatalog, service.fetchCatalog()) + val request = captured.single() + assertEquals("https://catalog.example.com/instances.json", request.url.toString()) + assertNull(request.header("Authorization")) + assertNull(request.header("Cookie")) + assertTrue(request.cacheControl.noCache) + } + + @Test + fun `non 2xx responses are errors`() = runBlocking { + val service = OkHttpInstanceApiService( + catalogUrl = "https://catalog.example.com/instances.json", + client = capturingClient(503, "{}", mutableListOf()), + ) + + try { + service.fetchCatalog() + fail("503 must throw") + } catch (e: InstanceCatalogHttpException) { + assertEquals(503, e.code) + } + } + + @Test + fun `default client times out after ten seconds`() { + val client = OkHttpInstanceApiService.defaultClient() + + assertEquals(10_000, client.callTimeoutMillis) + assertTrue(client.interceptors.isEmpty()) + assertTrue(client.networkInterceptors.isEmpty()) + } + + // endregion +} diff --git a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt new file mode 100644 index 000000000..a458a7af1 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt @@ -0,0 +1,228 @@ +package org.openedx.core.instance + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.system.AppCookieManager +import org.openedx.core.ui.theme.InstanceThemeManager + +class InstanceSessionManagerTest { + + private class InMemorySelection(override var selectedInstanceKey: String? = null) : InstanceSelectionStorage + + private fun instance(key: String, color: String = "#123456") = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("COLOR", color) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = instance("alpha", "#3C68FF") + private val beta = instance("beta", "#1C355E") + private val store = InstanceStore(InMemorySelection()) { InstancesConfig(listOf(alpha, beta)) } + private val preferences = mockk(relaxed = true) + private val downloads = mockk(relaxed = true) + private val databaseManager = mockk(relaxed = true) + private val push = mockk(relaxed = true) + private val cookies = mockk(relaxed = true) + + private fun manager(pushTokenUnregistrar: PushTokenUnregistering? = push) = + DefaultInstanceSessionManager(store, preferences, downloads, databaseManager, cookies, pushTokenUnregistrar) + + @After + fun tearDown() { + InstanceThemeManager.reset() + } + + @Test + fun `switching selects the site and tears nothing down`() = runBlocking { + manager().switchActiveInstance(beta) + + assertEquals(beta, store.currentInstance) + assertEquals("beta", InstanceThemeManager.theme.value?.key) + verify { cookies.invalidateSessionCookie() } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + coVerify(exactly = 0) { preferences.clearSession(any()) } + coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } + verify(exactly = 0) { databaseManager.clearTables() } + } + + @Test + fun `logout ends the session in order and keeps cached content`() = runBlocking { + store.select(alpha) + + manager().logoutCurrentInstance() + + coVerifyOrder { + downloads.cancelUnfinishedDownloads() + push.unregisterPushToken("alpha") + preferences.clearSession("alpha") + } + assertNull(store.currentInstance) + assertNull(InstanceThemeManager.theme.value) + verify { cookies.clearWebViewCookie() } + verify(exactly = 0) { databaseManager.clearTables() } + } + + @Test + fun `logout completes without push unregistration wired`() = runBlocking { + store.select(alpha) + + manager(pushTokenUnregistrar = null).logoutCurrentInstance() + + coVerify { preferences.clearSession("alpha") } + assertNull(store.currentInstance) + } + + @Test + fun `clearing the selection happens after the session is cleared`() = runBlocking { + store.select(alpha) + var selectedWhileClearing: Instance? = null + coEvery { preferences.clearSession("alpha") } answers { selectedWhileClearing = store.currentInstance } + + manager().logoutCurrentInstance() + + assertEquals(alpha, selectedWhileClearing) + } + + @Test + fun `logging out of another site leaves the active one alone`() = runBlocking { + store.select(alpha) + + manager().logout(beta) + + coVerify { push.unregisterPushToken("beta") } + coVerify { preferences.clearSession("beta") } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } + assertEquals(alpha, store.currentInstance) + } + + @Test + fun `logging out of the active site is a full logout`() = runBlocking { + store.select(beta) + + manager().logout(beta) + + coVerify { preferences.clearSession("beta") } + assertNull(store.currentInstance) + } + + @Test + fun `switching sites while push unregisters signs out only the site that started it`() = runBlocking { + val sessions = mutableSetOf("alpha", "beta") + every { preferences.hasSession(any()) } answers { firstArg() in sessions } + coEvery { preferences.clearSession(any()) } answers { sessions.remove(firstArg()) } + val unregisterStarted = CompletableDeferred() + val unregisterDone = CompletableDeferred() + coEvery { push.unregisterPushToken("alpha") } coAnswers { + unregisterStarted.complete(Unit) + unregisterDone.await() + } + val manager = manager() + manager.switchActiveInstance(alpha) + + val logout = launch { manager.logoutCurrentInstance() } + unregisterStarted.await() + manager.switchActiveInstance(beta) + unregisterDone.complete(Unit) + logout.join() + + assertFalse(preferences.hasSession("alpha")) + assertTrue(preferences.hasSession("beta")) + coVerify(exactly = 0) { preferences.clearSession("beta") } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + assertEquals(beta, store.currentInstance) + assertEquals("beta", InstanceThemeManager.theme.value?.key) + verify(exactly = 0) { cookies.clearWebViewCookie() } + } + + @Test + fun `signing out of another site while it becomes selected leaves the selection alone`() = runBlocking { + val unregisterStarted = CompletableDeferred() + val unregisterDone = CompletableDeferred() + coEvery { push.unregisterPushToken("beta") } coAnswers { + unregisterStarted.complete(Unit) + unregisterDone.await() + } + val manager = manager() + manager.switchActiveInstance(alpha) + + val logout = launch { manager.logout(beta) } + unregisterStarted.await() + manager.switchActiveInstance(beta) + unregisterDone.complete(Unit) + logout.join() + + coVerify { preferences.clearSession("beta") } + coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } + assertEquals(beta, store.currentInstance) + verify(exactly = 0) { cookies.clearWebViewCookie() } + } + + @Test + fun `a different learner signing in clears the previous learner's cache on that site`() { + every { preferences.lastSignedInUsername("beta") } returns "first" + + manager().onSignedIn("beta", "second") + + verify { databaseManager.clearTables("beta") } + verify { preferences.setLastSignedInUsername("beta", "second") } + } + + @Test + fun `the same learner signing back in keeps the cache`() { + every { preferences.lastSignedInUsername("beta") } returns "first" + manager().onSignedIn("beta", "first") + every { preferences.lastSignedInUsername("alpha") } returns "" + manager().onSignedIn("alpha", "first") + + verify(exactly = 0) { databaseManager.clearTables(any()) } + } + + @Test + fun `a sign-out that started on another site finishes there only`() = runBlocking { + store.select(beta) + + manager().logout("alpha") + + coVerifyOrder { + push.unregisterPushToken("alpha") + preferences.clearSession("alpha") + } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + assertEquals(beta, store.currentInstance) + } + + @Test + fun `theme follows the selected instance and resets without one`() { + store.select(beta) + manager().applyThemeForCurrentInstance() + assertEquals("#1C355E", InstanceThemeManager.theme.value?.accentColorHex) + + store.select(null) + manager().applyThemeForCurrentInstance() + assertNull(InstanceThemeManager.theme.value) + } +} diff --git a/core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt new file mode 100644 index 000000000..f03a9633a --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt @@ -0,0 +1,135 @@ +package org.openedx.core.instance + +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstanceStoreTest { + + private class InMemorySelectionStorage(override var selectedInstanceKey: String? = null) : + InstanceSelectionStorage + + private fun instance(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key.uppercase()) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = instance("alpha") + private val beta = instance("beta") + + private fun store( + storage: InstanceSelectionStorage, + vararg instances: Instance, + ) = InstanceStore(storage) { InstancesConfig(instances.toList()) } + + @Test + fun `single instance catalog with no prior selection auto selects it`() { + val storage = InMemorySelectionStorage() + + val store = store(storage, alpha) + + assertEquals(alpha, store.currentInstance) + assertEquals("alpha", storage.selectedInstanceKey) + assertFalse(store.hasMultipleInstances) + } + + @Test + fun `multi instance catalog with no prior selection selects nothing`() { + val store = store(InMemorySelectionStorage(), alpha, beta) + + assertNull(store.currentInstance) + assertTrue(store.hasMultipleInstances) + } + + @Test + fun `persisted selection is restored by key`() { + val store = store(InMemorySelectionStorage("beta"), alpha, beta) + + assertEquals(beta, store.currentInstance) + } + + @Test + fun `single instance catalog replaces a stale saved key`() { + val storage = InMemorySelectionStorage("gone") + + val store = store(storage, alpha) + + assertEquals(alpha, store.currentInstance) + assertEquals("alpha", storage.selectedInstanceKey) + } + + @Test + fun `empty catalog leaves nothing selected`() { + val store = store(InMemorySelectionStorage()) + + assertNull(store.currentInstance) + assertFalse(store.hasMultipleInstances) + } + + @Test + fun `deselecting a single instance catalog selects it again`() { + val store = store(InMemorySelectionStorage(), alpha) + + store.select(null) + + assertEquals(alpha, store.currentInstance) + } + + @Test + fun `deselecting a multi instance catalog stays deselected`() { + val storage = InMemorySelectionStorage() + val store = store(storage, alpha, beta) + store.select(beta) + + store.reset() + + assertNull(store.currentInstance) + assertNull(storage.selectedInstanceKey) + } + + @Test + fun `selection is published to the flow`() { + val store = store(InMemorySelectionStorage(), alpha, beta) + + store.select(alpha) + assertEquals(alpha, store.currentInstanceFlow.value) + store.select(beta) + assertEquals(beta, store.currentInstanceFlow.value) + } + + @Test + fun `a new catalog re-resolves the saved selection`() { + val storage = InMemorySelectionStorage("beta") + val store = store(storage, alpha) + assertEquals(alpha, store.currentInstance) + + storage.selectedInstanceKey = "beta" + store.updateInstancesConfig(InstancesConfig(listOf(beta, alpha))) + + assertEquals(beta, store.currentInstance) + assertEquals(listOf(beta, alpha), store.instancesConfig.instances) + } + + @Test + fun `a site removed from the catalog resolves to nothing but stays saved`() { + val storage = InMemorySelectionStorage() + val store = store(storage, alpha, beta) + store.select(beta) + + store.updateInstancesConfig(InstancesConfig(listOf(alpha, instance("gamma")))) + assertNull(store.currentInstance) + assertEquals("beta", storage.selectedInstanceKey) + + store.updateInstancesConfig(InstancesConfig(listOf(alpha, beta))) + assertEquals(beta, store.currentInstance) + } +} diff --git a/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt b/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt new file mode 100644 index 000000000..75fa89e22 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt @@ -0,0 +1,183 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstancesConfigTest { + + private fun fixture(): JsonObject { + val stream = requireNotNull(javaClass.classLoader?.getResourceAsStream("instances/bundled_instances.json")) + return stream.reader().use { JsonParser.parseReader(it).asJsonObject } + } + + private fun requiredFields(): JsonObject = JsonObject().apply { + addProperty("KEY", "test-instance") + addProperty("NAME", "Test Instance") + addProperty("OAUTH_CLIENT_ID", "client-id") + addProperty("API_HOST_URL", "https://test-instance.example.com") + } + + @Test + fun `bundled catalog parses every field of a complete entry`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + val instance = requireNotNull(catalog.instance("example-instance")) + + assertEquals("Example Instance", instance.name) + assertEquals("Example Instance", instance.instanceName) + assertEquals("example-oauth-client-id", instance.oAuthClientId) + assertEquals("https://example-instance.example.com", instance.apiHostUrl) + assertEquals( + "https://example-instance.example.com/auth/login/tpa-saml/?auth_entry=login", + instance.ssoUrl + ) + assertEquals( + "https://example-instance.example.com/auth/login/tpa-saml/auth/complete/tpa-saml", + instance.ssoFinishedUrl + ) + assertEquals("Example Instance", instance.environmentDisplayName) + assertTrue(instance.isSwitchInstanceLoginEnabled) + assertEquals("exampleInstanceAppLogo", instance.logoUrl) + assertNull(instance.headerBackgroundUrl) + assertEquals(mapOf("accent_color" to "#3C68FF"), instance.themeColors?.light) + assertEquals(mapOf("accent_color" to "#1C355E"), instance.themeColors?.dark) + assertTrue(instance.config.has("UI_COMPONENTS")) + } + + @Test + fun `malformed entries are dropped instead of half populated`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + + assertEquals(listOf("example-instance", "second-universite"), catalog.instances.map { it.key }) + assertNull(catalog.instance("missing-client")) + } + + @Test + fun `each required field is enforced`() { + listOf("NAME", "API_HOST_URL", "OAUTH_CLIENT_ID").forEach { field -> + val json = requiredFields().apply { remove(field) } + assertNull("missing $field must be rejected", Instance.fromJson(json)) + } + val blankName = requiredFields().apply { addProperty("NAME", " ") } + assertNull(Instance.fromJson(blankName)) + val relativeHost = requiredFields().apply { addProperty("API_HOST_URL", "test-instance.example.com") } + assertNull(Instance.fromJson(relativeHost)) + val numericClient = requiredFields().apply { addProperty("OAUTH_CLIENT_ID", 42) } + assertNull(Instance.fromJson(numericClient)) + } + + @Test + fun `key falls back to a slug of the name`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + + assertNotNull(catalog.instance("second-universite")) + assertEquals("acme-u-of-arts", Instance.slugify(" ACME U. of Arts! ")) + } + + @Test + fun `display name follows the language then English then NAME`() { + assertEquals("اسم المُزود", InstancesConfig.fromAppConfig(fixture(), "ar").instances[0].instanceName) + assertEquals("Example Instance", InstancesConfig.fromAppConfig(fixture(), "uk").instances[0].instanceName) + assertEquals("Second Université", InstancesConfig.fromAppConfig(fixture(), "ar").instances[1].instanceName) + } + + @Test + fun `optional fields use defaults when omitted`() { + val instance = requireNotNull(Instance.fromJson(requiredFields())) + + assertEquals("#007AFF", instance.color) + assertEquals("Test Instance", instance.platformName) + assertNull(instance.ssoUrl) + assertNull(instance.ssoFinishedUrl) + assertNull(instance.themeColors) + assertNull(instance.logoUrl) + assertFalse(instance.isSwitchInstanceLoginEnabled) + assertTrue(instance.isRoundedCorners) + assertEquals(8.0, instance.buttonCornersRadius, 0.0) + } + + @Test + fun `blank optional strings count as unset`() { + val json = requiredFields().apply { + addProperty("SSO_URL", "") + addProperty("SSO_URL_SUCCESSFUL_LOGIN", " ") + addProperty("PLATFORM_NAME", "") + add("THEME", JsonParser.parseString("""{"LOGO_URL": "", "HEADER_BACKGROUND_URL": ""}""")) + } + val instance = requireNotNull(Instance.fromJson(json)) + + assertNull(instance.ssoUrl) + assertNull(instance.ssoFinishedUrl) + assertNull(instance.logoUrl) + assertNull(instance.headerBackgroundUrl) + assertEquals("Test Instance", instance.platformName) + } + + @Test + fun `instance level values win when provided`() { + val json = requiredFields().apply { + addProperty("PLATFORM_NAME", "Custom Platform Name") + addProperty("SSO_FINISHED_URL", "https://test-instance.example.com/finished") + addProperty("SSO_URL_SUCCESSFUL_LOGIN", "https://test-instance.example.com/ignored") + add("THEME", JsonParser.parseString("""{"ROUNDED_CORNERS_STYLE": false, "BUTTON_CORNERS_RADIUS": 4}""")) + } + val instance = requireNotNull(Instance.fromJson(json)) + + assertEquals("Custom Platform Name", instance.platformName) + assertEquals("https://test-instance.example.com/finished", instance.ssoFinishedUrl) + assertFalse(instance.isRoundedCorners) + assertEquals(4.0, instance.buttonCornersRadius, 0.0) + assertEquals(InstanceThemeColors(emptyMap(), emptyMap()), instance.themeColors) + } + + @Test + fun `fallback fills omitted fields and never overrides the entry`() { + val fallback = JsonObject().apply { + addProperty("OAUTH_CLIENT_ID", "fallback-client") + addProperty("FEEDBACK_EMAIL_ADDRESS", "fallback@example.com") + } + val entry = requiredFields().apply { remove("OAUTH_CLIENT_ID") } + val withClient = requiredFields().apply { addProperty("KEY", "with-client") } + val array = JsonArray().apply { + add(entry) + add(withClient) + } + + val catalog = InstancesConfig.fromJson(array, fallback) + + assertEquals("fallback-client", catalog.instance("test-instance")?.oAuthClientId) + assertEquals("client-id", catalog.instance("with-client")?.oAuthClientId) + val feedbackEmail = catalog.instance("with-client")?.config?.get("FEEDBACK_EMAIL_ADDRESS")?.asString + assertEquals("fallback@example.com", feedbackEmail) + assertFalse(fallback.has("KEY")) + } + + @Test + fun `identity is the key, not the position in the catalog`() { + val fixture = fixture() + val reversed = JsonArray().apply { + fixture.getAsJsonArray("INSTANCES").reversed().forEach { add(it) } + } + + val original = InstancesConfig.fromAppConfig(fixture, "en") + val reordered = InstancesConfig.fromJson(reversed, languageCode = "en") + + assertEquals(original.instance("example-instance"), reordered.instance("example-instance")) + assertEquals(original.instance("second-universite"), reordered.instance("second-universite")) + } + + @Test + fun `missing or non array INSTANCES gives an empty catalog`() { + assertTrue(InstancesConfig.fromAppConfig(JsonObject()).instances.isEmpty()) + val notArray = JsonObject().apply { addProperty("INSTANCES", "nope") } + assertTrue(InstancesConfig.fromAppConfig(notArray).instances.isEmpty()) + val junk = JsonObject().apply { add("INSTANCES", JsonParser.parseString("""[1, "two", null]""")) } + assertTrue(InstancesConfig.fromAppConfig(junk).instances.isEmpty()) + } +} diff --git a/core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt b/core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt new file mode 100644 index 000000000..9c84b064e --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt @@ -0,0 +1,134 @@ +package org.openedx.core.instance + +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageNamespaceTest { + + private class InMemoryStorage( + override var selectedInstanceKey: String? = null, + override var legacyOwnerInstanceKey: String? = null, + ) : InstanceSelectionStorage, LegacyNamespaceOwnerStorage + + private fun instance(key: String, host: String, client: String = "$key-client") = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", client) + addProperty("API_HOST_URL", host) + } + ) + ) + + private val appSite = instance("app-site", "https://LMS.example.com/", client = "app-client") + private val other = instance("other", "https://other.example.com") + + private fun namespaces(storage: InMemoryStorage, vararg instances: Instance): InstanceStorageNamespaces { + val store = InstanceStore(storage) { InstancesConfig(instances.toList()) } + return InstanceStorageNamespaces(store, storage, "https://lms.example.com:443", "app-client") + } + + @Test + fun `legacy namespace keeps raw keys and has no file segment`() { + assertEquals("access_token", StorageNamespace.LEGACY.key("access_token")) + assertNull(StorageNamespace.LEGACY.fileSegment) + assertTrue(StorageNamespace.LEGACY.isLegacy) + } + + @Test + fun `instance namespace prefixes keys with the instance key`() { + assertEquals("instance.acme.access_token", StorageNamespace("acme").key("access_token")) + } + + @Test + fun `file segments are safe and keep keys that sanitize alike apart`() { + val slash = requireNotNull(StorageNamespace("a/b").fileSegment) + val colon = requireNotNull(StorageNamespace("a:b").fileSegment) + + assertTrue(slash, slash.matches("[A-Za-z0-9_-]+".toRegex())) + assertTrue(slash.startsWith("a_b_")) + assertNotEquals(slash, colon) + assertEquals(slash, StorageNamespace("a/b").fileSegment) + } + + @Test + fun `nothing selected uses the legacy namespace`() { + val namespaces = namespaces(InMemoryStorage(), appSite, other) + + assertEquals(StorageNamespace.LEGACY, namespaces.current) + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor(null as String?)) + } + + @Test + fun `the instance matching the app level host and client inherits the legacy namespace`() { + val storage = InMemoryStorage() + val namespaces = namespaces(storage, appSite, other) + + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor(appSite)) + assertEquals(StorageNamespace("other"), namespaces.namespaceFor(other)) + assertEquals("app-site", storage.legacyOwnerInstanceKey) + } + + @Test + fun `same host with a different OAuth client is a different site`() { + val sameHost = instance("same-host", "https://lms.example.com", client = "another-client") + val namespaces = namespaces(InMemoryStorage(), sameHost) + + assertEquals(StorageNamespace("same-host"), namespaces.namespaceFor(sameHost)) + } + + @Test + fun `different port or path is a different site`() { + val port = instance("port", "https://lms.example.com:8443", client = "app-client") + val path = instance("path", "https://lms.example.com/tenant", client = "app-client") + val namespaces = namespaces(InMemoryStorage(), port, path) + + assertEquals(StorageNamespace("port"), namespaces.namespaceFor(port)) + assertEquals(StorageNamespace("path"), namespaces.namespaceFor(path)) + } + + @Test + fun `the recorded owner never changes once decided`() { + val storage = InMemoryStorage(legacyOwnerInstanceKey = "other") + val namespaces = namespaces(storage, appSite, other) + + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor(other)) + assertEquals(StorageNamespace("app-site"), namespaces.namespaceFor(appSite)) + assertEquals("other", storage.legacyOwnerInstanceKey) + } + + @Test + fun `catalog order does not decide ownership`() { + val first = namespaces(InMemoryStorage(), other, appSite) + val second = namespaces(InMemoryStorage(), appSite, other) + + assertEquals(first.namespaceFor(appSite), second.namespaceFor(appSite)) + assertEquals(first.namespaceFor(other), second.namespaceFor(other)) + } + + @Test + fun `a key no longer in the catalog keeps its own namespace`() { + val storage = InMemoryStorage(legacyOwnerInstanceKey = "app-site") + val namespaces = namespaces(storage, other) + + assertEquals(StorageNamespace("gone"), namespaces.namespaceFor("gone")) + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor("app-site")) + } + + @Test + fun `current follows the selection`() { + val storage = InMemoryStorage() + val store = InstanceStore(storage) { InstancesConfig(listOf(appSite, other)) } + val namespaces = InstanceStorageNamespaces(store, storage, "https://lms.example.com", "app-client") + + store.select(other) + assertEquals(StorageNamespace("other"), namespaces.current) + store.select(appSite) + assertEquals(StorageNamespace.LEGACY, namespaces.current) + } +} diff --git a/core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt b/core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt new file mode 100644 index 000000000..b9f9dae09 --- /dev/null +++ b/core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt @@ -0,0 +1,49 @@ +package org.openedx.core.module.download + +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openedx.core.instance.Instance +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import java.io.File +import java.nio.file.Files + +class DownloadHelperInstanceFolderTest { + + private val base = Files.createTempDirectory("downloads").toFile() + + private class FixedNamespace(override var current: StorageNamespace) : StorageNamespaceProvider { + override fun namespaceFor(instance: Instance?) = StorageNamespace(instance?.key) + override fun namespaceFor(instanceKey: String?) = StorageNamespace(instanceKey) + } + + private val namespace = FixedNamespace(StorageNamespace.LEGACY) + private val helper = DownloadHelper(mockk(), mockk(), namespace) + + @After + fun tearDown() { + base.deleteRecursively() + } + + @Test + fun `legacy namespace keeps downloading into the base folder`() { + assertEquals(base.path, helper.instanceFolder(base.path)) + } + + @Test + fun `each instance downloads into a folder of its own`() { + namespace.current = StorageNamespace("alpha") + val alpha = helper.instanceFolder(base.path) + namespace.current = StorageNamespace("beta") + val beta = helper.instanceFolder(base.path) + + assertNotEquals(alpha, beta) + assertTrue(File(alpha).isDirectory) + assertTrue(File(beta).isDirectory) + assertEquals(File(base, "instances"), File(alpha).parentFile) + } +} diff --git a/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt b/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt new file mode 100644 index 000000000..5518bca2e --- /dev/null +++ b/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt @@ -0,0 +1,107 @@ +package org.openedx.core.ui.theme + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class InstanceThemeTest { + + private val light = LightColorPalette + private val dark = DarkColorPalette + + @Test + fun `no palette derives only the accent colors from the hex`() { + val colors = InstanceTheme(key = "acme", accentColorHex = "#3E578C").applyTo(light, isDark = false) + val accent = Color(0xFF3E578C) + + assertEquals(accent, colors.primary) + assertEquals(accent, colors.textAccent) + assertEquals(accent, colors.textHyperLink) + assertEquals(accent, colors.primaryButtonBackground) + assertEquals(accent, colors.primaryButtonBorderedText) + assertEquals(light.textPrimary, colors.textPrimary) + assertEquals(light.background, colors.background) + } + + @Test + fun `partial palette overrides only the fields it sets`() { + val baseline = InstanceTheme(key = "acme", accentColorHex = "#3E578C").applyTo(light, isDark = false) + val overridden = InstanceTheme( + key = "acme", + accentColorHex = "#3E578C", + light = mapOf("text_primary" to "#18202E"), + ).applyTo(light, isDark = false) + + assertEquals(Color(0xFF18202E), overridden.textPrimary) + assertEquals(baseline.primary, overridden.primary) + assertEquals(baseline.background, overridden.background) + assertEquals(baseline.cardViewBackground, overridden.cardViewBackground) + } + + @Test + fun `palette accent wins over the flat color, per mode`() { + val theme = InstanceTheme( + key = "acme", + accentColorHex = "#007AFF", + light = mapOf("accent_color" to "#3C68FF"), + dark = mapOf("accent_color" to "#1C355E"), + ) + + assertEquals(Color(0xFF3C68FF), theme.applyTo(light, isDark = false).primary) + assertEquals(Color(0xFF1C355E), theme.applyTo(dark, isDark = true).primary) + } + + @Test + fun `missing dark value falls back to the light value`() { + val theme = InstanceTheme(key = "acme", light = mapOf("alert" to "#FF3D71", "accent_color" to "#3C68FF")) + + assertEquals(Color(0xFFFF3D71), theme.applyTo(dark, isDark = true).error) + assertEquals(Color(0xFF3C68FF), theme.applyTo(dark, isDark = true).primary) + } + + @Test + fun `light and dark values resolve independently`() { + val theme = InstanceTheme( + key = "acme", + light = mapOf("background" to "#FFFFFF"), + dark = mapOf("background" to "#0A0A0A"), + ) + + assertEquals(Color(0xFFFFFFFF), theme.applyTo(light, isDark = false).background) + assertEquals(Color(0xFF0A0A0A), theme.applyTo(dark, isDark = true).background) + } + + @Test + fun `unknown fields and bad hex values are ignored`() { + val theme = InstanceTheme( + key = "acme", + accentColorHex = "not-a-color", + light = mapOf("not_a_real_field" to "#000000", "text_primary" to "#12"), + ) + + assertEquals(light, theme.applyTo(light, isDark = false)) + } + + @Test + fun `hex parsing accepts short, long and alpha forms`() { + assertEquals(Color(0xFFFF0000), "#F00".toColorOrNull()) + assertEquals(Color(0xFF3C68FF), " 3C68FF ".toColorOrNull()) + assertEquals(Color(0x803C68FF), "#803C68FF".toColorOrNull()) + assertNull("#GG0000".toColorOrNull()) + assertNull("-12345".toColorOrNull()) + assertNull("".toColorOrNull()) + } + + @Test + fun `manager publishes and resets the theme`() { + val theme = InstanceTheme(key = "acme") + + InstanceThemeManager.applyTheme(theme) + assertSame(theme, InstanceThemeManager.theme.value) + + InstanceThemeManager.reset() + assertNull(InstanceThemeManager.theme.value) + } +} diff --git a/core/src/test/resources/instances/bundled_instances.json b/core/src/test/resources/instances/bundled_instances.json new file mode 100644 index 000000000..da32373fb --- /dev/null +++ b/core/src/test/resources/instances/bundled_instances.json @@ -0,0 +1,45 @@ +{ + "API_HOST_URL": "https://example-instance.example.com", + "OAUTH_CLIENT_ID": "example-oauth-client-id", + "INSTANCES": [ + { + "KEY": "example-instance", + "NAME": "Example Instance", + "INSTANCE_NAME": { + "ar": "اسم المُزود", + "en": "Example Instance" + }, + "ENVIRONMENT_DISPLAY_NAME": "Example Instance", + "OAUTH_CLIENT_ID": "example-oauth-client-id", + "API_HOST_URL": "https://example-instance.example.com", + "SSO_URL": "https://example-instance.example.com/auth/login/tpa-saml/?auth_entry=login", + "SSO_URL_SUCCESSFUL_LOGIN": "https://example-instance.example.com/auth/login/tpa-saml/auth/complete/tpa-saml", + "IS_SWITCH_INSTANCE_LOGIN_ENABLED": true, + "THEME": { + "LIGHT": { + "accent_color": "#3C68FF" + }, + "DARK": { + "accent_color": "#1C355E" + }, + "LOGO_URL": "exampleInstanceAppLogo" + }, + "UI_COMPONENTS": { + "COURSE_UNIT_PROGRESS_ENABLED": false, + "LOGIN_REGISTRATION_ENABLED": true, + "SAML_SSO_LOGIN_ENABLED": false, + "SAML_SSO_DEFAULT_LOGIN_BUTTON": false + } + }, + { + "NAME": "Second Université", + "OAUTH_CLIENT_ID": "second-client-id", + "API_HOST_URL": "https://second.example.com:8443" + }, + { + "KEY": "missing-client", + "NAME": "Missing Client", + "API_HOST_URL": "https://missing-client.example.com" + } + ] +} diff --git a/core/src/test/resources/instances/remote_catalog.json b/core/src/test/resources/instances/remote_catalog.json new file mode 100644 index 000000000..c9bab44a3 --- /dev/null +++ b/core/src/test/resources/instances/remote_catalog.json @@ -0,0 +1,34 @@ +{ + "instances": [ + { + "key": "niepd", + "name": "NIEPD", + "instance_name": { "en": "National Institute", "ar": "المعهد الوطني" }, + "color": "#1C355E", + "oauth_client_id": "niepd-client", + "api_host_url": "https://lms.niepd.example.com", + "sso_url": "https://lms.niepd.example.com/auth/login/tpa-saml/?auth_entry=login", + "sso_url_successful_login": "https://lms.niepd.example.com/auth/complete/tpa-saml", + "environment_display_name": "NIEPD", + "is_switch_instance_login_enabled": true, + "ui_components": { + "course_unit_progress_enabled": true, + "login_registration_enabled": false, + "saml_sso_login_enabled": true + }, + "theme": { + "light": { "accent_color": "#3C68FF", "text_primary": "#18202E" }, + "dark": { "accent_color": "#1C355E" }, + "logo_url": "https://cdn.example.com/niepd/logo.png", + "header_background_url": "https://cdn.example.com/niepd/header.png" + }, + "FEEDBACK_EMAIL_ADDRESS": "help@niepd.example.com", + "feedback_email_address": "ignored-because-unmapped@example.com" + }, + { + "key": "broken", + "name": "Broken entry without a client", + "api_host_url": "https://broken.example.com" + } + ] +} diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 229963b58..b2225952a 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -19,6 +19,8 @@ import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection import java.net.URLDecoder @@ -38,6 +40,8 @@ class CourseRepository( private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, private val networkConnection: NetworkConnection, + private val namespaces: StorageNamespaceProvider, + private val databases: InstanceDatabases, ) { // Session tracking - when entering a course, mark that data needs refresh private val needsRefresh = ConcurrentHashMap.newKeySet() @@ -276,10 +280,17 @@ class CourseRepository( suspend fun getXBlockProgress(blockId: String) = downloadDao.getOfflineXBlockProgress(blockId) + /** + * Runs in the background and can outlive a site switch, so it stays with the site it started + * on: progress is read from and cleared in that site's database, and it stops before sending + * anything once another site is selected, since requests go to the selected site. + */ suspend fun submitAllOfflineXBlockProgress() { - val allOfflineXBlockProgress = downloadDao.getAllOfflineXBlockProgress() - allOfflineXBlockProgress.forEach { - submitOfflineXBlockProgress(it.blockId, it.courseId, it.jsonProgress.data) + val namespace = namespaces.current + val siteDao = databases.downloadDao(namespace) + for (progress in siteDao.getAllOfflineXBlockProgress()) { + if (namespaces.current != namespace) return + submitOfflineXBlockProgress(progress.blockId, progress.courseId, progress.jsonProgress.data, siteDao) } } @@ -291,7 +302,8 @@ class CourseRepository( private suspend fun submitOfflineXBlockProgress( blockId: String, courseId: String, - jsonProgressData: String? + jsonProgressData: String?, + siteDao: DownloadDao = downloadDao, ) { if (!jsonProgressData.isNullOrEmpty()) { val parts = mutableListOf() @@ -302,7 +314,7 @@ class CourseRepository( parts.add(MultipartBody.Part.createFormData(key, value)) } api.submitOfflineXBlockProgress(courseId, blockId, parts) - downloadDao.removeOfflineXBlockProgress(listOf(blockId)) + siteDao.removeOfflineXBlockProgress(listOf(blockId)) } } diff --git a/course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt b/course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt new file mode 100644 index 000000000..684f9b813 --- /dev/null +++ b/course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt @@ -0,0 +1,72 @@ +package org.openedx.course.data.repository + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.openedx.core.data.api.CourseApi +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.InstanceDatabases +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import org.openedx.core.module.db.DownloadDao + +class CourseRepositoryOfflineSyncTest { + + private class Selection(override var current: StorageNamespace) : StorageNamespaceProvider { + override fun namespaceFor(instance: Instance?) = StorageNamespace(instance?.key) + override fun namespaceFor(instanceKey: String?) = StorageNamespace(instanceKey) + } + + private val alpha = StorageNamespace("alpha") + private val beta = StorageNamespace("beta") + private val alphaDao = mockk(relaxed = true) + private val betaDao = mockk(relaxed = true) + private val selectedSiteDao = mockk(relaxed = true) + private val api = mockk(relaxed = true) + private val selection = Selection(alpha) + private val databases = object : InstanceDatabases { + override fun downloadDao(namespace: StorageNamespace) = if (namespace == alpha) alphaDao else betaDao + } + + private fun progress(blockId: String) = + OfflineXBlockProgress(blockId, "course", XBlockProgressData("https://alpha/handler", "POST", "a%3D1")) + + private fun repository() = CourseRepository( + api = api, + courseDao = mockk(relaxed = true), + downloadDao = selectedSiteDao, + preferencesManager = mockk(relaxed = true), + networkConnection = mockk(relaxed = true), + namespaces = selection, + databases = databases, + ) + + @Test + fun `a switch during sync stops it and never touches the new site`() = runTest { + coEvery { alphaDao.getAllOfflineXBlockProgress() } returns listOf(progress("block-1"), progress("block-2")) + coEvery { api.submitOfflineXBlockProgress("course", "block-1", any()) } answers { + selection.current = beta + } + + repository().submitAllOfflineXBlockProgress() + + coVerify { alphaDao.removeOfflineXBlockProgress(listOf("block-1")) } + coVerify(exactly = 0) { api.submitOfflineXBlockProgress(any(), "block-2", any()) } + coVerify(exactly = 0) { betaDao.removeOfflineXBlockProgress(any()) } + coVerify(exactly = 0) { selectedSiteDao.removeOfflineXBlockProgress(any()) } + } + + @Test + fun `without a switch every stored block is submitted and cleared`() = runTest { + coEvery { alphaDao.getAllOfflineXBlockProgress() } returns listOf(progress("block-1"), progress("block-2")) + + repository().submitAllOfflineXBlockProgress() + + coVerify { alphaDao.removeOfflineXBlockProgress(listOf("block-1")) } + coVerify { alphaDao.removeOfflineXBlockProgress(listOf("block-2")) } + } +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f2868eb78..2101f01d7 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -5,6 +5,8 @@ URI_SCHEME: '' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' FAQ_URL: '' OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' +# URL of the remote learning site catalog. Leave empty to use only the bundled INSTANCES list. +INSTANCES_CATALOG_URL: '' # Keep empty to hide setting AGREEMENT_URLS: @@ -94,3 +96,52 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false + +# Learning sites ("instances") this build can sign in to. One entry: the app behaves like a +# single-site build, with no site picker. Several entries: the learner picks a site and can stay +# signed in to more than one. NAME, API_HOST_URL and OAUTH_CLIENT_ID are required; every other key +# is optional and falls back to the app-level value above. +INSTANCES: + - KEY: 'localhost' + NAME: 'Localhost' + INSTANCE_NAME: + en: 'Localhost' + ENVIRONMENT_DISPLAY_NAME: 'Localhost' + API_HOST_URL: 'http://localhost:8000' + OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' + SSO_URL: '' + SSO_URL_SUCCESSFUL_LOGIN: '' + IS_SWITCH_INSTANCE_LOGIN_ENABLED: false + FEEDBACK_EMAIL_ADDRESS: 'support@example.com' + FAQ_URL: '' + PLATFORM_NAME: 'OpenEdX' + TOKEN_TYPE: 'JWT' + THEME: + LIGHT: + accent_color: '#3C68FF' + DARK: + accent_color: '#3C68FF' + LOGO_URL: '' + HEADER_BACKGROUND_URL: '' + UI_COMPONENTS: + COURSE_DROPDOWN_NAVIGATION_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false + AGREEMENT_URLS: + PRIVACY_POLICY_URL: '' + COOKIE_POLICY_URL: '' + DATA_SELL_CONSENT_URL: '' + TOS_URL: '' + EULA_URL: '' + SUPPORTED_LANGUAGES: [ ] + DISCOVERY: + TYPE: 'native' + PROGRAM: + TYPE: 'native' + DASHBOARD: + TYPE: 'gallery' + EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + APP_LEVEL_DATES: + ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f2868eb78..2f5e900d7 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -5,6 +5,8 @@ URI_SCHEME: '' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' FAQ_URL: '' OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' +# URL of the remote learning site catalog. Leave empty to use only the bundled INSTANCES list. +INSTANCES_CATALOG_URL: '' # Keep empty to hide setting AGREEMENT_URLS: diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f2868eb78..2f5e900d7 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -5,6 +5,8 @@ URI_SCHEME: '' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' FAQ_URL: '' OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' +# URL of the remote learning site catalog. Leave empty to use only the bundled INSTANCES list. +INSTANCES_CATALOG_URL: '' # Keep empty to hide setting AGREEMENT_URLS: diff --git a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt index 1f206ed77..87386decf 100644 --- a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt +++ b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt @@ -3,6 +3,7 @@ package org.openedx.profile.data.api import okhttp3.RequestBody import okhttp3.ResponseBody import org.openedx.core.ApiConstants +import org.openedx.core.instance.RequestInstance import org.openedx.profile.data.model.Account import retrofit2.Response import retrofit2.http.Body @@ -16,6 +17,7 @@ import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query +import retrofit2.http.Tag interface ProfileApi { @@ -24,7 +26,8 @@ interface ProfileApi { suspend fun revokeAccessToken( @Field("client_id") clientId: String?, @Field("token") token: String?, - @Field("token_type_hint") tokenTypeHint: String? + @Field("token_type_hint") tokenTypeHint: String?, + @Tag instance: RequestInstance, ): ResponseBody @GET("/api/user/v1/accounts/{username}") diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index 561d73c05..668052ef5 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -3,9 +3,11 @@ package org.openedx.profile.data.repository import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import org.openedx.core.ApiConstants -import org.openedx.core.DatabaseManager import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.RequestInstance import org.openedx.profile.data.api.ProfileApi import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.domain.model.Account @@ -16,7 +18,8 @@ class ProfileRepository( private val api: ProfileApi, private val profilePreferences: ProfilePreferences, private val corePreferences: CorePreferences, - private val databaseManager: DatabaseManager + private val sessionManager: InstanceSessionManager, + private val instanceProvider: InstanceProvider, ) { suspend fun getAccount(): Account { @@ -53,16 +56,24 @@ class ProfileRepository( suspend fun deactivateAccount(password: String) = api.deactivateAccount(password) + /** + * Signs out of the site selected when it starts. The revoke request is pinned to that site, + * and if the learner selects another site while it is in flight, only the original site is + * signed out: the new selection and its session stay. + */ suspend fun logout() { + val siteKey = instanceProvider.currentInstance?.key + val clientId = config.getOAuthClientId() + val refreshToken = corePreferences.refreshToken(siteKey) try { api.revokeAccessToken( - config.getOAuthClientId(), - corePreferences.refreshToken, - ApiConstants.TOKEN_TYPE_REFRESH + clientId, + refreshToken, + ApiConstants.TOKEN_TYPE_REFRESH, + RequestInstance(siteKey), ) } finally { - corePreferences.clearCorePreferences() - databaseManager.clearTables() + sessionManager.logout(siteKey) } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 4c418f705..6b453b7dd 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -17,7 +17,6 @@ import org.openedx.core.AppUpdateState import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config -import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.notifier.app.AppNotifier @@ -40,7 +39,6 @@ class SettingsViewModel( private val resourceManager: ResourceManager, private val interactor: ProfileInteractor, private val cookieManager: AppCookieManager, - private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, private val profileRouter: ProfileRouter, private val calendarRouter: CalendarRouter, @@ -73,7 +71,6 @@ class SettingsViewModel( logProfileEvent(ProfileAnalyticsEvent.LOGOUT_CLICKED) viewModelScope.launch { try { - workerController.removeModels() withContext(Dispatchers.IO) { interactor.logout() } diff --git a/profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt b/profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt new file mode 100644 index 000000000..5be927ada --- /dev/null +++ b/profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt @@ -0,0 +1,81 @@ +package org.openedx.profile.data.repository + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Test +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.InstanceSessionManager +import org.openedx.core.instance.RequestInstance +import org.openedx.profile.data.api.ProfileApi + +class ProfileRepositoryLogoutTest { + + private class Selection(instance: Instance?) : InstanceProvider { + val flow = MutableStateFlow(instance) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances = true + } + + private fun site(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 selection = Selection(site("alpha")) + private val api = mockk() + private val preferences = mockk(relaxed = true) { + every { refreshToken("alpha") } returns "refresh-a" + } + private val sessionManager = mockk(relaxed = true) + private val config = mockk { every { getOAuthClientId() } returns "alpha-client" } + + private fun repository() = ProfileRepository( + config, + api, + mockk(relaxed = true), + preferences, + sessionManager, + selection + ) + + @Test + fun `a sign-out that was in flight during a switch signs out only the site it started on`() = runTest { + coEvery { api.revokeAccessToken(any(), any(), any(), any()) } answers { + selection.flow.value = site("beta") + "".toResponseBody() + } + + repository().logout() + + coVerify { api.revokeAccessToken("alpha-client", "refresh-a", any(), RequestInstance("alpha")) } + coVerify { sessionManager.logout("alpha") } + coVerify(exactly = 0) { sessionManager.logoutCurrentInstance() } + coVerify(exactly = 0) { sessionManager.logout("beta") } + } + + @Test + fun `a failed revoke still signs the site out`() = runTest { + coEvery { api.revokeAccessToken(any(), any(), any(), any()) } throws java.io.IOException("offline") + + runCatching { repository().logout() } + + coVerify { sessionManager.logout("alpha") } + } +}