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 {