Skip to content

Commit

Permalink
Initial support for login with Quran.com
Browse files Browse the repository at this point in the history
  • Loading branch information
ahmedre committed Dec 29, 2024
1 parent a144fad commit 1f97151
Show file tree
Hide file tree
Showing 17 changed files with 362 additions and 0 deletions.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/preferences_keys.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@
<string translatable="false" name="prefs_page_type">pageTypeKey</string>
<string translatable="false" name="prefs_category_dual_screen_key">dualScreenKey</string>
<string translatable="false" name="prefs_prefer_dns_over_https">preferDnsOverHttps</string>
<string translatable="false" name="prefs_sync">syncOptionsKey</string>
<string translatable="false" name="prefs_quran_sync_key">quranSyncKey</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@
<string name="prefs_sura_translated_name_summary">Show the translation of surah name</string>
<string name="prefs_sura_translated_name_title">Surah translated name</string>
<string name="prefs_preview">Preview</string>
<string name="prefs_category_sync">Synchronization Options</string>
<string name="prefs_quran_sync">Synchronization with Quran.com</string>
<string name="prefs_quran_sync_summary">Synchronize data with Quran.com</string>

<string name="translations" translatable="false">@string/prefs_translations</string>
<string name="more_translations">More Translations</string>
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/res/xml/quran_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@
app:iconSpaceReserved="false"/>
</PreferenceCategory>

<PreferenceCategory
android:key="@string/prefs_sync"
android:title="@string/prefs_category_sync"
app:iconSpaceReserved="false">

<Preference
android:key="@string/prefs_quran_sync_key"
android:summary="@string/prefs_quran_sync_summary"
android:title="@string/prefs_quran_sync"
app:iconSpaceReserved="false"/>
</PreferenceCategory>

<PreferenceCategory
android:key="@string/prefs_advanced_path"
android:title="@string/prefs_category_advanced"
Expand Down
1 change: 1 addition & 0 deletions feature/sync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oauth.properties
69 changes: 69 additions & 0 deletions feature/sync/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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"))

// androidx
implementation(libs.androidx.appcompat)
api(libs.androidx.datastore.prefs)

// coroutines
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)

// app auth library
implementation(libs.appauth)

// dagger
implementation(libs.dagger.runtime)

// timber
implementation(libs.timber)
}
18 changes: 18 additions & 0 deletions feature/sync/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<activity android:name=".QuranLoginActivity" />

<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="replace">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.quran.labs.androidquran"/>
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<Preferences>
) {

val authState = dataStore.data
.map { preferences -> preferences[AuthConstants.authPreference] }
.distinctUntilChanged()

suspend fun setAuthState(authState: String) {
dataStore.edit { preferences -> preferences[AuthConstants.authPreference] = authState }
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}

Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 1f97151

Please sign in to comment.