From 1f97151ca6399ff423b82f27c130f9fce832301b 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 | 69 ++++++++ feature/sync/src/main/AndroidManifest.xml | 18 +++ .../mobile/feature/sync/QuranLoginActivity.kt | 148 ++++++++++++++++++ .../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 +++ gradle/libs.versions.toml | 11 ++ settings.gradle.kts | 1 + 17 files changed, 362 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6acbc352af..21c02cfb91 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..9ab692a664 --- /dev/null +++ b/feature/sync/src/main/kotlin/com/quran/mobile/feature/sync/QuranLoginActivity.kt @@ -0,0 +1,148 @@ +package com.quran.mobile.feature.sync + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import com.quran.mobile.di.QuranApplicationComponentProvider +import com.quran.mobile.feature.sync.auth.AuthStateManager +import com.quran.mobile.feature.sync.di.AuthComponentInterface +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +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.ResponseTypeValues +import net.openid.appauth.TokenResponse +import timber.log.Timber +import javax.inject.Inject + +class QuranLoginActivity : AppCompatActivity() { + @Inject + lateinit var authStateManager: AuthStateManager + + private val scope = MainScope() + private lateinit var authState: AuthState + private val authorizationService by lazy { AuthorizationService(applicationContext) } + + private val authorizationLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + val data: Intent? = result.data + if (result.resultCode == RESULT_OK && data != null) { + val response = AuthorizationResponse.fromIntent(data) + val exception = AuthorizationException.fromIntent(data) + if (response != null) { + onUpdatedAuthState(response) + } else { + Timber.e(exception, "Authorization request failed") + } + } else { + Timber.d("Authorization request canceled") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val injector = (application as? QuranApplicationComponentProvider) + ?.provideQuranApplicationComponent() as? AuthComponentInterface + injector?.authComponentFactory()?.generate()?.inject(this) + + scope.launch { + authStateManager.authState + .map { authStateJson -> + if (authStateJson != null) { + AuthState.jsonDeserialize(authStateJson) + } else { + AuthState() + } + } + .onEach { authState = it } + .collect { authState -> + if (authState.isAuthorized) { + finish() + } else { + initializeAppAuth() + } + } + } + } + + private fun initializeAppAuth() { + AuthorizationServiceConfiguration.fetchFromIssuer( + Uri.parse(DISCOVERY_URI), + object : AuthorizationServiceConfiguration.RetrieveConfigurationCallback { + override fun onFetchConfigurationCompleted( + serviceConfiguration: AuthorizationServiceConfiguration?, + ex: AuthorizationException? + ) { + if (serviceConfiguration != null && ex == null) { + val authorizationRequest = + AuthorizationRequest.Builder( + serviceConfiguration, + CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(REDIRECT_URI) + ) + .setScope(SCOPES) + .build() + val authIntent = + authorizationService.getAuthorizationRequestIntent(authorizationRequest) + authorizationLauncher.launch(authIntent) + } + } + }) + } + + private fun onUpdatedAuthState(response: AuthorizationResponse) { + Timber.d("Authorization response - ${authState.isAuthorized}") + if (response.authorizationCode != null) { + Timber.d("Requesting authorization code...") + authorizationService.performTokenRequest( + response.createTokenExchangeRequest(), + object : AuthorizationService.TokenResponseCallback { + override fun onTokenRequestCompleted( + response: TokenResponse?, + ex: AuthorizationException? + ) { + val authState = authState + authState.update(response, ex) + + if (authState.isAuthorized) { + Timber.d("Authorization code succeeded") + saveAuthState(authState) + } else { + Timber.d("Authorization code exchange failed") + } + } + } + ) + } else { + val authState = authState + authState.update(response, null) + + saveAuthState(authState) + } + } + + private fun saveAuthState(authState: AuthState) { + scope.launch { + 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea8455a20c..1d55e51734 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")