diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CookieConsentModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CookieConsentModule.kt
new file mode 100644
index 0000000000..82662bfc1b
--- /dev/null
+++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CookieConsentModule.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.parentapp.di.feature
+
+import com.instructure.canvasapi2.apis.UserAPI
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.features.cookieconsent.AnalyticsConsentHandler
+import com.instructure.pandautils.features.cookieconsent.CookieConsentNamespace
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+class CookieConsentModule {
+
+ @Provides
+ fun provideCookieConsentNamespace(): CookieConsentNamespace {
+ return CookieConsentNamespace.PARENT
+ }
+
+ @Provides
+ fun provideAnalyticsConsentHandler(
+ userApi: UserAPI.UsersInterface,
+ featureFlagProvider: FeatureFlagProvider,
+ consentPrefs: ConsentPrefs,
+ apiPrefs: ApiPrefs
+ ): AnalyticsConsentHandler {
+ return object : AnalyticsConsentHandler(userApi, featureFlagProvider, consentPrefs, apiPrefs) {}
+ }
+}
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/LoginModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/LoginModule.kt
index 92b23e3f60..3a16d51efd 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/LoginModule.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/LoginModule.kt
@@ -26,9 +26,11 @@ import com.instructure.canvasapi2.utils.Analytics
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.loginapi.login.LoginNavigation
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
import com.instructure.loginapi.login.util.LoginPrefs
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.parentapp.features.login.ParentAcceptableUsePolicyRouter
+import com.instructure.parentapp.features.login.ParentCookieConsentRouter
import com.instructure.parentapp.features.login.ParentLoginNavigation
import com.instructure.parentapp.features.login.SignInActivity
import dagger.Module
@@ -59,6 +61,11 @@ class LoginModule {
): LoginNavigation {
return ParentLoginNavigation(activity, alarmScheduler)
}
+
+ @Provides
+ fun provideCookieConsentRouter(activity: FragmentActivity): CookieConsentRouter {
+ return ParentCookieConsentRouter(activity)
+ }
}
@Module
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentAcceptableUsePolicyRouter.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentAcceptableUsePolicyRouter.kt
index 20d973531e..403484184c 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentAcceptableUsePolicyRouter.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentAcceptableUsePolicyRouter.kt
@@ -22,10 +22,10 @@ import androidx.fragment.app.FragmentActivity
import com.instructure.canvasapi2.utils.Analytics
import com.instructure.canvasapi2.utils.AnalyticsEventConstants
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.loginapi.login.tasks.LogoutTask
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.parentapp.R
-import com.instructure.parentapp.features.main.MainActivity
import com.instructure.parentapp.features.webview.HtmlContentActivity
import com.instructure.parentapp.util.ParentLogoutTask
@@ -42,8 +42,9 @@ class ParentAcceptableUsePolicyRouter(
override fun startApp() {
CookieManager.getInstance().flush()
- val intent = Intent(activity, MainActivity::class.java)
+ val intent = Intent(activity, CookieConsentActivity::class.java)
activity.intent?.extras?.let { intent.putExtras(it) }
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
activity.startActivity(intent)
}
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentCookieConsentRouter.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentCookieConsentRouter.kt
new file mode 100644
index 0000000000..98c66750f3
--- /dev/null
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/ParentCookieConsentRouter.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.parentapp.features.login
+
+import android.content.Intent
+import androidx.fragment.app.FragmentActivity
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
+import com.instructure.parentapp.features.main.MainActivity
+
+class ParentCookieConsentRouter(
+ private val activity: FragmentActivity
+) : CookieConsentRouter {
+
+ override fun startApp() {
+ val intent = Intent(activity, MainActivity::class.java)
+ activity.intent?.extras?.let { intent.putExtras(it) }
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ activity.startActivity(intent)
+ }
+}
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorAction.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorAction.kt
index 32385dabf0..20d7717bea 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorAction.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorAction.kt
@@ -29,4 +29,5 @@ sealed class RouteValidatorAction {
data class ShowToast(val message: String) : RouteValidatorAction()
data class StartSignInActivity(val accountDomain: AccountDomain) : RouteValidatorAction()
data object StartLoginActivity : RouteValidatorAction()
+ data object StartCookieConsent : RouteValidatorAction()
}
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt
index bb7a1081df..5d3f44dd4c 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt
@@ -30,6 +30,7 @@ import com.instructure.pandautils.binding.viewBinding
import com.instructure.pandautils.utils.Const
import com.instructure.pandautils.utils.collectOneOffEvents
import com.instructure.parentapp.databinding.ActivityRouteValidatorBinding
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.parentapp.features.login.LoginActivity
import com.instructure.parentapp.features.login.SignInActivity
import com.instructure.parentapp.features.main.MainActivity
@@ -60,6 +61,7 @@ class RouteValidatorActivity : BaseCanvasActivity() {
is RouteValidatorAction.ShowToast -> Toast.makeText(this, action.message, Toast.LENGTH_LONG).show()
is RouteValidatorAction.StartSignInActivity -> startSignInActivity(action.accountDomain)
is RouteValidatorAction.StartLoginActivity -> startLoginActivity()
+ is RouteValidatorAction.StartCookieConsent -> startCookieConsent()
}
}
@@ -92,6 +94,13 @@ class RouteValidatorActivity : BaseCanvasActivity() {
finish()
}
+ private fun startCookieConsent() {
+ val intent = Intent(this, CookieConsentActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ startActivity(intent)
+ finish()
+ }
+
companion object {
fun createIntent(context: Context, uri: Uri): Intent {
val intent = Intent(context, RouteValidatorActivity::class.java)
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorViewModel.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorViewModel.kt
index 63448e453d..51b7c66876 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorViewModel.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorViewModel.kt
@@ -108,7 +108,7 @@ class RouteValidatorViewModel @Inject constructor(
} else {
// Log the analytics - only for real logins, not masquerading
logQREvent(apiPrefs.domain, true)
- postActionWithDelay(RouteValidatorAction.StartMainActivity())
+ postActionWithDelay(RouteValidatorAction.StartCookieConsent)
}
return@tryLaunch
} catch (e: Throwable) {
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviour.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviour.kt
index 16a0c73b6b..cd19f74c9c 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviour.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviour.kt
@@ -26,7 +26,7 @@ class ParentSettingsBehaviour(private val selectedStudentHolder: SelectedStudent
get() = mapOf(
R.string.preferences to listOf(SettingsItem.APP_THEME),
R.string.inboxSettingsTitle to listOf(SettingsItem.INBOX_SIGNATURE),
- R.string.legal to listOf(SettingsItem.ABOUT, SettingsItem.LEGAL)
+ R.string.legal to listOf(SettingsItem.ABOUT, SettingsItem.LEGAL, SettingsItem.PRIVACY)
)
override suspend fun applyAppSpecificColorSettings() {
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsRouter.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsRouter.kt
index 09814a3b77..b8dd82c587 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsRouter.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/settings/ParentSettingsRouter.kt
@@ -24,4 +24,8 @@ class ParentSettingsRouter(private val navigation: Navigation, private val activ
override fun navigateToInboxSignature() {
navigation.navigate(activity, navigation.inboxSignatureSettings)
}
+
+ override fun navigateToPrivacySettings() {
+ navigation.navigate(activity, navigation.privacySettings)
+ }
}
\ No newline at end of file
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/splash/SplashViewModel.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/splash/SplashViewModel.kt
index c2ca03f046..35e6387b99 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/features/splash/SplashViewModel.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/features/splash/SplashViewModel.kt
@@ -23,6 +23,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.instructure.canvasapi2.models.User
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.weave.catch
import com.instructure.canvasapi2.utils.weave.tryLaunch
import com.instructure.pandautils.utils.ColorKeeper
@@ -43,6 +44,7 @@ class SplashViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: SplashRepository,
private val apiPrefs: ApiPrefs,
+ private val consentPrefs: ConsentPrefs,
private val colorKeeper: ColorKeeper,
private val featureFlagProvider: FeatureFlagProvider,
savedStateHandle: SavedStateHandle
@@ -83,18 +85,20 @@ class SplashViewModel @Inject constructor(
}
}
- val sendUsageMetrics = repository.getSendUsageMetrics()
- if (sendUsageMetrics) {
- val userWithIds = repository.getSelfWithUuid()
- val visitorData = mapOf(
- "locale" to apiPrefs.effectiveLocale,
- )
- val accountData = mapOf(
- "surveyOptOut" to featureFlagProvider.checkAccountSurveyNotificationsFlag()
- )
- Pendo.startSession(userWithIds?.uuid?.SHA256().orEmpty(), userWithIds?.accountUuid.orEmpty(), visitorData, accountData)
- } else {
- Pendo.endSession()
+ if (consentPrefs.currentUserConsent == true) {
+ val sendUsageMetrics = repository.getSendUsageMetrics()
+ if (sendUsageMetrics) {
+ val userWithIds = repository.getSelfWithUuid()
+ val visitorData = mapOf(
+ "locale" to apiPrefs.effectiveLocale,
+ )
+ val accountData = mapOf(
+ "surveyOptOut" to featureFlagProvider.checkAccountSurveyNotificationsFlag()
+ )
+ Pendo.startSession(userWithIds?.uuid?.SHA256().orEmpty(), userWithIds?.accountUuid.orEmpty(), visitorData, accountData)
+ } else {
+ Pendo.endSession()
+ }
}
val students = repository.getStudents()
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt b/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt
index 48429754f4..1f729130b1 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt
@@ -28,6 +28,7 @@ import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions
import com.instructure.pandautils.features.lti.LtiLaunchFragment
import com.instructure.pandautils.features.settings.SettingsFragment
import com.instructure.pandautils.features.settings.inboxsignature.InboxSignatureFragment
+import com.instructure.pandautils.features.privacysettings.PrivacySettingsFragment
import com.instructure.pandautils.utils.Const
import com.instructure.pandautils.utils.fromJson
import com.instructure.pandautils.utils.toJson
@@ -80,6 +81,7 @@ class Navigation(apiPrefs: ApiPrefs) {
val qrPairing = "$baseUrl/qr-pairing"
val settings = "$baseUrl/settings"
val inboxSignatureSettings = "$baseUrl/inboxSignatureSettings"
+ val privacySettings = "$baseUrl/privacySettings"
private fun splashRoute(qrCodeMasqueradeId: Long) = "$baseUrl/splash/$qrCodeMasqueradeId"
fun assignmentDetailsRoute(courseId: Long, assignmentId: Long) = "$baseUrl/courses/${courseId}/assignments/${assignmentId}"
@@ -280,6 +282,7 @@ class Navigation(apiPrefs: ApiPrefs) {
}
}
fragment(inboxSignatureSettings)
+ fragment(privacySettings)
}
}
diff --git a/apps/parent/src/test/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviourTest.kt b/apps/parent/src/test/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviourTest.kt
index e471698479..03c0d6e542 100644
--- a/apps/parent/src/test/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviourTest.kt
+++ b/apps/parent/src/test/java/com/instructure/parentapp/features/settings/ParentSettingsBehaviourTest.kt
@@ -38,7 +38,8 @@ class ParentSettingsBehaviourTest {
R.string.inboxSettingsTitle to listOf(SettingsItem.INBOX_SIGNATURE),
R.string.legal to listOf(
SettingsItem.ABOUT,
- SettingsItem.LEGAL
+ SettingsItem.LEGAL,
+ SettingsItem.PRIVACY
)
)
diff --git a/apps/parent/src/test/java/com/instructure/parentapp/features/splash/SplashViewModelTest.kt b/apps/parent/src/test/java/com/instructure/parentapp/features/splash/SplashViewModelTest.kt
index c1685dd2ef..95e4e8e339 100644
--- a/apps/parent/src/test/java/com/instructure/parentapp/features/splash/SplashViewModelTest.kt
+++ b/apps/parent/src/test/java/com/instructure/parentapp/features/splash/SplashViewModelTest.kt
@@ -27,6 +27,7 @@ import com.instructure.canvasapi2.models.CanvasColor
import com.instructure.canvasapi2.models.CanvasTheme
import com.instructure.canvasapi2.models.User
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.pandautils.utils.ColorKeeper
import com.instructure.pandautils.utils.Const
@@ -67,6 +68,7 @@ class SplashViewModelTest {
private val context: Context = mockk(relaxed = true)
private val repository: SplashRepository = mockk(relaxed = true)
private val apiPrefs: ApiPrefs = mockk(relaxed = true)
+ private val consentPrefs: ConsentPrefs = mockk(relaxed = true)
private val colorKeeper: ColorKeeper = mockk(relaxed = true)
private val savedStateHandle = mockk(relaxed = true)
private val featureFlagProvider = mockk(relaxed = true)
@@ -262,6 +264,7 @@ class SplashViewModelTest {
@Test
fun `Send usage metrics enabled`() = runTest {
coEvery { repository.getSendUsageMetrics() } returns true
+ every { consentPrefs.currentUserConsent } returns true
createViewModel()
@@ -275,6 +278,7 @@ class SplashViewModelTest {
@Test
fun `Send usage metrics disabled`() = runTest {
coEvery { repository.getSendUsageMetrics() } returns false
+ every { consentPrefs.currentUserConsent } returns true
createViewModel()
@@ -290,6 +294,7 @@ class SplashViewModelTest {
context = context,
repository = repository,
apiPrefs = apiPrefs,
+ consentPrefs = consentPrefs,
colorKeeper = colorKeeper,
featureFlagProvider = featureFlagProvider,
savedStateHandle = savedStateHandle
diff --git a/apps/parent/src/test/java/com/instructure/parentapp/login/routevalidator/RouteValidatorViewModelTest.kt b/apps/parent/src/test/java/com/instructure/parentapp/login/routevalidator/RouteValidatorViewModelTest.kt
index 9de1972cc2..91302e5e0f 100644
--- a/apps/parent/src/test/java/com/instructure/parentapp/login/routevalidator/RouteValidatorViewModelTest.kt
+++ b/apps/parent/src/test/java/com/instructure/parentapp/login/routevalidator/RouteValidatorViewModelTest.kt
@@ -166,7 +166,7 @@ class RouteValidatorViewModelTest {
Assert.assertEquals(RouteValidatorAction.LoadWebViewUrl("sessionUrl"), events.last())
delay(800)
- Assert.assertEquals(RouteValidatorAction.StartMainActivity(), events.last())
+ Assert.assertEquals(RouteValidatorAction.StartCookieConsent, events.last())
}
@Test
diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt
index 347748933d..e2726f63bd 100644
--- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt
+++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt
@@ -42,6 +42,7 @@ import com.instructure.canvasapi2.models.User
import com.instructure.canvasapi2.utils.APIHelper
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.canvasapi2.utils.ApiType
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.LinkHeaders
import com.instructure.canvasapi2.utils.Logger
import com.instructure.canvasapi2.utils.RemoteConfigParam
@@ -109,6 +110,9 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No
@Inject
lateinit var apiPrefs: ApiPrefs
+ @Inject
+ lateinit var consentPrefs: ConsentPrefs
+
@Inject
lateinit var courseApi: CourseAPI.CoursesInterface
@@ -214,6 +218,8 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No
}
private suspend fun setupPendoTracking() {
+ if (consentPrefs.currentUserConsent != true) return
+
val user = userApi.getSelfWithUUID(RestParams(isForceReadFromNetwork = true)).dataOrNull
val featureFlagsResult = FeaturesManager.getEnvironmentFeatureFlagsAsync(true).await().dataOrNull
val sendUsageMetrics = featureFlagsResult?.get(FeaturesManager.SEND_USAGE_METRICS) ?: false
diff --git a/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt b/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt
index 2830c1ca57..4f0262b128 100644
--- a/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt
+++ b/apps/student/src/main/java/com/instructure/student/activity/InterwebsToApplication.kt
@@ -37,6 +37,7 @@ import com.instructure.canvasapi2.utils.weave.apiAsync
import com.instructure.canvasapi2.utils.weave.catch
import com.instructure.canvasapi2.utils.weave.tryWeave
import com.instructure.horizon.HorizonActivity
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.loginapi.login.tasks.LogoutTask
import com.instructure.loginapi.login.util.QRLogin.performSSOLogin
import com.instructure.loginapi.login.util.QRLogin.verifySSOLoginUri
@@ -164,7 +165,7 @@ class InterwebsToApplication : BaseCanvasActivity() {
} else {
// Log the analytics - only for real logins, not masquerading
logQREvent(ApiPrefs.domain, true)
- Intent(this@InterwebsToApplication, NavigationActivity.startActivityClass)
+ Intent(this@InterwebsToApplication, CookieConsentActivity::class.java)
}
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
diff --git a/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt b/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt
index 8d6aa90753..b5db9d71a2 100644
--- a/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt
+++ b/apps/student/src/main/java/com/instructure/student/di/LoginModule.kt
@@ -24,11 +24,13 @@ import com.instructure.canvasapi2.models.AccountDomain
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.loginapi.login.LoginNavigation
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
import com.instructure.loginapi.login.util.LoginPrefs
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.pandautils.room.offline.DatabaseProvider
import com.instructure.student.activity.SignInActivity
import com.instructure.student.features.login.StudentAcceptableUsePolicyRouter
+import com.instructure.student.features.login.StudentCookieConsentRouter
import com.instructure.student.features.login.StudentLoginNavigation
import dagger.Module
import dagger.Provides
@@ -59,6 +61,13 @@ class LoginModule {
): LoginNavigation {
return StudentLoginNavigation(activity, databaseProvider, alarmScheduler)
}
+
+ @Provides
+ fun provideCookieConsentRouter(
+ activity: FragmentActivity
+ ): CookieConsentRouter {
+ return StudentCookieConsentRouter(activity)
+ }
}
@Module
diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/CookieConsentModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/CookieConsentModule.kt
new file mode 100644
index 0000000000..41d0ca69d1
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/di/feature/CookieConsentModule.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.student.di.feature
+
+import com.instructure.canvasapi2.apis.UserAPI
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.features.cookieconsent.AnalyticsConsentHandler
+import com.instructure.pandautils.features.cookieconsent.CookieConsentNamespace
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import com.instructure.student.widget.WidgetLogger
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+class CookieConsentModule {
+
+ @Provides
+ fun provideCookieConsentNamespace(): CookieConsentNamespace {
+ return CookieConsentNamespace.STUDENT
+ }
+
+ @Provides
+ fun provideAnalyticsConsentHandler(
+ userApi: UserAPI.UsersInterface,
+ featureFlagProvider: FeatureFlagProvider,
+ consentPrefs: ConsentPrefs,
+ apiPrefs: ApiPrefs,
+ widgetLogger: WidgetLogger
+ ): AnalyticsConsentHandler {
+ return object : AnalyticsConsentHandler(userApi, featureFlagProvider, consentPrefs, apiPrefs) {
+ override suspend fun beforeStartPendoSession() {
+ widgetLogger.cancelLogging()
+ }
+ }
+ }
+}
diff --git a/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt b/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt
index 4e82ba612e..558a66ff72 100644
--- a/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt
+++ b/apps/student/src/main/java/com/instructure/student/features/login/StudentAcceptableUsePolicyRouter.kt
@@ -20,18 +20,14 @@ import android.content.Intent
import android.webkit.CookieManager
import androidx.fragment.app.FragmentActivity
import com.instructure.canvasapi2.utils.ApiPrefs
-import com.instructure.horizon.HorizonActivity
-import com.instructure.loginapi.login.CANVAS_CAREER
-import com.instructure.loginapi.login.NEXT_GEN_CANVAS
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.loginapi.login.tasks.LogoutTask
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.pandautils.room.offline.DatabaseProvider
import com.instructure.pandautils.services.PushNotificationRegistrationWorker
import com.instructure.student.R
import com.instructure.student.activity.InternalWebViewActivity
-import com.instructure.student.activity.NavigationActivity
-import com.instructure.ngc.NGCActivity
import com.instructure.student.tasks.StudentLogoutTask
class StudentAcceptableUsePolicyRouter(
@@ -50,18 +46,10 @@ class StudentAcceptableUsePolicyRouter(
CookieManager.getInstance().flush()
- val isCanvasCareer = activity.intent?.getBooleanExtra(CANVAS_CAREER, false) ?: false
- val isNextGenCanvas = activity.intent?.getBooleanExtra(NEXT_GEN_CANVAS, false) ?: false
- val intent = when {
- isCanvasCareer -> Intent(activity, HorizonActivity::class.java)
- isNextGenCanvas -> Intent(activity, NGCActivity::class.java)
- else -> Intent(activity, NavigationActivity.startActivityClass)
- }
-
+ val intent = Intent(activity, CookieConsentActivity::class.java)
activity.intent?.extras?.let { extras ->
intent.putExtras(extras)
}
-
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
activity.startActivity(intent)
}
diff --git a/apps/student/src/main/java/com/instructure/student/features/login/StudentCookieConsentRouter.kt b/apps/student/src/main/java/com/instructure/student/features/login/StudentCookieConsentRouter.kt
new file mode 100644
index 0000000000..f160ac3549
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/features/login/StudentCookieConsentRouter.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.student.features.login
+
+import android.content.Intent
+import androidx.fragment.app.FragmentActivity
+import com.instructure.horizon.HorizonActivity
+import com.instructure.loginapi.login.CANVAS_CAREER
+import com.instructure.loginapi.login.NEXT_GEN_CANVAS
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
+import com.instructure.ngc.NGCActivity
+import com.instructure.student.activity.NavigationActivity
+
+class StudentCookieConsentRouter(
+ private val activity: FragmentActivity
+) : CookieConsentRouter {
+
+ override fun startApp() {
+ val isCanvasCareer = activity.intent?.getBooleanExtra(CANVAS_CAREER, false) ?: false
+ val isNextGenCanvas = activity.intent?.getBooleanExtra(NEXT_GEN_CANVAS, false) ?: false
+ val intent = when {
+ isCanvasCareer -> Intent(activity, HorizonActivity::class.java)
+ isNextGenCanvas -> Intent(activity, NGCActivity::class.java)
+ else -> Intent(activity, NavigationActivity.startActivityClass)
+ }
+
+ activity.intent?.extras?.let { extras ->
+ intent.putExtras(extras)
+ }
+
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ activity.startActivity(intent)
+ }
+}
diff --git a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt
index f735e4312f..c832a895ce 100644
--- a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt
+++ b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt
@@ -55,7 +55,7 @@ class StudentSettingsBehaviour(
R.string.preferences to preferencesList,
R.string.inboxSettingsTitle to listOf(SettingsItem.INBOX_SIGNATURE),
R.string.offlineContent to listOf(SettingsItem.OFFLINE_SYNCHRONIZATION),
- R.string.legal to listOf(SettingsItem.ABOUT, SettingsItem.LEGAL)
+ R.string.legal to listOf(SettingsItem.ABOUT, SettingsItem.LEGAL, SettingsItem.PRIVACY)
)
}
diff --git a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsRouter.kt b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsRouter.kt
index e121505a6f..ef06b46066 100644
--- a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsRouter.kt
+++ b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsRouter.kt
@@ -22,6 +22,7 @@ import com.instructure.pandautils.features.notification.preferences.EmailNotific
import com.instructure.pandautils.features.notification.preferences.PushNotificationPreferencesFragment
import com.instructure.pandautils.features.offline.sync.settings.SyncSettingsFragment
import com.instructure.pandautils.features.settings.SettingsRouter
+import com.instructure.pandautils.features.privacysettings.PrivacySettingsFragment
import com.instructure.pandautils.features.settings.inboxsignature.InboxSignatureFragment
import com.instructure.pandautils.fragments.RemoteConfigParamsFragment
import com.instructure.student.activity.NothingToSeeHereFragment
@@ -102,4 +103,11 @@ class StudentSettingsRouter(
Route(null, InboxSignatureFragment::class.java)
)
}
+
+ override fun navigateToPrivacySettings() {
+ RouteMatcher.route(
+ activity,
+ Route(null, PrivacySettingsFragment::class.java)
+ )
+ }
}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt
index fa402a630b..f1da0ee3e3 100644
--- a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt
+++ b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt
@@ -25,6 +25,7 @@ import com.instructure.pandautils.features.notification.preferences.PushNotifica
import com.instructure.pandautils.features.offline.offlinecontent.OfflineContentFragment
import com.instructure.pandautils.features.offline.sync.progress.SyncProgressFragment
import com.instructure.pandautils.features.offline.sync.settings.SyncSettingsFragment
+import com.instructure.pandautils.features.privacysettings.PrivacySettingsFragment
import com.instructure.pandautils.features.settings.inboxsignature.InboxSignatureFragment
import com.instructure.pandautils.features.smartsearch.SmartSearchFragment
import com.instructure.pandautils.fragments.RemoteConfigParamsFragment
@@ -189,6 +190,7 @@ object RouteResolver {
cls.isA() -> RemoteConfigParamsFragment()
cls.isA() -> SmartSearchFragment.newInstance(route)
cls.isA() -> InboxSignatureFragment()
+ cls.isA() -> PrivacySettingsFragment.newInstance(route)
cls.isA() -> CustomizeDashboardFragment.newInstance(route)
cls.isA() -> CustomizeCourseFragment.newInstance(route)
cls.isA() -> InternalWebviewFragment.newInstance(route) // Keep this at the end
diff --git a/apps/student/src/main/java/com/instructure/student/util/AppManager.kt b/apps/student/src/main/java/com/instructure/student/util/AppManager.kt
index 2ef7fd5d13..312b8264a9 100644
--- a/apps/student/src/main/java/com/instructure/student/util/AppManager.kt
+++ b/apps/student/src/main/java/com/instructure/student/util/AppManager.kt
@@ -24,6 +24,7 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerFactory
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.MasqueradeHelper
import com.instructure.canvasapi2.utils.PendoInitCallbackHandler
import com.instructure.loginapi.login.tasks.LogoutTask
@@ -67,7 +68,9 @@ class AppManager : BaseAppManager() {
).execute()
}
- schedulePandataUpload()
+ if (ConsentPrefs.currentUserConsent == true) {
+ schedulePandataUpload()
+ }
initPendo()
}
diff --git a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListNetworkDataSourceTest.kt b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListNetworkDataSourceTest.kt
index c2b7cd8456..1be7cd4f75 100644
--- a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListNetworkDataSourceTest.kt
+++ b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListNetworkDataSourceTest.kt
@@ -25,6 +25,7 @@ import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.canvasapi2.models.Conference
import com.instructure.canvasapi2.models.ConferenceList
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.DataResult
import com.instructure.canvasapi2.utils.LinkHeaders
import com.instructure.student.mobius.conferences.conference_list.datasource.ConferenceListNetworkDataSource
@@ -49,7 +50,8 @@ class ConferenceListNetworkDataSourceTest {
@Before
fun setup() {
mockkObject(ApiPrefs)
- every { ApiPrefs.mobileConsent } returns true
+ mockkObject(ConsentPrefs)
+ every { ConsentPrefs.currentUserConsent } returns true
networkDataSource = ConferenceListNetworkDataSource(conferencesApi, oAuthApi)
}
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt
index 1d662d7c8c..99ad1786d3 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt
@@ -35,6 +35,7 @@ import com.instructure.canvasapi2.utils.weave.catch
import com.instructure.canvasapi2.utils.weave.tryWeave
import com.instructure.interactions.router.RouteContext
import com.instructure.interactions.router.RouterParams
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.loginapi.login.tasks.LogoutTask
import com.instructure.loginapi.login.util.QRLogin
import com.instructure.loginapi.login.util.QRLogin.verifySSOLoginUri
@@ -124,7 +125,7 @@ class RouteValidatorActivity : BaseCanvasActivity() {
LoginActivity.createLaunchApplicationMainActivityIntent(this@RouteValidatorActivity, extras)
} else {
logQREvent(ApiPrefs.domain, true)
- LoginActivity.createLaunchApplicationMainActivityIntent(this@RouteValidatorActivity, null)
+ Intent(this@RouteValidatorActivity, CookieConsentActivity::class.java)
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/SplashActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/SplashActivity.kt
index e600699ad2..44e012d39a 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/activities/SplashActivity.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/SplashActivity.kt
@@ -33,6 +33,7 @@ import com.instructure.canvasapi2.models.CanvasColor
import com.instructure.canvasapi2.models.CanvasTheme
import com.instructure.canvasapi2.models.User
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.Logger
import com.instructure.canvasapi2.utils.weave.StatusCallbackError
import com.instructure.canvasapi2.utils.weave.awaitApi
@@ -233,6 +234,8 @@ class SplashActivity : BaseCanvasActivity() {
}
private suspend fun setupPendoTracking(user: User) {
+ if (ConsentPrefs.currentUserConsent != true) return
+
val featureFlagsResult = FeaturesManager.getEnvironmentFeatureFlagsAsync(true).await().dataOrNull
val sendUsageMetrics = featureFlagsResult?.get(FeaturesManager.SEND_USAGE_METRICS) ?: false
if (sendUsageMetrics) {
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/CookieConsentModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/CookieConsentModule.kt
new file mode 100644
index 0000000000..972c229ddb
--- /dev/null
+++ b/apps/teacher/src/main/java/com/instructure/teacher/di/CookieConsentModule.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.teacher.di
+
+import com.instructure.canvasapi2.apis.UserAPI
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.features.cookieconsent.AnalyticsConsentHandler
+import com.instructure.pandautils.features.cookieconsent.CookieConsentNamespace
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+class CookieConsentModule {
+
+ @Provides
+ fun provideCookieConsentNamespace(): CookieConsentNamespace {
+ return CookieConsentNamespace.TEACHER
+ }
+
+ @Provides
+ fun provideAnalyticsConsentHandler(
+ userApi: UserAPI.UsersInterface,
+ featureFlagProvider: FeatureFlagProvider,
+ consentPrefs: ConsentPrefs,
+ apiPrefs: ApiPrefs
+ ): AnalyticsConsentHandler {
+ return object : AnalyticsConsentHandler(userApi, featureFlagProvider, consentPrefs, apiPrefs) {}
+ }
+}
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/LoginModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/LoginModule.kt
index a95c94075b..32f598caaf 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/di/LoginModule.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/di/LoginModule.kt
@@ -24,10 +24,12 @@ import com.instructure.canvasapi2.models.AccountDomain
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.loginapi.login.LoginNavigation
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
import com.instructure.loginapi.login.util.LoginPrefs
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.teacher.activities.SignInActivity
import com.instructure.teacher.features.login.TeacherAcceptableUsePolicyRouter
+import com.instructure.teacher.features.login.TeacherCookieConsentRouter
import com.instructure.teacher.features.login.TeacherLoginNavigation
import dagger.Module
import dagger.Provides
@@ -50,6 +52,11 @@ class LoginModule {
fun provideLoginNavigation(activity: FragmentActivity, alarmScheduler: AlarmScheduler): LoginNavigation {
return TeacherLoginNavigation(activity, alarmScheduler)
}
+
+ @Provides
+ fun provideCookieConsentRouter(activity: FragmentActivity): CookieConsentRouter {
+ return TeacherCookieConsentRouter(activity)
+ }
}
@Module
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherAcceptableUsePolicyRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherAcceptableUsePolicyRouter.kt
index 18c5402a4e..77d9ea74cf 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherAcceptableUsePolicyRouter.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherAcceptableUsePolicyRouter.kt
@@ -20,12 +20,12 @@ import android.content.Intent
import androidx.fragment.app.FragmentActivity
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.loginapi.login.tasks.LogoutTask
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.pandautils.services.PushNotificationRegistrationWorker
import com.instructure.teacher.R
import com.instructure.teacher.activities.InternalWebViewActivity
-import com.instructure.teacher.activities.SplashActivity
import com.instructure.teacher.tasks.TeacherLogoutTask
class TeacherAcceptableUsePolicyRouter(
@@ -41,8 +41,8 @@ class TeacherAcceptableUsePolicyRouter(
override fun startApp() {
PushNotificationRegistrationWorker.scheduleJob(activity, ApiPrefs.isMasquerading)
- val intent = SplashActivity.createIntent(activity, activity.intent?.extras)
-
+ val intent = Intent(activity, CookieConsentActivity::class.java)
+ activity.intent?.extras?.let { intent.putExtras(it) }
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
activity.startActivity(intent)
}
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherCookieConsentRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherCookieConsentRouter.kt
new file mode 100644
index 0000000000..c82e265bfe
--- /dev/null
+++ b/apps/teacher/src/main/java/com/instructure/teacher/features/login/TeacherCookieConsentRouter.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.teacher.features.login
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
+import com.instructure.pandautils.services.PushNotificationRegistrationWorker
+import com.instructure.teacher.activities.SplashActivity
+
+class TeacherCookieConsentRouter(
+ private val activity: FragmentActivity
+) : CookieConsentRouter {
+
+ override fun startApp() {
+ PushNotificationRegistrationWorker.scheduleJob(activity, ApiPrefs.isMasquerading)
+ val intent = SplashActivity.createIntent(activity, activity.intent?.extras)
+ intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
+ activity.startActivity(intent)
+ }
+}
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsBehaviour.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsBehaviour.kt
index 780a1c344d..cc3eb6e8bd 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsBehaviour.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsBehaviour.kt
@@ -40,7 +40,8 @@ class TeacherSettingsBehaviour : SettingsBehaviour {
R.string.inboxSettingsTitle to listOf(SettingsItem.INBOX_SIGNATURE),
R.string.legal to listOf(
SettingsItem.ABOUT,
- SettingsItem.LEGAL
+ SettingsItem.LEGAL,
+ SettingsItem.PRIVACY
)
)
}
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsRouter.kt
index ae072849b8..8e3e801368 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsRouter.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/features/settings/TeacherSettingsRouter.kt
@@ -22,6 +22,7 @@ import com.instructure.pandautils.dialogs.RatingDialog
import com.instructure.pandautils.features.notification.preferences.EmailNotificationPreferencesFragment
import com.instructure.pandautils.features.notification.preferences.PushNotificationPreferencesFragment
import com.instructure.pandautils.features.settings.SettingsRouter
+import com.instructure.pandautils.features.privacysettings.PrivacySettingsFragment
import com.instructure.pandautils.features.settings.inboxsignature.InboxSignatureFragment
import com.instructure.pandautils.fragments.RemoteConfigParamsFragment
import com.instructure.pandautils.utils.AppType
@@ -76,4 +77,11 @@ class TeacherSettingsRouter(private val activity: FragmentActivity) : SettingsRo
Route(null, InboxSignatureFragment::class.java)
)
}
+
+ override fun navigateToPrivacySettings() {
+ RouteMatcher.route(
+ activity,
+ Route(null, PrivacySettingsFragment::class.java)
+ )
+ }
}
\ No newline at end of file
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/router/RouteResolver.kt b/apps/teacher/src/main/java/com/instructure/teacher/router/RouteResolver.kt
index 4c516842e7..9188b0745e 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/router/RouteResolver.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/router/RouteResolver.kt
@@ -23,6 +23,7 @@ import com.instructure.pandautils.features.lti.LtiLaunchFragment
import com.instructure.pandautils.features.notification.preferences.EmailNotificationPreferencesFragment
import com.instructure.pandautils.features.notification.preferences.PushNotificationPreferencesFragment
import com.instructure.pandautils.features.settings.SettingsFragment
+import com.instructure.pandautils.features.privacysettings.PrivacySettingsFragment
import com.instructure.pandautils.features.settings.inboxsignature.InboxSignatureFragment
import com.instructure.pandautils.features.speedgrader.SpeedGraderFragment
import com.instructure.pandautils.fragments.HtmlContentFragment
@@ -234,6 +235,8 @@ object RouteResolver {
fragment = CreateUpdateEventFragment.newInstance(route)
} else if (InboxSignatureFragment::class.java.isAssignableFrom(cls)) {
fragment = InboxSignatureFragment()
+ } else if (PrivacySettingsFragment::class.java.isAssignableFrom(cls)) {
+ fragment = PrivacySettingsFragment.newInstance(route)
} else if (ModuleProgressionFragment::class.java.isAssignableFrom(cls)) {
fragment = ModuleProgressionFragment.newInstance(route.copy(canvasContext = canvasContext))
} else if (CustomizeDashboardFragment::class.java.isAssignableFrom(cls)) {
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt b/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt
index a368901231..703037aa7b 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt
@@ -21,6 +21,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerFactory
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.pandautils.analytics.pageview.PageViewUploadWorker
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.teacher.BuildConfig
@@ -47,7 +48,9 @@ class AppManager : BaseAppManager() {
override fun onCreate() {
super.onCreate()
- schedulePandataUpload()
+ if (ConsentPrefs.currentUserConsent == true) {
+ schedulePandataUpload()
+ }
initPendo()
}
diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/OAuthAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/OAuthAPI.kt
index 08d014be76..195776578c 100644
--- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/OAuthAPI.kt
+++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/OAuthAPI.kt
@@ -22,6 +22,7 @@ import com.instructure.canvasapi2.builders.RestParams
import com.instructure.canvasapi2.models.AuthenticatedSession
import com.instructure.canvasapi2.models.OAuthTokenResponse
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.DataResult
import com.instructure.canvasapi2.utils.dataResult
import retrofit2.Call
@@ -51,10 +52,10 @@ object OAuthAPI {
@Field("grant_type") grantType: String = "authorization_code"): Call
@GET("/login/session_token")
- fun getAuthenticatedSession(@Query("return_to") targetUrl: String, @Query("mobile_consent") mobileConsent: Boolean = ApiPrefs.mobileConsent): Call
+ fun getAuthenticatedSession(@Query("return_to") targetUrl: String, @Query("mobile_consent") mobileConsent: Boolean = ConsentPrefs.currentUserConsent == true): Call
@GET("/login/session_token")
- suspend fun getAuthenticatedSession(@Query("return_to") targetUrl: String, @Tag params: RestParams, @Query("mobile_consent") mobileConsent: Boolean = ApiPrefs.mobileConsent): DataResult
+ suspend fun getAuthenticatedSession(@Query("return_to") targetUrl: String, @Tag params: RestParams, @Query("mobile_consent") mobileConsent: Boolean = ConsentPrefs.currentUserConsent == true): DataResult
@GET("/api/v1/login/session_token")
fun getAuthenticatedSessionMasquerading(@Query("return_to") targetUrl: String, @Query("as_user_id") userId: Long): Call
diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt
index c9540cdeeb..c56df054aa 100644
--- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt
+++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt
@@ -72,6 +72,7 @@ import com.instructure.canvasapi2.managers.ToDoManager
import com.instructure.canvasapi2.managers.UserManager
import com.instructure.canvasapi2.utils.ApiPrefs
import com.instructure.canvasapi2.utils.CanvasAuthenticator
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.JourneyApiPref
import com.instructure.canvasapi2.utils.pageview.PandataApi
import dagger.Module
@@ -168,6 +169,12 @@ class ApiModule {
return ApiPrefs
}
+ @Provides
+ @Singleton
+ fun provideConsentPrefs(): ConsentPrefs {
+ return ConsentPrefs
+ }
+
@Provides
@Singleton
fun providePlannerApi(): PlannerAPI {
diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt
index 6c3b3639b3..4d80019f3a 100644
--- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt
+++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt
@@ -23,6 +23,7 @@ import sdk.pendo.io.Pendo
object Analytics {
fun logEvent(eventName: String, bundle: Bundle? = null) {
+ if (ConsentPrefs.currentUserConsent != true) return
val map = bundle?.let { bundle ->
bundle.keySet()
.filterNotNull()
@@ -35,6 +36,7 @@ object Analytics {
}
fun logEvent(eventName: String) {
+ if (ConsentPrefs.currentUserConsent != true) return
Pendo.track(eventName, emptyMap())
}
diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt
index 26da4fea53..b1b304cf9e 100644
--- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt
+++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt
@@ -154,8 +154,6 @@ object ApiPrefs : PrefManager(PREFERENCE_FILE_NAME) {
var canSwitchToCanvasCareer by BooleanPref(defaultValue = false)
- var mobileConsent by BooleanPref(defaultValue = false)
-
var webViewAuthenticationTimestamp by LongPref(0)
/**
diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ConsentPrefs.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ConsentPrefs.kt
new file mode 100644
index 0000000000..52c35df52d
--- /dev/null
+++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ConsentPrefs.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.instructure.canvasapi2.utils
+
+object ConsentPrefs : PrefManager("consent-prefs") {
+
+ private var consentMap: HashMap by BooleanMapPref()
+
+ fun getConsent(userId: Long, domain: String): Boolean? = consentMap["$domain:$userId"]
+
+ fun setConsent(userId: Long, domain: String, consent: Boolean) {
+ consentMap = HashMap(consentMap).apply { put("$domain:$userId", consent) }
+ }
+
+ fun removeConsent(userId: Long, domain: String) {
+ consentMap = HashMap(consentMap).apply { remove("$domain:$userId") }
+ }
+
+ val currentUserConsent: Boolean?
+ get() {
+ val userId = ApiPrefs.user?.id ?: return null
+ val domain = ApiPrefs.domain.takeIf { it.isNotBlank() } ?: return null
+ return getConsent(userId, domain)
+ }
+}
diff --git a/libs/horizon/build.gradle.kts b/libs/horizon/build.gradle.kts
index 41d8a6056d..8706047eff 100644
--- a/libs/horizon/build.gradle.kts
+++ b/libs/horizon/build.gradle.kts
@@ -65,6 +65,7 @@ android {
dependencies {
implementation(project(":pandautils"))
+ implementation(project(":login-api-2"))
implementation(Libs.NAVIGATION_COMPOSE)
implementation(Libs.HILT)
diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt
index 4deadaa412..ac45e496ac 100644
--- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt
+++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt
@@ -23,6 +23,10 @@ import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdat
import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoViewModelBehavior
import com.instructure.pandautils.features.calendartodo.details.ToDoRouter
import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior
+import com.instructure.pandautils.features.cookieconsent.AnalyticsConsentHandler
+import com.instructure.pandautils.features.cookieconsent.CookieConsentNamespace
+import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyRouter
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentRouter
import com.instructure.pandautils.features.dashboard.edit.EditDashboardRepository
import com.instructure.pandautils.features.dashboard.edit.EditDashboardRouter
import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter
@@ -96,6 +100,26 @@ object HorizonTestModule {
}
}
+ @Provides
+ fun provideAnalyticsConsentHandler(): AnalyticsConsentHandler {
+ throw NotImplementedError("This is a test module. Implementation not required.")
+ }
+
+ @Provides
+ fun provideCookieConsentRouter(): CookieConsentRouter {
+ throw NotImplementedError("This is a test module. Implementation not required.")
+ }
+
+ @Provides
+ fun provideCookieConsentNamespace(): CookieConsentNamespace {
+ throw NotImplementedError("This is a test module. Implementation not required.")
+ }
+
+ @Provides
+ fun provideAcceptableUsePolicyRouter(): AcceptableUsePolicyRouter {
+ throw NotImplementedError("This is a test module. Implementation not required.")
+ }
+
@Provides
fun provideAppDatabase(): AppDatabase {
throw NotImplementedError("This is a test module. Implementation not required.")
diff --git a/libs/login-api-2/build.gradle b/libs/login-api-2/build.gradle
index e79b601d3b..e20db5adf3 100644
--- a/libs/login-api-2/build.gradle
+++ b/libs/login-api-2/build.gradle
@@ -22,6 +22,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'com.google.devtools.ksp'
apply plugin: 'dagger.hilt.android.plugin'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
static String isTesting() {
if ( System.getenv("IS_TESTING") == "true" ) {
@@ -92,6 +93,7 @@ android {
buildFeatures {
dataBinding true
viewBinding true
+ compose true
}
}
diff --git a/libs/login-api-2/src/main/AndroidManifest.xml b/libs/login-api-2/src/main/AndroidManifest.xml
index 24ad09da00..2d1c4ce00f 100644
--- a/libs/login-api-2/src/main/AndroidManifest.xml
+++ b/libs/login-api-2/src/main/AndroidManifest.xml
@@ -28,5 +28,9 @@
+
+
diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/LoginNavigation.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/LoginNavigation.kt
index 7c42696b3b..48093b93c8 100644
--- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/LoginNavigation.kt
+++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/LoginNavigation.kt
@@ -19,6 +19,7 @@ package com.instructure.loginapi.login
import android.content.Intent
import androidx.fragment.app.FragmentActivity
import com.instructure.loginapi.login.features.acceptableusepolicy.AcceptableUsePolicyActivity
+import com.instructure.loginapi.login.features.cookieconsent.CookieConsentActivity
import com.instructure.loginapi.login.viewmodel.Experience
import com.instructure.loginapi.login.viewmodel.LoginResultAction
import com.instructure.loginapi.login.viewmodel.LoginViewModel
@@ -38,7 +39,7 @@ abstract class LoginNavigation(
event?.getContentIfNotHandled()?.let {
when (it) {
LoginResultAction.TokenNotValid -> logout()
- is LoginResultAction.Login -> startApp(it.experience)
+ is LoginResultAction.Login -> showCookieConsent(it.experience)
is LoginResultAction.ShouldAcceptPolicy -> showPolicy(it.experience)
}
}
@@ -47,8 +48,13 @@ abstract class LoginNavigation(
protected abstract fun logout()
- private fun startApp(experience: Experience) {
- val intent = initMainActivityIntent(experience)
+ private fun showCookieConsent(experience: Experience) {
+ val intent = Intent(activity, CookieConsentActivity::class.java)
+ when (experience) {
+ is Experience.Academic -> intent.putExtra(CANVAS_FOR_ELEMENTARY, experience.elementary)
+ is Experience.Career -> intent.putExtra(CANVAS_CAREER, true)
+ is Experience.NextGenCanvas -> intent.putExtra(NEXT_GEN_CANVAS, true)
+ }
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
activity.startActivity(intent)
activity.finish()
diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/cookieconsent/CookieConsentActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/cookieconsent/CookieConsentActivity.kt
new file mode 100644
index 0000000000..e3adeb0e6f
--- /dev/null
+++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/cookieconsent/CookieConsentActivity.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.loginapi.login.features.cookieconsent
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.instructure.pandautils.base.BaseCanvasActivity
+import com.instructure.pandautils.compose.CanvasTheme
+import com.instructure.pandautils.features.cookieconsent.CookieConsentContent
+import com.instructure.pandautils.features.cookieconsent.CookieConsentViewModel
+import com.instructure.pandautils.utils.EdgeToEdgeHelper
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class CookieConsentActivity : BaseCanvasActivity() {
+
+ private val viewModel: CookieConsentViewModel by viewModels()
+
+ @Inject
+ lateinit var router: CookieConsentRouter
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ EdgeToEdgeHelper.enableEdgeToEdge(this)
+
+ viewModel.checkAndShowIfNeeded()
+
+ setContent {
+ CanvasTheme {
+ val uiState by viewModel.uiState.collectAsState()
+ CookieConsentContent(uiState = uiState)
+ }
+ }
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.uiState.collect { state ->
+ state.consentResult?.let {
+ state.onConsentResultHandled()
+ proceedToApp()
+ }
+ }
+ }
+ }
+ }
+
+ private fun proceedToApp() {
+ router.startApp()
+ finish()
+ }
+
+ @Suppress("DEPRECATION")
+ @Deprecated("Deprecated in Java")
+ override fun onBackPressed() {
+ // Do nothing - consent is required before proceeding
+ }
+}
\ No newline at end of file
diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/cookieconsent/CookieConsentRouter.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/cookieconsent/CookieConsentRouter.kt
new file mode 100644
index 0000000000..58a789d02e
--- /dev/null
+++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/cookieconsent/CookieConsentRouter.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.instructure.loginapi.login.features.cookieconsent
+
+interface CookieConsentRouter {
+ fun startApp()
+}
\ No newline at end of file
diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt
index 4a6bfd5c93..c527dfb2d2 100644
--- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt
+++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/tasks/LogoutTask.kt
@@ -26,6 +26,7 @@ import com.instructure.canvasapi2.builders.RestBuilder
import com.instructure.canvasapi2.managers.CommunicationChannelsManager
import com.instructure.canvasapi2.managers.OAuthManager
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.canvasapi2.utils.JourneyApiPref
import com.instructure.canvasapi2.utils.MasqueradeHelper
@@ -100,6 +101,9 @@ abstract class LogoutTask(
when (type) {
Type.LOGOUT, Type.LOGOUT_NO_LOGIN_FLOW -> {
removeOfflineData(ApiPrefs.user?.id)
+ val userId = ApiPrefs.user?.id
+ val domain = ApiPrefs.domain
+ if (userId != null) ConsentPrefs.removeConsent(userId, domain)
removeUser()
}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/analytics/pageview/PageViewUploadWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/analytics/pageview/PageViewUploadWorker.kt
index 203ef635cd..8ae6eb9582 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/analytics/pageview/PageViewUploadWorker.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/analytics/pageview/PageViewUploadWorker.kt
@@ -21,6 +21,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.instructure.canvasapi2.builders.RestParams
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.Logger
import com.instructure.canvasapi2.utils.isValid
import com.instructure.canvasapi2.utils.pageview.PageViewUpload
@@ -43,11 +44,14 @@ class PageViewUploadWorker @AssistedInject constructor(
private val appKey: PandataInfo.AppKey,
private val pageViewDao: PageViewDao,
private val apiPrefs: ApiPrefs,
+ private val consentPrefs: ConsentPrefs,
private val pandataApi: PandataApi.PandataInterface
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result {
return try {
+ if (consentPrefs.currentUserConsent != true) return Result.success()
+
if (!ApiPrefs.getValidToken()
.isValid() && ApiPrefs.pandataInfo?.isValid != true
) return Result.failure()
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt
index 10df9f19e9..41c40fe0a2 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/CanvasTheme.kt
@@ -30,6 +30,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.RippleConfiguration
import androidx.compose.material.Typography
import androidx.compose.material.ripple.RippleAlpha
+import androidx.compose.material3.LocalTextStyle as Material3LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
@@ -60,13 +61,15 @@ fun CanvasTheme(
body1 = typography.body1.copy(letterSpacing = TextUnit(0.0f, TextUnitType.Sp))
)
) {
+ val textStyle = TextStyle(
+ fontFamily = currentFontFamily,
+ letterSpacing = TextUnit(0f, TextUnitType.Sp)
+ )
CompositionLocalProvider(
LocalRippleConfiguration provides RippleConfiguration(color = colorResource(id = R.color.backgroundDark), getRippleAlpha(isSystemInDarkTheme())),
LocalTextSelectionColors provides getCustomTextSelectionColors(context = LocalContext.current),
- LocalTextStyle provides TextStyle(
- fontFamily = lato,
- letterSpacing = TextUnit(0f, TextUnitType.Sp)
- ),
+ LocalTextStyle provides textStyle,
+ Material3LocalTextStyle provides textStyle,
LocalCourseColor provides courseColor,
content = content
)
@@ -79,17 +82,17 @@ private val lato = FontFamily(
Font(R.font.lato_italic, style = FontStyle.Italic),
)
+private var currentFontFamily: FontFamily = lato
+
private var typography = Typography(
- defaultFontFamily = lato,
+ defaultFontFamily = currentFontFamily,
)
fun overrideComposeFonts(@FontRes fontResource: Int) {
- val newFont = FontFamily(
- Font(fontResource)
- )
+ currentFontFamily = FontFamily(Font(fontResource))
typography = Typography(
- defaultFontFamily = newFont,
+ defaultFontFamily = currentFontFamily,
)
}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/SettingsModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/SettingsModule.kt
index df60a67c1c..3c8e359eed 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/SettingsModule.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/SettingsModule.kt
@@ -21,6 +21,7 @@ import com.instructure.canvasapi2.managers.InboxSettingsManager
import com.instructure.pandautils.features.settings.SettingsBehaviour
import com.instructure.pandautils.features.settings.SettingsRepository
import com.instructure.pandautils.features.settings.SettingsSharedEvents
+import com.instructure.pandautils.utils.FeatureFlagProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -37,9 +38,10 @@ class SettingsModule {
featuresApi: FeaturesAPI.FeaturesInterface,
inboxSettingsManager: InboxSettingsManager,
settingsBehaviour: SettingsBehaviour,
- experienceAPI: ExperienceAPI
+ experienceAPI: ExperienceAPI,
+ featureFlagProvider: FeatureFlagProvider
): SettingsRepository {
- return SettingsRepository(featuresApi, inboxSettingsManager, settingsBehaviour, experienceAPI)
+ return SettingsRepository(featuresApi, inboxSettingsManager, settingsBehaviour, experienceAPI, featureFlagProvider)
}
}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/AnalyticsConsentHandler.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/AnalyticsConsentHandler.kt
new file mode 100644
index 0000000000..fdf97cd1bf
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/AnalyticsConsentHandler.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import com.instructure.canvasapi2.apis.UserAPI
+import com.instructure.canvasapi2.builders.RestParams
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import com.instructure.pandautils.utils.SHA256
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import sdk.pendo.io.Pendo
+
+@OptIn(DelicateCoroutinesApi::class)
+abstract class AnalyticsConsentHandler(
+ private val userApi: UserAPI.UsersInterface,
+ private val featureFlagProvider: FeatureFlagProvider,
+ private val consentPrefs: ConsentPrefs,
+ private val apiPrefs: ApiPrefs
+) {
+
+ fun onConsentGranted() {
+ GlobalScope.launch { startPendoSession() }
+ }
+
+ fun onConsentRevoked() {
+ Pendo.endSession()
+ }
+
+ protected open suspend fun beforeStartPendoSession() = Unit
+
+ private suspend fun startPendoSession() {
+ if (consentPrefs.currentUserConsent != true) return
+
+ val user = userApi.getSelfWithUUID(RestParams(isForceReadFromNetwork = true)).dataOrNull
+ val visitorData = mapOf("locale" to apiPrefs.effectiveLocale)
+ val accountData = mapOf("surveyOptOut" to featureFlagProvider.checkAccountSurveyNotificationsFlag())
+ beforeStartPendoSession()
+ Pendo.startSession(
+ user?.uuid?.SHA256().orEmpty(),
+ user?.accountUuid.orEmpty(),
+ visitorData,
+ accountData
+ )
+ }
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentNamespace.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentNamespace.kt
new file mode 100644
index 0000000000..354e4ddd1b
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentNamespace.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+enum class CookieConsentNamespace(val value: String) {
+ STUDENT("MOBILE_CANVAS_STUDENT_COOKIE_CONSENT"),
+ TEACHER("MOBILE_CANVAS_TEACHER_COOKIE_CONSENT"),
+ PARENT("MOBILE_CANVAS_PARENT_COOKIE_CONSENT")
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentScreen.kt
new file mode 100644
index 0000000000..7047120149
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentScreen.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import android.content.res.Configuration
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.instructure.pandautils.R
+import com.instructure.pandautils.views.CanvasLoadingView
+
+@Composable
+fun CookieConsentContent(
+ uiState: CookieConsentUiState,
+ modifier: Modifier = Modifier
+) {
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ uiState.errorMessage?.let { message ->
+ LaunchedEffect(message) {
+ snackbarHostState.showSnackbar(message)
+ uiState.onErrorDismissed()
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ containerColor = colorResource(R.color.backgroundLightest)
+ ) { paddingValues ->
+ if (!uiState.showDialog) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ if (uiState.loading) {
+ val loadingColorRes = when (uiState.namespace) {
+ CookieConsentNamespace.STUDENT -> R.color.login_studentAppTheme
+ CookieConsentNamespace.TEACHER -> R.color.login_teacherAppTheme
+ CookieConsentNamespace.PARENT -> R.color.login_parentAppTheme
+ }
+ AndroidView(
+ factory = {
+ CanvasLoadingView(it).apply {
+ setOverrideColor(it.getColor(loadingColorRes))
+ }
+ },
+ modifier = Modifier
+ .size(120.dp)
+ .align(Alignment.Center)
+ )
+ }
+ }
+ return@Scaffold
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(paddingValues)
+ .windowInsetsPadding(WindowInsets.safeDrawing)
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(R.string.cookieConsentTitle),
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ color = colorResource(R.color.textDarkest)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.cookieConsentDescription),
+ fontSize = 16.sp,
+ color = colorResource(R.color.textDark)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ BulletItem(text = stringResource(R.string.cookieConsentNoProfiles))
+ Spacer(modifier = Modifier.height(8.dp))
+ BulletItem(text = stringResource(R.string.cookieConsentNoSelling))
+ Spacer(modifier = Modifier.height(8.dp))
+ BulletItem(text = stringResource(R.string.cookieConsentFullControl))
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = uiState.onAllow,
+ enabled = !uiState.saving,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorResource(R.color.backgroundInfo),
+ contentColor = colorResource(R.color.textLightest)
+ )
+ ) {
+ if (uiState.saving) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = colorResource(R.color.white),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.cookieConsentAllow),
+ fontSize = 16.sp
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedButton(
+ onClick = uiState.onDecline,
+ enabled = !uiState.saving,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.outlinedButtonColors(
+ containerColor = colorResource(R.color.backgroundLightest),
+ contentColor = colorResource(R.color.textDarkest),
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.cookieConsentDecline),
+ fontSize = 16.sp,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BulletItem(text: String) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "\u2022",
+ fontSize = 16.sp,
+ color = colorResource(R.color.textDark)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = text,
+ fontSize = 14.sp,
+ color = colorResource(R.color.textDark)
+ )
+ }
+}
+
+@Preview
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, backgroundColor = 0x0000000)
+@Composable
+fun CookieConsentContentPreview() {
+ CookieConsentContent(
+ uiState = CookieConsentUiState(
+ showDialog = true,
+ saving = false,
+ loading = false,
+ onAllow = {},
+ onDecline = {}
+ )
+ )
+}
+
+@Preview
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, backgroundColor = 0x0000000)
+@Composable
+fun CookieConsentLoadingPreview() {
+ CookieConsentContent(
+ uiState = CookieConsentUiState(
+ showDialog = false,
+ saving = false,
+ loading = true,
+ onAllow = {},
+ onDecline = {}
+ )
+ )
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentUiState.kt
new file mode 100644
index 0000000000..64f20463a1
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentUiState.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+data class ConsentResult(
+ val consentGiven: Boolean,
+ val needed: Boolean
+)
+
+data class CookieConsentUiState(
+ val loading: Boolean = true,
+ val showDialog: Boolean = false,
+ val saving: Boolean = false,
+ val namespace: CookieConsentNamespace = CookieConsentNamespace.STUDENT,
+ val errorMessage: String? = null,
+ val consentResult: ConsentResult? = null,
+ val onAllow: () -> Unit = {},
+ val onDecline: () -> Unit = {},
+ val onErrorDismissed: () -> Unit = {},
+ val onConsentResultHandled: () -> Unit = {}
+)
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentViewModel.kt
new file mode 100644
index 0000000000..62dabf1489
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/CookieConsentViewModel.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class CookieConsentViewModel @Inject constructor(
+ private val getCookieConsentUseCase: GetCookieConsentUseCase,
+ private val setCookieConsentUseCase: SetCookieConsentUseCase,
+ private val namespace: CookieConsentNamespace,
+ private val analyticsConsentHandler: AnalyticsConsentHandler
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(
+ CookieConsentUiState(
+ namespace = namespace,
+ onAllow = { submitConsent(true) },
+ onDecline = { submitConsent(false) },
+ onErrorDismissed = ::clearError,
+ onConsentResultHandled = ::clearConsentResult
+ )
+ )
+ val uiState = _uiState.asStateFlow()
+
+ fun checkAndShowIfNeeded() {
+ viewModelScope.launch {
+ try {
+ val result = getCookieConsentUseCase(Unit)
+ if (result.flagEnabled && result.consent == null) {
+ _uiState.update { it.copy(loading = false, showDialog = true) }
+ } else {
+ _uiState.update {
+ it.copy(loading = false, consentResult = ConsentResult(
+ consentGiven = result.consent ?: false,
+ needed = false
+ ))
+ }
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(loading = false, consentResult = ConsentResult(consentGiven = false, needed = false))
+ }
+ }
+ }
+ }
+
+ private fun submitConsent(consent: Boolean) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(saving = true) }
+ try {
+ setCookieConsentUseCase(SetCookieConsentUseCase.Params(consent))
+ if (!consent) analyticsConsentHandler.onConsentRevoked()
+ _uiState.update {
+ it.copy(
+ showDialog = false,
+ saving = false,
+ consentResult = ConsentResult(consentGiven = consent, needed = true)
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ saving = false,
+ errorMessage = e.message ?: "Failed to save consent"
+ )
+ }
+ }
+ }
+ }
+
+ private fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ private fun clearConsentResult() {
+ _uiState.update { it.copy(consentResult = null) }
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/GetCookieConsentUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/GetCookieConsentUseCase.kt
new file mode 100644
index 0000000000..d4ad1e4ee1
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/GetCookieConsentUseCase.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.domain.usecase.BaseUseCase
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import javax.inject.Inject
+
+class GetCookieConsentUseCase @Inject constructor(
+ private val featureFlagProvider: FeatureFlagProvider,
+ private val consentPrefs: ConsentPrefs
+) : BaseUseCase() {
+
+ data class Result(
+ val flagEnabled: Boolean,
+ val consent: Boolean?
+ )
+
+ override suspend fun execute(params: Unit): Result {
+ featureFlagProvider.fetchEnvironmentFeatureFlags()
+ val flagEnabled = featureFlagProvider.checkCookieConsentFlag()
+ if (!flagEnabled) return Result(flagEnabled = false, consent = null)
+
+ return Result(flagEnabled = true, consent = consentPrefs.currentUserConsent)
+ }
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/SetCookieConsentUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/SetCookieConsentUseCase.kt
new file mode 100644
index 0000000000..d27d9ba3fa
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/cookieconsent/SetCookieConsentUseCase.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.domain.usecase.BaseUseCase
+import javax.inject.Inject
+
+class SetCookieConsentUseCase @Inject constructor(
+ private val apiPrefs: ApiPrefs,
+ private val consentPrefs: ConsentPrefs
+) : BaseUseCase() {
+
+ data class Params(val consent: Boolean)
+
+ override suspend fun execute(params: Params) {
+ val userId = apiPrefs.user?.id ?: return
+ consentPrefs.setConsent(userId, apiPrefs.domain, params.consent)
+ }
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsFragment.kt
new file mode 100644
index 0000000000..e954223cea
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsFragment.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.privacysettings
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.fragment.app.viewModels
+import com.instructure.interactions.router.Route
+import com.instructure.pandautils.base.BaseCanvasFragment
+import com.instructure.pandautils.utils.ThemePrefs
+import com.instructure.pandautils.utils.ViewStyler
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class PrivacySettingsFragment : BaseCanvasFragment() {
+
+ private val viewModel: PrivacySettingsViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ ViewStyler.setStatusBarDark(requireActivity(), ThemePrefs.primaryColor)
+ return ComposeView(requireActivity()).apply {
+ setContent {
+ val uiState by viewModel.uiState.collectAsState()
+ PrivacySettingsScreen(
+ uiState = uiState,
+ navigationActionClick = { requireActivity().onBackPressed() }
+ )
+ }
+ }
+ }
+
+ companion object {
+ fun newInstance(route: Route) = PrivacySettingsFragment()
+
+ fun makeRoute() = Route(PrivacySettingsFragment::class.java, null)
+ }
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsScreen.kt
new file mode 100644
index 0000000000..efd01a6c22
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsScreen.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.privacysettings
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Divider
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.instructure.canvasapi2.utils.ContextKeeper
+import com.instructure.pandautils.R
+import com.instructure.pandautils.compose.CanvasTheme
+import com.instructure.pandautils.compose.composables.CanvasThemedAppBar
+import com.instructure.pandautils.compose.composables.LabelSwitchRow
+
+@Composable
+fun PrivacySettingsScreen(
+ uiState: PrivacySettingsUiState,
+ navigationActionClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ CanvasTheme {
+ Scaffold(
+ backgroundColor = colorResource(R.color.backgroundLightest),
+ topBar = {
+ CanvasThemedAppBar(
+ title = stringResource(R.string.privacySettingsTitle),
+ navigationActionClick = navigationActionClick
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ LabelSwitchRow(
+ label = stringResource(R.string.privacySettingsToggleLabel),
+ checked = uiState.consentEnabled,
+ enabled = !uiState.saving,
+ onCheckedChange = uiState.onToggleChanged,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Divider()
+ Text(
+ text = stringResource(R.string.privacySettingsDescription),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp, horizontal = 16.dp),
+ fontSize = 14.sp,
+ color = colorResource(R.color.textDarkest)
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, backgroundColor = 0x0000000)
+@Composable
+fun PrivacySettingsScreenPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ PrivacySettingsScreen(
+ uiState = PrivacySettingsUiState(
+ consentEnabled = true
+ ),
+ navigationActionClick = {}
+ )
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsUiState.kt
new file mode 100644
index 0000000000..12d2299583
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsUiState.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.privacysettings
+
+data class PrivacySettingsUiState(
+ val consentEnabled: Boolean = false,
+ val saving: Boolean = false,
+ val errorMessage: String? = null,
+ val onToggleChanged: (Boolean) -> Unit = {},
+ val onErrorDismissed: () -> Unit = {}
+)
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsViewModel.kt
new file mode 100644
index 0000000000..dcebf4e005
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsViewModel.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.privacysettings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.features.cookieconsent.AnalyticsConsentHandler
+import com.instructure.pandautils.features.cookieconsent.SetCookieConsentUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class PrivacySettingsViewModel @Inject constructor(
+ private val setCookieConsentUseCase: SetCookieConsentUseCase,
+ private val analyticsConsentHandler: AnalyticsConsentHandler,
+ consentPrefs: ConsentPrefs
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(
+ PrivacySettingsUiState(
+ consentEnabled = consentPrefs.currentUserConsent == true,
+ onToggleChanged = ::onToggleChanged,
+ onErrorDismissed = ::clearError
+ )
+ )
+ val uiState = _uiState.asStateFlow()
+
+ private fun onToggleChanged(enabled: Boolean) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(saving = true) }
+ try {
+ setCookieConsentUseCase(SetCookieConsentUseCase.Params(enabled))
+ if (enabled) analyticsConsentHandler.onConsentGranted()
+ else analyticsConsentHandler.onConsentRevoked()
+ _uiState.update { it.copy(saving = false, consentEnabled = enabled) }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(saving = false, errorMessage = e.message) }
+ }
+ }
+ }
+
+ private fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsBehaviour.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsBehaviour.kt
index 38bc448a92..c775b609dd 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsBehaviour.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsBehaviour.kt
@@ -45,4 +45,5 @@ enum class SettingsItem(val res: Int, val availableOffline: Boolean = false) {
ACCOUNT_PREFERENCES(R.string.accountPreferences, true),
HOMEROOM_VIEW(R.string.settingsHomeroomView, true),
SWITCH_EXPERIENCE(R.string.settingsSwitchExperience),
+ PRIVACY(R.string.privacySettingsTitle),
}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsFragment.kt
index b89f5c4923..f99f583de5 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsFragment.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsFragment.kt
@@ -241,6 +241,10 @@ class SettingsFragment : BaseCanvasFragment() {
settingsRouter.navigateToInboxSignature()
}
+ SettingsItem.PRIVACY -> {
+ settingsRouter.navigateToPrivacySettings()
+ }
+
else -> {
}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRepository.kt
index 64bf1d918a..e859665baa 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRepository.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRepository.kt
@@ -21,14 +21,20 @@ import com.instructure.canvasapi2.apis.FeaturesAPI
import com.instructure.canvasapi2.builders.RestParams
import com.instructure.canvasapi2.managers.InboxSettingsManager
import com.instructure.pandautils.R
+import com.instructure.pandautils.utils.FeatureFlagProvider
class SettingsRepository(
private val featuresApi: FeaturesAPI.FeaturesInterface,
private val inboxSettingsManager: InboxSettingsManager,
private val settingsBehaviour: SettingsBehaviour,
- private val experienceAPI: ExperienceAPI
+ private val experienceAPI: ExperienceAPI,
+ private val featureFlagProvider: FeatureFlagProvider
) {
+ suspend fun isCookieConsentEnabled(): Boolean {
+ return featureFlagProvider.checkCookieConsentFlag()
+ }
+
suspend fun getInboxSignatureState(): InboxSignatureState {
val environmentSettings = featuresApi.getAccountSettingsFeatures(RestParams()).dataOrNull
val inboxSignatureEnabled = environmentSettings?.enableInboxSignatureBlock ?: false
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRouter.kt
index 73586ec7c7..0854987618 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRouter.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsRouter.kt
@@ -37,4 +37,6 @@ interface SettingsRouter {
fun navigateToInboxSignature() = Unit
+ fun navigateToPrivacySettings() = Unit
+
}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsViewModel.kt
index 8df29ae187..1ef9b4b73c 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsViewModel.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/settings/SettingsViewModel.kt
@@ -105,6 +105,13 @@ class SettingsViewModel @Inject constructor(
changeSettingsItemSubtitle(SettingsItem.INBOX_SIGNATURE, it)
}
}
+ if (!settingsRepository.isCookieConsentEnabled()) {
+ _uiState.update { state ->
+ state.copy(items = state.items.mapValues { (_, items) ->
+ items.filter { it.item != SettingsItem.PRIVACY }
+ }.filter { it.value.isNotEmpty() })
+ }
+ }
_uiState.update { it.copy(loading = false) }
} catch {
_uiState.update { it.copy(loading = false) }
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt
index 4e1d853816..472cb536f2 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt
@@ -26,6 +26,7 @@ import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFl
const val FEATURE_FLAG_OFFLINE = "mobile_offline_mode"
const val FEATURE_FLAG_WIDGET_DASHBOARD = "widget_dashboard"
+const val FEATURE_FLAG_COOKIE_CONSENT = "cookie_consent_necessary"
class FeatureFlagProvider(
private val userManager: UserManager,
private val apiPrefs: ApiPrefs,
@@ -68,6 +69,10 @@ class FeatureFlagProvider(
return checkEnvironmentFeatureFlag(FEATURE_FLAG_WIDGET_DASHBOARD)
}
+ suspend fun checkCookieConsentFlag(): Boolean {
+ return checkEnvironmentFeatureFlag(FEATURE_FLAG_COOKIE_CONSENT)
+ }
+
suspend fun checkRestrictStudentAccessFlag(): Boolean {
return checkEnvironmentFeatureFlag("restrict_student_access")
}
diff --git a/libs/pandautils/src/main/res/values/strings.xml b/libs/pandautils/src/main/res/values/strings.xml
index d5552935d8..9d8ccdcd91 100644
--- a/libs/pandautils/src/main/res/values/strings.xml
+++ b/libs/pandautils/src/main/res/values/strings.xml
@@ -511,4 +511,16 @@
Grade: %s points
Grade: %d percent, %s
Grade: %s points, %s
+
+
+ Help us build a better app
+ We collect anonymous application data to help our team identify technical issues and optimize app features.
+ No Personal Profiles: We do not track your identity or show advertisements.
+ No Data Selling: Your information is never sold to third parties.
+ Full Control: You can adjust these settings at any time.
+ Allow
+ Decline
+ Privacy Settings
+ Anonymous Application Analytics
+ Share anonymous data about app performance and feature use. This helps us fix bugs and improve the overall application experience. We do not collect personal identifiers.
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/session/GetAuthenticatedSessionUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/session/GetAuthenticatedSessionUseCaseTest.kt
index 805bd2f904..814e36475d 100644
--- a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/session/GetAuthenticatedSessionUseCaseTest.kt
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/session/GetAuthenticatedSessionUseCaseTest.kt
@@ -19,6 +19,7 @@ package com.instructure.pandautils.domain.usecase.session
import com.instructure.canvasapi2.apis.OAuthAPI
import com.instructure.canvasapi2.models.AuthenticatedSession
import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
import com.instructure.canvasapi2.utils.DataResult
import io.mockk.coEvery
import io.mockk.coVerify
@@ -43,8 +44,8 @@ class GetAuthenticatedSessionUseCaseTest {
fun setUp() {
useCase = GetAuthenticatedSessionUseCase(oauthApi, apiPrefs)
every { apiPrefs.fullDomain } returns "https://canvas.instructure.com"
- mockkObject(ApiPrefs)
- every { ApiPrefs.mobileConsent } returns true
+ mockkObject(ConsentPrefs)
+ every { ConsentPrefs.currentUserConsent } returns true
}
@After
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/CookieConsentViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/CookieConsentViewModelTest.kt
new file mode 100644
index 0000000000..860b378f1b
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/CookieConsentViewModelTest.kt
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+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.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CookieConsentViewModelTest {
+
+ private val getCookieConsentUseCase: GetCookieConsentUseCase = mockk(relaxed = true)
+ private val setCookieConsentUseCase: SetCookieConsentUseCase = mockk(relaxed = true)
+ private val analyticsConsentHandler: AnalyticsConsentHandler = mockk(relaxed = true)
+ private val namespace = CookieConsentNamespace.STUDENT
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ unmockkAll()
+ }
+
+ @Test
+ fun `initial state has loading true and showDialog false`() {
+ val viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertTrue(state.loading)
+ assertFalse(state.showDialog)
+ assertNull(state.consentResult)
+ assertNull(state.errorMessage)
+ assertFalse(state.saving)
+ }
+
+ @Test
+ fun `initial state has correct namespace`() {
+ val viewModel = createViewModel()
+
+ assertEquals(CookieConsentNamespace.STUDENT, viewModel.uiState.value.namespace)
+ }
+
+ @Test
+ fun `checkAndShowIfNeeded shows dialog when flag enabled and consent is null`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = null
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.loading)
+ assertTrue(state.showDialog)
+ assertNull(state.consentResult)
+ }
+
+ @Test
+ fun `checkAndShowIfNeeded skips dialog when flag disabled`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = false, consent = null
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.loading)
+ assertFalse(state.showDialog)
+ assertEquals(ConsentResult(consentGiven = false, needed = false), state.consentResult)
+ }
+
+ @Test
+ fun `checkAndShowIfNeeded skips dialog when consent already given`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = true
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.loading)
+ assertFalse(state.showDialog)
+ assertEquals(ConsentResult(consentGiven = true, needed = false), state.consentResult)
+ }
+
+ @Test
+ fun `checkAndShowIfNeeded skips dialog when consent already declined`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = false
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.loading)
+ assertFalse(state.showDialog)
+ assertEquals(ConsentResult(consentGiven = false, needed = false), state.consentResult)
+ }
+
+ @Test
+ fun `checkAndShowIfNeeded handles exception by skipping dialog`() {
+ coEvery { getCookieConsentUseCase(Unit) } throws RuntimeException("Network error")
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.loading)
+ assertFalse(state.showDialog)
+ assertEquals(ConsentResult(consentGiven = false, needed = false), state.consentResult)
+ }
+
+ @Test
+ fun `checkAndShowIfNeeded invokes use case`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = false, consent = null
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ coVerify { getCookieConsentUseCase(Unit) }
+ }
+
+ @Test
+ fun `onAllow submits consent true and sets consent result`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = null
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ viewModel.uiState.value.onAllow()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.showDialog)
+ assertFalse(state.saving)
+ assertEquals(ConsentResult(consentGiven = true, needed = true), state.consentResult)
+ coVerify { setCookieConsentUseCase(SetCookieConsentUseCase.Params(true)) }
+ }
+
+ @Test
+ fun `onDecline submits consent false and sets consent result`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = null
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ viewModel.uiState.value.onDecline()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.showDialog)
+ assertFalse(state.saving)
+ assertEquals(ConsentResult(consentGiven = false, needed = true), state.consentResult)
+ coVerify { setCookieConsentUseCase(SetCookieConsentUseCase.Params(false)) }
+ verify { analyticsConsentHandler.onConsentRevoked() }
+ }
+
+ @Test
+ fun `submit consent shows error message on failure`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = null
+ )
+ coEvery { setCookieConsentUseCase(SetCookieConsentUseCase.Params(true)) } throws RuntimeException("Save failed")
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ viewModel.uiState.value.onAllow()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.saving)
+ assertEquals("Save failed", state.errorMessage)
+ assertNull(state.consentResult)
+ }
+
+ @Test
+ fun `submit consent shows default error message when exception has no message`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = null
+ )
+ coEvery { setCookieConsentUseCase(SetCookieConsentUseCase.Params(true)) } throws RuntimeException()
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ viewModel.uiState.value.onAllow()
+
+ assertEquals("Failed to save consent", viewModel.uiState.value.errorMessage)
+ }
+
+ @Test
+ fun `onErrorDismissed clears error message`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = true, consent = null
+ )
+ coEvery { setCookieConsentUseCase(SetCookieConsentUseCase.Params(true)) } throws RuntimeException("Error")
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+ viewModel.uiState.value.onAllow()
+
+ viewModel.uiState.value.onErrorDismissed()
+
+ assertNull(viewModel.uiState.value.errorMessage)
+ }
+
+ @Test
+ fun `onConsentResultHandled clears consent result`() {
+ coEvery { getCookieConsentUseCase(Unit) } returns GetCookieConsentUseCase.Result(
+ flagEnabled = false, consent = null
+ )
+
+ val viewModel = createViewModel()
+ viewModel.checkAndShowIfNeeded()
+
+ viewModel.uiState.value.onConsentResultHandled()
+
+ assertNull(viewModel.uiState.value.consentResult)
+ }
+
+ private fun createViewModel(): CookieConsentViewModel {
+ return CookieConsentViewModel(
+ getCookieConsentUseCase,
+ setCookieConsentUseCase,
+ namespace,
+ analyticsConsentHandler
+ )
+ }
+}
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/GetCookieConsentUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/GetCookieConsentUseCaseTest.kt
new file mode 100644
index 0000000000..7e35782324
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/GetCookieConsentUseCaseTest.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import io.mockk.Ordering
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+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
+
+class GetCookieConsentUseCaseTest {
+
+ private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true)
+ private val consentPrefs: ConsentPrefs = mockk(relaxed = true)
+
+ private val useCase = GetCookieConsentUseCase(featureFlagProvider, consentPrefs)
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `returns flag disabled with null consent when feature flag is off`() = runTest {
+ coEvery { featureFlagProvider.checkCookieConsentFlag() } returns false
+
+ val result = useCase(Unit)
+
+ assertFalse(result.flagEnabled)
+ assertNull(result.consent)
+ }
+
+ @Test
+ fun `fetches environment feature flags before checking`() = runTest {
+ coEvery { featureFlagProvider.checkCookieConsentFlag() } returns false
+
+ useCase(Unit)
+
+ coVerify(ordering = Ordering.ORDERED) {
+ featureFlagProvider.fetchEnvironmentFeatureFlags()
+ featureFlagProvider.checkCookieConsentFlag()
+ }
+ }
+
+ @Test
+ fun `does not read ConsentPrefs when flag is disabled`() = runTest {
+ coEvery { featureFlagProvider.checkCookieConsentFlag() } returns false
+
+ useCase(Unit)
+
+ verify(exactly = 0) { consentPrefs.currentUserConsent }
+ }
+
+ @Test
+ fun `returns flag enabled with true consent when ConsentPrefs stores true`() = runTest {
+ coEvery { featureFlagProvider.checkCookieConsentFlag() } returns true
+ every { consentPrefs.currentUserConsent } returns true
+
+ val result = useCase(Unit)
+
+ assertTrue(result.flagEnabled)
+ assertEquals(true, result.consent)
+ }
+
+ @Test
+ fun `returns flag enabled with false consent when ConsentPrefs stores false`() = runTest {
+ coEvery { featureFlagProvider.checkCookieConsentFlag() } returns true
+ every { consentPrefs.currentUserConsent } returns false
+
+ val result = useCase(Unit)
+
+ assertTrue(result.flagEnabled)
+ assertEquals(false, result.consent)
+ }
+
+ @Test
+ fun `returns flag enabled with null consent when ConsentPrefs has no decision stored`() = runTest {
+ coEvery { featureFlagProvider.checkCookieConsentFlag() } returns true
+ every { consentPrefs.currentUserConsent } returns null
+
+ val result = useCase(Unit)
+
+ assertTrue(result.flagEnabled)
+ assertNull(result.consent)
+ }
+}
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/SetCookieConsentUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/SetCookieConsentUseCaseTest.kt
new file mode 100644
index 0000000000..3d059bb41b
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/cookieconsent/SetCookieConsentUseCaseTest.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package com.instructure.pandautils.features.cookieconsent
+
+import com.instructure.canvasapi2.models.User
+import com.instructure.canvasapi2.utils.ApiPrefs
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.Runs
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+class SetCookieConsentUseCaseTest {
+
+ private val apiPrefs: ApiPrefs = mockk(relaxed = true)
+ private val consentPrefs: ConsentPrefs = mockk(relaxed = true)
+
+ private val useCase = SetCookieConsentUseCase(apiPrefs, consentPrefs)
+
+ @Before
+ fun setup() {
+ every { consentPrefs.setConsent(any(), any(), any()) } just Runs
+ val user = mockk { every { id } returns 42L }
+ every { apiPrefs.user } returns user
+ every { apiPrefs.domain } returns "test.instructure.com"
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `stores true consent in ConsentPrefs`() = runTest {
+ useCase(SetCookieConsentUseCase.Params(true))
+
+ verify { consentPrefs.setConsent(42L, "test.instructure.com", true) }
+ }
+
+ @Test
+ fun `stores false consent in ConsentPrefs`() = runTest {
+ useCase(SetCookieConsentUseCase.Params(false))
+
+ verify { consentPrefs.setConsent(42L, "test.instructure.com", false) }
+ }
+
+ @Test
+ fun `does nothing when user is null`() = runTest {
+ every { apiPrefs.user } returns null
+
+ useCase(SetCookieConsentUseCase.Params(true))
+
+ verify(exactly = 0) { consentPrefs.setConsent(any(), any(), any()) }
+ }
+}
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsViewModelTest.kt
new file mode 100644
index 0000000000..f89d3ed521
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/privacysettings/PrivacySettingsViewModelTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2026 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.features.privacysettings
+
+import com.instructure.canvasapi2.utils.ConsentPrefs
+import com.instructure.pandautils.features.cookieconsent.AnalyticsConsentHandler
+import com.instructure.pandautils.features.cookieconsent.SetCookieConsentUseCase
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+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.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PrivacySettingsViewModelTest {
+
+ private val setCookieConsentUseCase: SetCookieConsentUseCase = mockk(relaxed = true)
+ private val analyticsConsentHandler: AnalyticsConsentHandler = mockk(relaxed = true)
+ private val consentPrefs: ConsentPrefs = mockk(relaxed = true)
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ unmockkAll()
+ }
+
+ @Test
+ fun `initial state reflects consent true from prefs`() {
+ every { consentPrefs.currentUserConsent } returns true
+
+ val viewModel = createViewModel()
+
+ assertTrue(viewModel.uiState.value.consentEnabled)
+ }
+
+ @Test
+ fun `initial state reflects consent false from prefs`() {
+ every { consentPrefs.currentUserConsent } returns false
+
+ val viewModel = createViewModel()
+
+ assertFalse(viewModel.uiState.value.consentEnabled)
+ }
+
+ @Test
+ fun `initial state reflects consent null from prefs as false`() {
+ every { consentPrefs.currentUserConsent } returns null
+
+ val viewModel = createViewModel()
+
+ assertFalse(viewModel.uiState.value.consentEnabled)
+ }
+
+ @Test
+ fun `initial state has saving false and no error`() {
+ val viewModel = createViewModel()
+
+ assertFalse(viewModel.uiState.value.saving)
+ assertNull(viewModel.uiState.value.errorMessage)
+ }
+
+ @Test
+ fun `onToggleChanged true saves consent and calls onConsentGranted`() {
+ val viewModel = createViewModel()
+
+ viewModel.uiState.value.onToggleChanged(true)
+
+ coVerify { setCookieConsentUseCase(SetCookieConsentUseCase.Params(true)) }
+ verify { analyticsConsentHandler.onConsentGranted() }
+ }
+
+ @Test
+ fun `onToggleChanged true updates consentEnabled to true`() {
+ val viewModel = createViewModel()
+
+ viewModel.uiState.value.onToggleChanged(true)
+
+ assertTrue(viewModel.uiState.value.consentEnabled)
+ assertFalse(viewModel.uiState.value.saving)
+ }
+
+ @Test
+ fun `onToggleChanged false saves consent and calls onConsentRevoked`() {
+ val viewModel = createViewModel()
+
+ viewModel.uiState.value.onToggleChanged(false)
+
+ coVerify { setCookieConsentUseCase(SetCookieConsentUseCase.Params(false)) }
+ verify { analyticsConsentHandler.onConsentRevoked() }
+ }
+
+ @Test
+ fun `onToggleChanged false updates consentEnabled to false`() {
+ every { consentPrefs.currentUserConsent } returns true
+ val viewModel = createViewModel()
+
+ viewModel.uiState.value.onToggleChanged(false)
+
+ assertFalse(viewModel.uiState.value.consentEnabled)
+ assertFalse(viewModel.uiState.value.saving)
+ }
+
+ @Test
+ fun `onToggleChanged sets error message on failure`() {
+ coEvery { setCookieConsentUseCase(any()) } throws RuntimeException("Save failed")
+ val viewModel = createViewModel()
+
+ viewModel.uiState.value.onToggleChanged(true)
+
+ assertEquals("Save failed", viewModel.uiState.value.errorMessage)
+ assertFalse(viewModel.uiState.value.saving)
+ }
+
+ @Test
+ fun `onToggleChanged does not call handler on failure`() {
+ coEvery { setCookieConsentUseCase(any()) } throws RuntimeException("Save failed")
+ val viewModel = createViewModel()
+
+ viewModel.uiState.value.onToggleChanged(true)
+
+ verify(exactly = 0) { analyticsConsentHandler.onConsentGranted() }
+ verify(exactly = 0) { analyticsConsentHandler.onConsentRevoked() }
+ }
+
+ @Test
+ fun `onErrorDismissed clears error message`() {
+ coEvery { setCookieConsentUseCase(any()) } throws RuntimeException("Save failed")
+ val viewModel = createViewModel()
+ viewModel.uiState.value.onToggleChanged(true)
+
+ viewModel.uiState.value.onErrorDismissed()
+
+ assertNull(viewModel.uiState.value.errorMessage)
+ }
+
+ private fun createViewModel() = PrivacySettingsViewModel(
+ setCookieConsentUseCase,
+ analyticsConsentHandler,
+ consentPrefs
+ )
+}
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/settings/SettingsRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/settings/SettingsRepositoryTest.kt
index 8128d59549..2895b234a2 100644
--- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/settings/SettingsRepositoryTest.kt
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/settings/SettingsRepositoryTest.kt
@@ -21,6 +21,7 @@ import com.instructure.canvasapi2.managers.InboxSettingsManager
import com.instructure.canvasapi2.managers.InboxSignatureSettings
import com.instructure.canvasapi2.models.EnvironmentSettings
import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.pandautils.utils.FeatureFlagProvider
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@@ -33,8 +34,9 @@ class SettingsRepositoryTest {
private val inboxSettingsManager: InboxSettingsManager = mockk(relaxed = true)
private val settingsBehaviour: SettingsBehaviour = mockk(relaxed = true)
private val experienceApi: ExperienceAPI = mockk(relaxed = true)
+ private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true)
- private val repository = SettingsRepository(featuresApi, inboxSettingsManager, settingsBehaviour, experienceApi)
+ private val repository = SettingsRepository(featuresApi, inboxSettingsManager, settingsBehaviour, experienceApi, featureFlagProvider)
@Test
fun `Return hidden state when feature request fails`() = runTest {