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

Filter by extension

Filter by extension

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

setUserId(user)

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

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

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

interface NotificationsApi {
@POST("/api/mobile/v4/notifications/create-token/")
@FormUrlEncoded
suspend fun syncFirebaseToken(
@Field("registration_id") token: String,
@Field("active") active: Boolean = true
@Field("active") active: Boolean,
@Tag instance: RequestInstance,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,58 @@ 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")
}

// The token belongs to the selected site; never send it to another host.
if (token.isNotEmpty() && targetsActiveSite(request().url.toString())) {
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 targetsActiveSite(url: String): Boolean {
val siteUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL()
val site = siteUrl.toHttpUrlOrNull()
val request = url.toHttpUrlOrNull()
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.
site == null -> true
request == null -> false
else -> request.isSameOrigin(site)
siteHost == null -> true
else -> request.url.isSameOrigin(siteHost)
}
}

private companion object {
const val AUTHORIZATION = "Authorization"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,9 @@ 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

/**
* Which instance a request was sent for, attached as an OkHttp tag when the request starts.
* Token refresh reads it, so a response that arrives after a switch is still handled for the
* site that sent it.
*/
data class RequestInstance(val instanceKey: String?)
import org.openedx.core.instance.RequestInstance

/**
* Re-targets requests built against the app's base URL at the selected instance: scheme, host
Expand All @@ -22,16 +17,19 @@ data class RequestInstance(val instanceKey: String?)
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 instance = instanceProvider.currentInstance
val request = chain.request()
val rewritten = rewriteHostIfNeeded(request, instance?.apiHostUrl)
// 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, RequestInstance(instance?.key))
.tag(RequestInstance::class.java, pinned ?: RequestInstance(target?.key))
.build()
return chain.proceed(rewritten)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ class PreferencesManager(
private object InstanceKeys {
fun accessToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("access_token"))
fun refreshToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("refresh_token"))
fun pushToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("push_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"))
Expand All @@ -94,6 +94,7 @@ class PreferencesManager(
get() = namespaces.current

private object Keys {
val PUSH_TOKEN = stringPreferencesKey("push_token")
val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY =
booleanPreferencesKey("video_settings_wifi_download_only")
val VIDEO_SETTINGS_STREAMING_QUALITY =
Expand Down Expand Up @@ -154,7 +155,6 @@ class PreferencesManager(
dataStore.edit { prefs ->
prefs.remove(InstanceKeys.accessToken(namespace))
prefs.remove(InstanceKeys.refreshToken(namespace))
prefs.remove(InstanceKeys.pushToken(namespace))
prefs.remove(InstanceKeys.expiresIn(namespace))
prefs.remove(InstanceKeys.user(namespace))
prefs.remove(InstanceKeys.account(namespace))
Expand All @@ -176,6 +176,14 @@ class PreferencesManager(
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 ->
Expand All @@ -195,8 +203,8 @@ class PreferencesManager(
set(value) = setEncryptedString(InstanceKeys.refreshToken(ns), value)

override var pushToken: String
get() = getEncryptedString(InstanceKeys.pushToken(ns), "")
set(value) = setEncryptedString(InstanceKeys.pushToken(ns), value)
get() = getEncryptedString(Keys.PUSH_TOKEN, "")
set(value) = setEncryptedString(Keys.PUSH_TOKEN, value)

override var accessTokenExpiresAt: Long
get() = getValue(InstanceKeys.expiresIn(ns), 0L)
Expand Down Expand Up @@ -247,6 +255,14 @@ class PreferencesManager(
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(InstanceKeys.account(ns), "")
Expand Down
21 changes: 15 additions & 6 deletions app/src/main/java/org/openedx/app/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.openedx.app.di

import androidx.appcompat.app.AppCompatActivity
import androidx.room.RoomDatabase
import com.google.android.play.core.review.ReviewManagerFactory
import com.google.gson.Gson
import com.google.gson.GsonBuilder
Expand All @@ -16,7 +15,6 @@ 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.DatabaseManager
import org.openedx.app.room.InstanceDatabaseProvider
import org.openedx.app.room.InstanceScopedCalendarDao
Expand All @@ -25,6 +23,7 @@ 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
Expand All @@ -41,12 +40,14 @@ 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
Expand Down Expand Up @@ -129,6 +130,17 @@ val appModule = module {
single<InstanceCatalogCache> { SharedPrefsInstanceCatalogCache(get()) }
single<InstanceApiService> { OkHttpInstanceApiService(get<Config>().getInstancesCatalogUrl()) }
single { InstanceConfigLoader(get(), get(), { get<Config>().getInstancesConfig() }) }
single { PushRegistrations(get(), get(), get(), get()) }
single<InstanceSessionManager> {
DefaultInstanceSessionManager(
get(),
get(),
get(),
get(),
get(),
pushTokenUnregistrar = get<PushRegistrations>()
)
}
single { PreferencesManager(get(), get(), get()) }
single<CorePreferences> { get<PreferencesManager>() }
single<ProfilePreferences> { get<PreferencesManager>() }
Expand All @@ -142,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<IDatabaseManager> { get<DatabaseManager>() }

single { ImageProcessor(get()) }
Expand Down Expand Up @@ -184,9 +196,6 @@ val appModule = module {

single { InstanceDatabaseProvider(androidApplication(), get(), get()) }
single<InstanceDatabases> { get<InstanceDatabaseProvider>() }
// Resolved on every injection: the database belongs to whichever instance is selected.
factory<AppDatabase> { get<InstanceDatabaseProvider>().current }
factory<RoomDatabase> { get<AppDatabase>() }

single<DiscoveryDao> { InstanceScopedDiscoveryDao(get()) }
single<CourseDao> { InstanceScopedCourseDao(get()) }
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/org/openedx/app/di/NetworkingModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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
Expand All @@ -24,14 +25,17 @@ import java.util.concurrent.TimeUnit
val networkingModule = module {

single { OauthRefreshTokenAuthenticator(get(), get(), get(), get()) }
single { InstanceHostInterceptor(get<Config>().getApiHostURL(), get()) }
single {
val store = get<InstanceStore>()
InstanceHostInterceptor(get<Config>().getApiHostURL(), store) { store.instance(it) }
}

single {
OkHttpClient.Builder().apply {
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
addInterceptor(get<InstanceHostInterceptor>())
addInterceptor(HeadersInterceptor(get(), get(), get(), get()))
addInterceptor(HeadersInterceptor(get(), get(), get(), get()) { get<InstanceStore>().instance(it) })
if (BuildConfig.DEBUG) {
addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
Expand Down
Loading
Loading