From ca54af3239fdedfe86f0de049f7fe47a23f04b46 Mon Sep 17 00:00:00 2001 From: Ahmed El-Helw Date: Sun, 29 Dec 2024 22:58:41 +0400 Subject: [PATCH] Initial support for login with Quran.com --- app/build.gradle.kts | 1 + .../quran/labs/androidquran/data/Constants.kt | 1 + .../ui/fragment/QuranSettingsFragment.kt | 7 + app/src/main/res/values/preferences_keys.xml | 2 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/quran_preferences.xml | 12 + feature/sync/.gitignore | 1 + feature/sync/build.gradle.kts | 82 ++++++ feature/sync/src/main/AndroidManifest.xml | 23 ++ .../mobile/feature/sync/QuranLoginActivity.kt | 67 +++++ .../mobile/feature/sync/auth/AuthConstants.kt | 11 + .../feature/sync/auth/AuthStateManager.kt | 25 ++ .../mobile/feature/sync/di/AuthComponent.kt | 18 ++ .../feature/sync/di/AuthComponentInterface.kt | 9 + .../mobile/feature/sync/di/AuthModule.kt | 25 ++ .../feature/sync/presenter/LoginState.kt | 45 +++ .../sync/presenter/QuranLoginPresenter.kt | 271 ++++++++++++++++++ .../feature/sync/ui/AuthenticatingState.kt | 40 +++ .../quran/mobile/feature/sync/ui/LoggedIn.kt | 36 +++ .../mobile/feature/sync/ui/LoggingOut.kt | 37 +++ .../mobile/feature/sync/ui/LoginScreen.kt | 73 +++++ feature/sync/src/main/res/values/strings.xml | 9 + gradle/libs.versions.toml | 11 + settings.gradle.kts | 1 + 24 files changed, 810 insertions(+) create mode 100644 feature/sync/.gitignore create mode 100644 feature/sync/build.gradle.kts create mode 100644 feature/sync/src/main/AndroidManifest.xml create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/QuranLoginActivity.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthConstants.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthStateManager.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponent.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponentInterface.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthModule.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/LoginState.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/QuranLoginPresenter.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/AuthenticatingState.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggedIn.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggingOut.kt create mode 100644 feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoginScreen.kt create mode 100644 feature/sync/src/main/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f79f2ca33c..938afb2e28 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,6 +149,7 @@ dependencies { implementation(project(":feature:audiobar")) implementation(project(":feature:downloadmanager")) implementation(project(":feature:qarilist")) + implementation(project(":feature:sync")) // android auto support implementation(project(":feature:autoquran")) diff --git a/app/src/main/java/com/quran/labs/androidquran/data/Constants.kt b/app/src/main/java/com/quran/labs/androidquran/data/Constants.kt index d0bcce84bf..314e8e86e5 100644 --- a/app/src/main/java/com/quran/labs/androidquran/data/Constants.kt +++ b/app/src/main/java/com/quran/labs/androidquran/data/Constants.kt @@ -93,4 +93,5 @@ object Constants { const val PREF_SHOW_SIDELINES = "showSidelines" const val PREF_SHOW_LINE_DIVIDERS = "showLineDividers" const val PREFS_PREFER_DNS_OVER_HTTPS = "preferDnsOverHttps" + const val PREFS_QURAN_SYNC = "quranSyncKey" } diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.kt b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.kt index db23c75496..ea14fb6c26 100644 --- a/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.kt +++ b/app/src/main/java/com/quran/labs/androidquran/ui/fragment/QuranSettingsFragment.kt @@ -16,6 +16,7 @@ import com.quran.labs.androidquran.pageselect.PageSelectActivity import com.quran.labs.androidquran.ui.TranslationManagerActivity import com.quran.mobile.di.ExtraPreferencesProvider import com.quran.mobile.feature.downloadmanager.AudioManagerActivity +import com.quran.mobile.feature.sync.QuranLoginActivity import javax.inject.Inject class QuranSettingsFragment : PreferenceFragmentCompat(), @@ -55,6 +56,12 @@ class QuranSettingsFragment : PreferenceFragmentCompat(), (readingPrefs as PreferenceGroup).removePreference(pageChangePref) } + val quranSyncPref: Preference? = findPreference(Constants.PREFS_QURAN_SYNC) + quranSyncPref?.setOnPreferenceClickListener { + startActivity(Intent(activity, QuranLoginActivity::class.java)) + true + } + // add additional injected preferences (if any) extraPreferences .sortedBy { it.order } diff --git a/app/src/main/res/values/preferences_keys.xml b/app/src/main/res/values/preferences_keys.xml index be3b71258c..8108d73a02 100644 --- a/app/src/main/res/values/preferences_keys.xml +++ b/app/src/main/res/values/preferences_keys.xml @@ -39,4 +39,6 @@ pageTypeKey dualScreenKey preferDnsOverHttps + syncOptionsKey + quranSyncKey diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5986433a6e..a7bafce871 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,6 +231,9 @@ Show the translation of surah name Surah translated name Preview + Synchronization Options + Synchronization with Quran.com + Synchronize data with Quran.com @string/prefs_translations More Translations diff --git a/app/src/main/res/xml/quran_preferences.xml b/app/src/main/res/xml/quran_preferences.xml index 8b0604a3f6..be05433e4b 100644 --- a/app/src/main/res/xml/quran_preferences.xml +++ b/app/src/main/res/xml/quran_preferences.xml @@ -218,6 +218,18 @@ app:iconSpaceReserved="false"/> + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/QuranLoginActivity.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/QuranLoginActivity.kt new file mode 100644 index 0000000000..31272218b8 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/QuranLoginActivity.kt @@ -0,0 +1,67 @@ +package com.quran.mobile.feature.sync + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.cash.molecule.AndroidUiDispatcher +import app.cash.molecule.RecompositionMode +import app.cash.molecule.launchMolecule +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.di.QuranApplicationComponentProvider +import com.quran.mobile.feature.sync.di.AuthComponentInterface +import com.quran.mobile.feature.sync.presenter.QuranLoginPresenter +import com.quran.mobile.feature.sync.ui.LoginScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Inject + +class QuranLoginActivity : AppCompatActivity() { + @Inject + lateinit var quranLoginPresenter: QuranLoginPresenter + + private val scope = CoroutineScope(SupervisorJob() + AndroidUiDispatcher.Main) + + private val authFlow by lazy(LazyThreadSafetyMode.NONE) { + scope.launchMolecule(mode = RecompositionMode.ContextClock) { + quranLoginPresenter.present() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val injector = (application as? QuranApplicationComponentProvider) + ?.provideQuranApplicationComponent() as? AuthComponentInterface + injector?.authComponentFactory()?.generate()?.inject(this) + + setContent { + QuranTheme { + val authenticationState = authFlow.collectAsState() + + Scaffold(topBar = { + TopAppBar( + title = { Text(stringResource(R.string.sync_with_quran_com)) }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + } + ) + }) { paddingValues -> + LoginScreen(authenticationState.value, Modifier.padding(paddingValues)) + } + } + } + } +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthConstants.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthConstants.kt new file mode 100644 index 0000000000..66be7a407b --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthConstants.kt @@ -0,0 +1,11 @@ +package com.quran.mobile.feature.sync.auth + +import androidx.datastore.preferences.core.stringPreferencesKey + +object AuthConstants { + val authPreference = stringPreferencesKey(Keys.AUTH_STATE_PREFERENCE_KEY) + + private object Keys { + const val AUTH_STATE_PREFERENCE_KEY = "authState" + } +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthStateManager.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthStateManager.kt new file mode 100644 index 0000000000..248744bc72 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/auth/AuthStateManager.kt @@ -0,0 +1,25 @@ +package com.quran.mobile.feature.sync.auth + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.quran.mobile.feature.sync.di.AuthModule +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class AuthStateManager @Inject constructor( + @Named(AuthModule.AUTH_DATASTORE) private val dataStore: DataStore +) { + + val authState = dataStore.data + .map { preferences -> preferences[AuthConstants.authPreference] } + .distinctUntilChanged() + + suspend fun setAuthState(authState: String) { + dataStore.edit { preferences -> preferences[AuthConstants.authPreference] = authState } + } +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponent.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponent.kt new file mode 100644 index 0000000000..7a5aa02dad --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponent.kt @@ -0,0 +1,18 @@ +package com.quran.mobile.feature.sync.di + +import com.quran.data.di.ActivityLevelScope +import com.quran.data.di.ActivityScope +import com.quran.mobile.feature.sync.QuranLoginActivity +import com.squareup.anvil.annotations.MergeSubcomponent + +@ActivityScope +@MergeSubcomponent(ActivityLevelScope::class) +interface AuthComponent { + fun inject(loginActivity: QuranLoginActivity) + + @MergeSubcomponent.Factory + interface Factory { + fun generate(): AuthComponent + } +} + diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponentInterface.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponentInterface.kt new file mode 100644 index 0000000000..d6249d1c50 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthComponentInterface.kt @@ -0,0 +1,9 @@ +package com.quran.mobile.feature.sync.di + +import com.quran.data.di.AppScope +import com.squareup.anvil.annotations.ContributesTo + +@ContributesTo(AppScope::class) +interface AuthComponentInterface { + fun authComponentFactory(): AuthComponent.Factory +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthModule.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthModule.kt new file mode 100644 index 0000000000..930b19d323 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/di/AuthModule.kt @@ -0,0 +1,25 @@ +package com.quran.mobile.feature.sync.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.quran.data.di.AppScope +import com.quran.mobile.di.qualifier.ApplicationContext +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Named + +@Module +@ContributesTo(AppScope::class) +object AuthModule { + const val AUTH_DATASTORE = "auth_datastore" + private const val PREFERENCES_STORE = "auth_prefs" + private val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCES_STORE) + + @Named(AUTH_DATASTORE) + @Provides + fun provideAuthDataStore(@ApplicationContext appContext: Context): DataStore = + appContext.dataStore +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/LoginState.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/LoginState.kt new file mode 100644 index 0000000000..bd6dc08cf6 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/LoginState.kt @@ -0,0 +1,45 @@ +package com.quran.mobile.feature.sync.presenter + +import android.content.Intent +import net.openid.appauth.AuthorizationResponse + +sealed class LoginState { + data class LoggedIn( + val name: String, + val email: String, + val eventHandler: (LoginEvent) -> Unit + ) : LoginState() + + data class LoggedOut( + val isAuthenticating: Boolean, + val eventHandler: (LoginEvent) -> Unit + ) : LoginState() + + data object LoggingIn : LoginState() + + data class Authenticating( + val intent: Intent?, + val eventHandler: (LoginEvent) -> Unit + ) : LoginState() + + data class LoggingOut( + val intent: Intent, + val eventHandler: (LoginEvent) -> Unit + ) : LoginState() +} + +sealed class LoginEvent { + data object Login : LoginEvent() + data object Logout : LoginEvent() + data object CancelLogin : LoginEvent() + data object CancelLogout : LoginEvent() + data class OnAuthenticationResult( + val response: AuthorizationResponse?, + val exception: Exception? + ) : LoginEvent() + + data class OnLogoutResult( + val response: AuthorizationResponse?, + val exception: Exception? + ) : LoginEvent() +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/QuranLoginPresenter.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/QuranLoginPresenter.kt new file mode 100644 index 0000000000..dc2ba65d78 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/QuranLoginPresenter.kt @@ -0,0 +1,271 @@ +package com.quran.mobile.feature.sync.presenter + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.quran.mobile.di.qualifier.ApplicationContext +import com.quran.mobile.feature.sync.BuildConfig +import com.quran.mobile.feature.sync.auth.AuthStateManager +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.EndSessionRequest +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.resumeWithException + +class QuranLoginPresenter @Inject constructor( + private val authStateManager: AuthStateManager, + @ApplicationContext applicationContext: Context +) { + private val authorizationService by lazy { AuthorizationService(applicationContext) } + + private val authenticationStateFlow = + authStateManager.authState + .map { authStateJson -> + if (authStateJson != null) { + AuthState.jsonDeserialize(authStateJson) + } else { + null + } + } + + @Composable + fun present(): LoginState { + val authState = authenticationStateFlow.collectAsState(null) + val shouldLogin = remember { mutableStateOf(false) } + val isLoggingIn = remember { mutableStateOf(false) } + val isLoggingOut = remember { mutableStateOf(false) } + val clearToken = remember { mutableStateOf(false) } + val intent = remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + val eventHandler = { event: LoginEvent -> + when (event) { + LoginEvent.Login -> { + shouldLogin.value = true + } + + LoginEvent.CancelLogin -> { + isLoggingIn.value = false + intent.value = null + } + + LoginEvent.Logout -> { + isLoggingOut.value = true + } + + LoginEvent.CancelLogout -> { + isLoggingOut.value = false + clearToken.value = true + } + + is LoginEvent.OnAuthenticationResult -> { + intent.value = null + val currentAuthState = authState.value + if (currentAuthState != null && event.response != null) { + isLoggingIn.value = true + scope.launch { + val state = runCatching { onUpdatedAuthState(currentAuthState, event.response) } + if (state.isSuccess) { + val currentAuthState = state.getOrThrow() + saveAuthState(currentAuthState) + isLoggingIn.value = false + } else { + Timber.e(state.exceptionOrNull(), "Failed to update auth state") + isLoggingIn.value = false + } + } + } + } + + is LoginEvent.OnLogoutResult -> { + clearToken.value = true + isLoggingOut.value = false + } + } + } + + LaunchedEffect(shouldLogin.value) { + if (shouldLogin.value) { + val configuration = applicationConfiguration(authState.value) + if (configuration != null) { + saveAuthState(AuthState(configuration)) + intent.value = authorizationIntent(configuration) + } + shouldLogin.value = false + } + } + + LaunchedEffect(clearToken.value) { + if (clearToken.value) { + scope.launch { + val authState = authState.value + if (authState != null) { + signOut(authState) + } + clearToken.value = false + } + } + } + + val currentIntent = intent.value + + val currentAuthState = authState.value + val state = if (currentIntent != null || shouldLogin.value) { + LoginState.Authenticating(currentIntent, eventHandler) + } else if (isLoggingIn.value) { + LoginState.LoggingIn + } else if (isLoggingOut.value && currentAuthState != null) { + val logoutIntent = logoutIntent(currentAuthState) + if (logoutIntent != null) { + LoginState.LoggingOut(logoutIntent, eventHandler) + } else { + clearToken.value = true + isLoggingOut.value = false + LoginState.LoggedOut(isAuthenticating = false, eventHandler) + } + } else if (currentAuthState?.isAuthorized == true && !clearToken.value) { + val parsedIdToken = currentAuthState.parsedIdToken?.additionalClaims.orEmpty() + val firstName = parsedIdToken["first_name"]?.toString() ?: "" + val lastName = parsedIdToken["last_name"]?.toString() ?: "" + val name = listOf(firstName, lastName) + val email = parsedIdToken["email"]?.toString() ?: "" + LoginState.LoggedIn(name.joinToString(" ").trim(), email, eventHandler) + } else { + LoginState.LoggedOut(isAuthenticating = false, eventHandler) + } + + return state + } + + private suspend fun applicationConfiguration(authState: AuthState?): AuthorizationServiceConfiguration? { + val configuration = authState?.authorizationServiceConfiguration + return configuration + ?: suspendCancellableCoroutine { continuation -> + val callback = object : AuthorizationServiceConfiguration.RetrieveConfigurationCallback { + override fun onFetchConfigurationCompleted( + serviceConfiguration: AuthorizationServiceConfiguration?, + ex: AuthorizationException? + ) { + if (serviceConfiguration != null && ex == null) { + continuation.resumeWith(Result.success(serviceConfiguration)) + } else { + continuation.resumeWithException( + ex ?: IllegalStateException("Failed to fetch configuration") + ) + } + } + } + + AuthorizationServiceConfiguration.fetchFromIssuer( + Uri.parse(DISCOVERY_URI), + callback + ) + + continuation.invokeOnCancellation { + } + } + } + + private fun authorizationIntent(serviceConfiguration: AuthorizationServiceConfiguration): Intent { + val authorizationRequest = + AuthorizationRequest.Builder( + serviceConfiguration, + CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(REDIRECT_URI) + ) + .setScope(SCOPES) + .build() + return authorizationService.getAuthorizationRequestIntent(authorizationRequest) + } + + private fun logoutIntent(authState: AuthState): Intent? { + val configuration = authState.authorizationServiceConfiguration + return if (configuration?.endSessionEndpoint != null) { + authorizationService.getEndSessionRequestIntent( + EndSessionRequest.Builder(configuration) + .setIdTokenHint(authState.idToken) + .setPostLogoutRedirectUri(Uri.parse(REDIRECT_URI)) + .build() + ) + } else { + null + } + } + + private suspend fun signOut(authState: AuthState) { + val config = authState.authorizationServiceConfiguration + val authState = if (config != null) { + AuthState(config) + } else { + AuthState() + } + saveAuthState(authState) + } + + private suspend fun onUpdatedAuthState( + authState: AuthState, + response: AuthorizationResponse + ): AuthState { + Timber.d("Authorization response - ${authState.isAuthorized}") + if (response.authorizationCode != null) { + Timber.d("Requesting authorization code...") + + return suspendCancellableCoroutine { continuation -> + val callback = object : AuthorizationService.TokenResponseCallback { + override fun onTokenRequestCompleted( + response: TokenResponse?, + ex: AuthorizationException? + ) { + authState.update(response, ex) + + if (ex != null) { + continuation.resumeWith(Result.failure(ex)) + } else { + continuation.resumeWith(Result.success(authState)) + } + } + } + + authorizationService.performTokenRequest( + response.createTokenExchangeRequest(), + callback + ) + + continuation.invokeOnCancellation { + } + } + } else { + val authState = authState + authState.update(response, null) + return authState + } + } + + private suspend fun saveAuthState(authState: AuthState) { + authStateManager.setAuthState(authState.jsonSerializeString()) + } + + companion object { + private const val CLIENT_ID = BuildConfig.CLIENT_ID + private const val DISCOVERY_URI = BuildConfig.DISCOVERY_URI + private const val SCOPES = BuildConfig.SCOPES + private const val REDIRECT_URI = BuildConfig.REDIRECT_URI + } +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/AuthenticatingState.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/AuthenticatingState.kt new file mode 100644 index 0000000000..391e0a9f4d --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/AuthenticatingState.kt @@ -0,0 +1,40 @@ +package com.quran.mobile.feature.sync.ui + +import android.app.Activity.RESULT_OK +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import com.quran.mobile.feature.sync.presenter.LoginEvent +import com.quran.mobile.feature.sync.presenter.LoginState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import timber.log.Timber + +@Composable +fun AuthenticatingState(state: LoginState.Authenticating, modifier: Modifier = Modifier) { + val authorizationLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + val data: Intent? = result.data + if (result.resultCode == RESULT_OK && data != null) { + val response = AuthorizationResponse.fromIntent(data) + val exception = AuthorizationException.fromIntent(data) + state.eventHandler(LoginEvent.OnAuthenticationResult(response, exception)) + } else { + Timber.d("Authorization request canceled") + state.eventHandler(LoginEvent.CancelLogin) + } + } + + CircularProgressIndicator(modifier) + + val intent = state.intent + LaunchedEffect(intent) { + if (intent != null) { + authorizationLauncher.launch(intent) + } + } +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggedIn.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggedIn.kt new file mode 100644 index 0000000000..18f31427c8 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggedIn.kt @@ -0,0 +1,36 @@ +package com.quran.mobile.feature.sync.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.quran.mobile.feature.sync.R +import com.quran.mobile.feature.sync.presenter.LoginEvent +import com.quran.mobile.feature.sync.presenter.LoginState + +@Composable +fun LoggedIn(state: LoginState.LoggedIn, modifier: Modifier = Modifier) { + Column(modifier) { + if (state.name.isNotEmpty()) { + Text(text = state.name) + } + + if (state.email.isNotEmpty()) { + Text(text = state.email) + } + + TextButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp), + onClick = { state.eventHandler(LoginEvent.Logout) } + ) { + Text(stringResource(R.string.logout)) + } + } +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggingOut.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggingOut.kt new file mode 100644 index 0000000000..309aa50dbc --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoggingOut.kt @@ -0,0 +1,37 @@ +package com.quran.mobile.feature.sync.ui + +import android.app.Activity.RESULT_OK +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import com.quran.mobile.feature.sync.presenter.LoginEvent +import com.quran.mobile.feature.sync.presenter.LoginState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import timber.log.Timber + +@Composable +fun LoggingOut(state: LoginState.LoggingOut, modifier: Modifier = Modifier) { + val authorizationLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + val data: Intent? = result.data + if (result.resultCode == RESULT_OK && data != null) { + val response = AuthorizationResponse.fromIntent(data) + val exception = AuthorizationException.fromIntent(data) + state.eventHandler(LoginEvent.OnLogoutResult(response, exception)) + } else { + Timber.d("Sign out request canceled") + state.eventHandler(LoginEvent.CancelLogout) + } + } + + LaunchedEffect(state.intent) { + authorizationLauncher.launch(state.intent) + } + + CircularProgressIndicator(modifier) +} diff --git a/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoginScreen.kt b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoginScreen.kt new file mode 100644 index 0000000000..dd7c1d0ce7 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/ui/LoginScreen.kt @@ -0,0 +1,73 @@ +package com.quran.mobile.feature.sync.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.quran.labs.androidquran.common.ui.core.QuranTheme +import com.quran.mobile.feature.sync.R +import com.quran.mobile.feature.sync.presenter.LoginEvent +import com.quran.mobile.feature.sync.presenter.LoginState + +@Composable +fun LoginScreen(loginState: LoginState, modifier: Modifier = Modifier) { + Column( + modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + Text( + stringResource(R.string.sync_with_quran_com_details), + style = MaterialTheme.typography.bodyMedium + ) + + val modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterHorizontally) + + when (loginState) { + is LoginState.LoggedIn -> LoggedIn(loginState, modifier) + is LoginState.LoggingIn -> CircularProgressIndicator(modifier) + is LoginState.LoggingOut -> LoggingOut(loginState, modifier) + is LoginState.Authenticating -> AuthenticatingState(loginState, modifier) + is LoginState.LoggedOut -> { + TextButton( + modifier = modifier, + onClick = { loginState.eventHandler(LoginEvent.Login) } + ) { + Text(stringResource(R.string.login)) + } + } + } + } +} + +@Preview +@Composable +private fun LoggedInPreview() { + QuranTheme { + LoginScreen( + loginState = LoginState.LoggedIn( + name = "Altayer ibn Lahad", + email = "altayer@", + eventHandler = {} + ) + ) + } +} + +@Preview +@Composable +private fun LoggedOutPreview() { + QuranTheme { + LoginScreen(loginState = LoginState.LoggedOut(isAuthenticating = false, eventHandler = {})) + } +} diff --git a/feature/sync/src/main/res/values/strings.xml b/feature/sync/src/main/res/values/strings.xml new file mode 100644 index 0000000000..eee5340690 --- /dev/null +++ b/feature/sync/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Sync with Quran.com + Quran for Android can synchronize bookmarks and reading + statuses with Quran.com, so that reading can be continued on other Quran.com applications and + devices. + Login + Logout + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 18b1b5548c..e07bfcb66c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidxRecyclerViewVersion = "1.3.2" androidxSwipeRefreshVersion = "1.1.0" androidxPagingVersion = "3.3.5" androidxPagingComposeVersion = "3.3.5" +androidxPreferencesDataStoreVersion = "1.1.1" androidxWorkManagerVersion = "2.10.0" androidxWindowManager = "1.3.0" @@ -61,6 +62,9 @@ numberPickerVersion = "2.4.13" reorderableComposeVersion = "0.9.6" molecule = "2.0.0" +# app auth +appAuthVersion = "0.11.1" + # recitations grpcOkhttpVersion = "1.69.0" googleAuthVersion = "1.30.1" @@ -80,6 +84,8 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3ExoplayerVersion" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3ExoplayerVersion" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3ExoplayerVersion" } + +# kotlin kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "immutableCollectionsVersion" } @@ -103,8 +109,10 @@ androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefre androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWorkManagerVersion" } androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidxPagingVersion" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidxPagingComposeVersion" } +androidx-datastore-prefs = { module = "androidx.datastore:datastore-preferences", version.ref = "androidxPreferencesDataStoreVersion" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationVersion" } androidx-window = { module = "androidx.window:window", version.ref = "androidxWindowManager" } + # compose compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-animation = { module = "androidx.compose.animation:animation" } @@ -125,6 +133,9 @@ dagger-runtime = { module = "com.google.dagger:dagger", version.ref = "daggerVer # molecule molecule = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } +# app auth +appauth = { module = "net.openid:appauth", version.ref = "appAuthVersion"} + # moshi moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshiVersion" } moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshiVersion" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 065cce9743..137bb79ddf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ include(":feature:downloadmanager") include(":feature:linebyline") include(":feature:qarilist") include(":feature:recitation") +include(":feature:sync") include(":pages:madani") include(":pages:data:madani") include(":pages:data:warsh")