Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
efacd2e
feat: add instance domain model and static config
IvanStepanok Oct 19, 2026
b15a3e7
feat: add instance selection store (InstanceStore)
IvanStepanok Oct 19, 2026
16e15de
feat: add per-instance theme engine
IvanStepanok Oct 19, 2026
b8ef84a
feat: fetch the instance catalog from a remote URL
IvanStepanok Oct 19, 2026
a795b26
feat: route API requests to the selected instance
IvanStepanok Oct 19, 2026
4920b7a
feat: namespace local storage keys by instance
IvanStepanok Oct 19, 2026
5406ec9
fix: keep a token refresh on the site that started it
IvanStepanok Oct 19, 2026
0bd08eb
feat: scope the database and downloads storage by instance
IvanStepanok Oct 19, 2026
c9a933f
fix: keep offline progress sync on the site it started on
IvanStepanok Oct 19, 2026
ad5815f
feat: add InstanceSessionManager (switch + logout) and route logout t…
IvanStepanok Oct 19, 2026
b55e24e
fix: sign-out and switching stay within one site
IvanStepanok Oct 19, 2026
7f2e607
fix: sign-in, sign-out and push registration stay with their site
IvanStepanok Oct 19, 2026
28a5796
fix: a sign-out finishes on the site it started on
IvanStepanok Oct 20, 2026
da8d21b
feat: app launch sequencing and InstanceAwareConfig
IvanStepanok Oct 19, 2026
e1c5d03
feat: Learning Sites picker (multi-instance switching UI)
IvanStepanok Oct 19, 2026
0d5b25b
fix: match the iOS Learning Sites, Sign In and Settings layouts
IvanStepanok Oct 19, 2026
78613d9
fix: no site can be picked while a sign-out is in progress
IvanStepanok Oct 20, 2026
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)
}
}
44 changes: 28 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,15 +12,18 @@ 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
import org.openedx.app.databinding.ActivityAppBinding
import org.openedx.app.deeplink.DeepLink
import org.openedx.auth.presentation.learningsites.LearningSitesFragment
import org.openedx.auth.presentation.logistration.LogistrationFragment
import org.openedx.auth.presentation.signin.SignInFragment
import org.openedx.core.data.storage.CorePreferences
Expand Down Expand Up @@ -92,7 +95,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 +158,33 @@ 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 {
viewModel.startsWithSiteSelection -> addFragment(LearningSitesFragment.newInstance(isSwitching = false))

intent.extras?.takeIf { it.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) }?.let {
handlePushNotification(it)
corePreferencesManager.user == null -> {
val fragment = if (viewModel.isLogistrationEnabled && authCode == null) {
LogistrationFragment()
} else {
SignInFragment.newInstance(null, null)
}
addFragment(fragment)
}

whatsNewManager.shouldShowWhatsNew() -> addFragment(WhatsNewFragment.newInstance())
else -> addFragment(MainFragment.newInstance())
}

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

Expand Down
20 changes: 18 additions & 2 deletions app/src/main/java/org/openedx/app/AppRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import org.openedx.app.deeplink.HomeTab
import org.openedx.auth.presentation.AuthRouter
import org.openedx.auth.presentation.learningsites.LearningSitesFragment
import org.openedx.auth.presentation.logistration.LogistrationFragment
import org.openedx.auth.presentation.restore.RestorePasswordFragment
import org.openedx.auth.presentation.signin.SignInFragment
import org.openedx.auth.presentation.signup.SignUpFragment
import org.openedx.core.CalendarRouter
import org.openedx.core.FragmentViewType
import org.openedx.core.instance.InstanceProvider
import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter
import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment
import org.openedx.core.presentation.global.webview.SSOWebContentFragment
Expand Down Expand Up @@ -61,7 +63,9 @@ import org.openedx.profile.presentation.video.VideoSettingsFragment
import org.openedx.whatsnew.WhatsNewRouter
import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment

class AppRouter :
class AppRouter(
private val instanceProvider: InstanceProvider,
) :
AuthRouter,
DiscoveryRouter,
DashboardRouter,
Expand Down Expand Up @@ -403,10 +407,22 @@ class AppRouter :
)
}

override fun navigateToLearningSites(fm: FragmentManager, isSwitching: Boolean) {
if (isSwitching) {
replaceFragmentWithBackStack(fm, LearningSitesFragment.newInstance(isSwitching = true))
} else {
clearBackStack(fm)
replaceFragment(fm, LearningSitesFragment.newInstance(isSwitching = false))
}
}

override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) {
fm.apply {
clearBackStack(this)
if (isLogistrationEnabled) {
// A multi-site build always starts over at site selection: sign-in belongs to a site.
if (instanceProvider.hasMultipleInstances) {
replaceFragment(fm, LearningSitesFragment.newInstance(isSwitching = false))
} else if (isLogistrationEnabled) {
replaceFragment(fm, LogistrationFragment())
} else {
replaceFragment(fm, SignInFragment.newInstance(null, null))
Expand Down
Loading
Loading