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

Filter by extension

Filter by extension

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

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

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

@RunWith(AndroidJUnit4::class)
class InstanceDatabaseProviderTest {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

selection.flow.value = alpha
assertEquals(7, calendar.readCourseCalendarStateById("course")?.checksum)
}
}
41 changes: 25 additions & 16 deletions app/src/main/java/org/openedx/app/AppActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStarted
import androidx.window.layout.WindowMetricsCalculator
import com.braze.support.toStringMap
import io.branch.referral.Branch
import io.branch.referral.Branch.BranchUniversalReferralInitListener
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
Expand Down Expand Up @@ -92,7 +94,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
installSplashScreen().setKeepOnScreenCondition { !viewModel.isInstanceCatalogResolved.value }
binding = ActivityAppBinding.inflate(layoutInflater)
lifecycle.addObserver(viewModel)
viewModel.logAppLaunchEvent()
Expand Down Expand Up @@ -155,24 +157,31 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
}

private fun setupInitialFragment(savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
when {
corePreferencesManager.user == null -> {
val fragment = if (viewModel.isLogistrationEnabled && authCode == null) {
LogistrationFragment()
} else {
SignInFragment.newInstance(null, null)
}
addFragment(fragment)
}
// A recreated activity gets its screens back from the fragment manager.
if (savedInstanceState != null) return
lifecycleScope.launch {
viewModel.isInstanceCatalogResolved.first { it }
lifecycle.withStarted { showInitialFragment() }
}
}

whatsNewManager.shouldShowWhatsNew() -> addFragment(WhatsNewFragment.newInstance())
else -> addFragment(MainFragment.newInstance())
private fun showInitialFragment() {
when {
corePreferencesManager.user == null -> {
val fragment = if (viewModel.isLogistrationEnabled && authCode == null) {
LogistrationFragment()
} else {
SignInFragment.newInstance(null, null)
}
addFragment(fragment)
}

intent.extras?.takeIf { it.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) }?.let {
handlePushNotification(it)
}
whatsNewManager.shouldShowWhatsNew() -> addFragment(WhatsNewFragment.newInstance())
else -> addFragment(MainFragment.newInstance())
}

intent.extras?.takeIf { it.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) }?.let {
handlePushNotification(it)
}
}

Expand Down
50 changes: 38 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,24 @@ 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.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
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.InstanceConfigLoader
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 +40,11 @@ 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 instanceConfigLoader: InstanceConfigLoader,
private val pushRegistrations: PushRegistrations,
private val analytics: AppAnalytics,
private val deepLinkRouter: DeepLinkRouter,
private val fileUtil: FileUtil,
Expand All @@ -55,8 +61,29 @@ class AppViewModel(
val downloadFailedDialog: SharedFlow<DownloadFailed>
get() = _downloadFailedDialog.asSharedFlow()

private val _isInstanceCatalogResolved = MutableStateFlow(false)

/**
* Whether the instance catalog for this launch is in place. The first screen depends on it
* (a site picker, or a site's own sign-in), so the activity routes only once it is.
*/
val isInstanceCatalogResolved: StateFlow<Boolean> = _isInstanceCatalogResolved.asStateFlow()

val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled()

init {
viewModelScope.launch { resolveInstanceCatalog() }
}

private suspend fun resolveInstanceCatalog() {
// Falls back to the cached or bundled catalog when the fetch fails or times out.
instanceStore.updateInstancesConfig(instanceConfigLoader.load())
// A selection restored from storage doesn't go through a switch, so nothing else
// applies its branding.
sessionManager.applyThemeForCurrentInstance()
_isInstanceCatalogResolved.value = true
}

private var logoutHandledAt: Long = 0

val isBranchEnabled get() = config.getBranchConfig().enabled
Expand All @@ -69,8 +96,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 +107,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 +151,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,
)
}
Loading
Loading