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"/>
+
+
+
+
+
+) {
+
+ val authState = dataStore.data
+ .map { preferences -> preferences[AuthConstants.authPreference] }
+ .distinctUntilChanged()
+
+ val authenticationStateFlow =
+ authState
+ .map { authStateJson ->
+ if (authStateJson != null) {
+ AuthState.jsonDeserialize(authStateJson)
+ } else {
+ null
+ }
+ }
+
+ suspend fun setAuthState(authState: String) {
+ dataStore.edit { preferences -> preferences[AuthConstants.authPreference] = authState }
+ }
+}
diff --git a/common/sync/src/main/kotlin/com/quran/mobile/common/sync/di/AuthModule.kt b/common/sync/src/main/kotlin/com/quran/mobile/common/sync/di/AuthModule.kt
new file mode 100644
index 0000000000..c27293eb64
--- /dev/null
+++ b/common/sync/src/main/kotlin/com/quran/mobile/common/sync/di/AuthModule.kt
@@ -0,0 +1,25 @@
+package com.quran.mobile.common.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/.gitignore b/feature/sync/.gitignore
new file mode 100644
index 0000000000..c57b925bda
--- /dev/null
+++ b/feature/sync/.gitignore
@@ -0,0 +1 @@
+oauth.properties
diff --git a/feature/sync/build.gradle.kts b/feature/sync/build.gradle.kts
new file mode 100644
index 0000000000..22ce21be11
--- /dev/null
+++ b/feature/sync/build.gradle.kts
@@ -0,0 +1,82 @@
+import java.io.FileInputStream
+import java.util.Properties
+
+plugins {
+ id("quran.android.library.compose")
+ alias(libs.plugins.anvil)
+}
+
+android {
+ namespace = "com.quran.mobile.feature.sync"
+ buildFeatures.buildConfig = true
+
+ val properties = Properties()
+ val propertiesFile = project.projectDir.resolve("oauth.properties")
+
+ if (propertiesFile.exists()) {
+ properties.load(FileInputStream(propertiesFile))
+ }
+
+ defaultConfig {
+ buildConfigField(
+ "String",
+ "CLIENT_ID",
+ "\"${properties.getProperty("client_id", "")}\""
+ )
+ buildConfigField(
+ "String",
+ "DISCOVERY_URI",
+ "\"${properties.getProperty("discovery_uri", "")}\""
+ )
+ buildConfigField(
+ "String",
+ "SCOPES",
+ "\"${properties.getProperty("scopes", "")}\""
+ )
+ buildConfigField(
+ "String",
+ "REDIRECT_URI",
+ "\"${properties.getProperty("redirect_uri", "")}\""
+ )
+ }
+}
+
+anvil {
+ useKsp(contributesAndFactoryGeneration = true, componentMerging = true)
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(project(":common:di"))
+ implementation(project(":common:data"))
+ api(project(":common:sync"))
+ implementation(project(":common:ui:core"))
+
+ // androidx
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.activity.compose)
+
+ // compose
+ implementation(libs.compose.animation)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling.preview)
+ debugImplementation(libs.compose.ui.tooling)
+
+ // coroutines
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.coroutines.android)
+
+ // molecule
+ implementation(libs.molecule)
+
+ // app auth library
+ implementation(libs.appauth)
+
+ // dagger
+ implementation(libs.dagger.runtime)
+
+ // timber
+ implementation(libs.timber)
+}
diff --git a/feature/sync/src/main/AndroidManifest.xml b/feature/sync/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..6e2db6d186
--- /dev/null
+++ b/feature/sync/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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/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..b81fd448e2
--- /dev/null
+++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/presenter/QuranLoginPresenter.kt
@@ -0,0 +1,260 @@
+package com.quran.mobile.feature.sync.presenter
+
+import android.content.Context
+import android.content.Intent
+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 androidx.core.net.toUri
+import com.quran.mobile.common.sync.auth.AuthStateManager
+import com.quran.mobile.di.qualifier.ApplicationContext
+import com.quran.mobile.feature.sync.BuildConfig
+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) }
+
+ @Composable
+ fun present(): LoginState {
+ val authState = authStateManager.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(
+ DISCOVERY_URI.toUri(),
+ callback
+ )
+
+ continuation.invokeOnCancellation {
+ }
+ }
+ }
+
+ private fun authorizationIntent(serviceConfiguration: AuthorizationServiceConfiguration): Intent {
+ val authorizationRequest =
+ AuthorizationRequest.Builder(
+ serviceConfiguration,
+ CLIENT_ID,
+ ResponseTypeValues.CODE,
+ REDIRECT_URI.toUri()
+ )
+ .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(REDIRECT_URI.toUri())
+ .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 2d01893cab..5a73711924 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -44,6 +44,7 @@ androidxRecyclerViewVersion = "1.4.0"
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.70.0"
googleAuthVersion = "1.31.0"
@@ -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..ec374e3064 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -32,6 +32,7 @@ include(":common:linebyline:ui")
include(":common:recitation")
include(":common:preference")
include(":common:search")
+include(":common:sync")
include(":common:toolbar")
include(":common:translation")
include(":common:upgrade")
@@ -45,6 +46,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")