
Sync and data stack for Quran apps: OIDC authentication, SQL-backed local persistence, orchestration of sync engine via DI, unified SyncService API, mutation/observe primitives and demos.
Kotlin Multiplatform sync/data stack for Quran mobile apps.
This repository contains shared modules used by Android and iOS apps for:
SyncService)auth + persistence + syncengine are composed in sync-pipelines, exposed through a managed DI graph (AppGraph / SharedDependencyGraph), and exported to iOS through umbrella.
flowchart LR
UI[Android/iOS UI] --> G[AppGraph]
G --> S[SyncService]
G --> F[SyncAuthService]
F --> A[Auth Module]
S --> A
S --> P[Persistence Module]
S --> E[SyncEngine Module]
U[Umbrella XCFramework] --> UI| Module | Purpose |
|---|---|
:auth |
OIDC login/logout, auth state, token handling |
:persistence |
SQLDelight DB, repositories for bookmarks/collections/notes/reading sessions |
:syncengine |
Core sync engine and scheduling |
:sync-pipelines |
DI graph and SyncService orchestration API |
:umbrella |
iOS XCFramework export (Shared.xcframework) |
:demo:android |
Android sample app (Compose) |
:demo:common |
Shared demo helpers/models |
:mutations-definitions |
Shared mutation/domain definitions |
Optional but recommended:
local.properties with the OAuth client ID for the Android demo app:OAUTH_CLIENT_ID=your_client_idPublished library artifacts do not embed credentials. Android apps are OAuth public clients using PKCE and must not embed OAuth client secrets.
git clone https://github.com/quran/mobile-sync.git
cd mobile-sync./gradlew help./gradlew allTests --stacktrace --continueChoose one integration mode per database.
Persistence-only mode uses the public :persistence repositories directly. Repositories preserve
local integrity and mutation tracking, but consumers must not mutate SQL tables directly. Use this
mode only when the database is not managed by :sync-pipelines.
Managed sync-capable mode uses :sync-pipelines through AppGraph. In this mode, all
user-visible data reads and writes go through SyncService, and authentication goes through
SyncAuthService. Do not mix direct persistence writes with the managed sync graph. If sync may be
enabled later for the same app data, use managed mode from day one, even while logged out.
Logged-out managed-service writes are local-first; sync triggers no-op until the user is
authenticated.
AppGraph exposes only managed facades: syncService: SyncService and
authService: SyncAuthService. Raw write-capable persistence repositories are no longer exposed
from :sync-pipelines AppGraph; they remain public in :persistence for persistence-only
consumers. The raw :auth AuthService is hidden from AppGraph and wrapped by
SyncAuthService.
This repository is still in active development. Schema changes are made directly in .sq files;
.sqm migrations are not currently maintained.
Configure the Android app manifest placeholders for the login and post-logout redirect URIs. The
:auth artifact contributes com.quran.shared.auth.android.SafeOidcRedirectActivity as the
exported browser callback entry point and keeps the upstream OIDC HandleRedirectActivity
non-exported.
android {
defaultConfig {
manifestPlaceholders["oidcRedirectScheme"] = "com.quran.oauth"
manifestPlaceholders["oidcRedirectHost"] = "callback"
manifestPlaceholders["oidcPostLogoutRedirectScheme"] = "com.quran.oauth"
manifestPlaceholders["oidcPostLogoutRedirectHost"] = "callback"
}
}Do not declare or export
org.publicvalue.multiplatform.oidc.appsupport.HandleRedirectActivity in the app manifest.
If you override AuthConfig.redirectUri or AuthConfig.postLogoutRedirectUri on Android, keep
the corresponding manifest placeholders aligned with those URIs.
import com.quran.shared.auth.di.AuthFlowFactoryProvider
import com.quran.shared.persistence.DriverFactory
import com.quran.shared.pipeline.AppEnvironment
import com.quran.shared.pipeline.di.SharedDependencyGraph
import com.quran.shared.pipeline.storage.createMobileSyncStorage
import org.publicvalue.multiplatform.oidc.appsupport.AndroidCodeAuthFlowFactory
val authFactory = AndroidCodeAuthFlowFactory(useWebView = false)
authFactory.registerActivity(activity)
AuthFlowFactoryProvider.initialize(authFactory)
val graph = SharedDependencyGraph.init(
driverFactory = DriverFactory(context = applicationContext),
storage = createMobileSyncStorage(applicationContext),
appEnvironment = AppEnvironment.PRELIVE,
clientId = appClientId
)
val authService = graph.authService
val syncService = graph.syncServiceimport Shared
final class AppContainer {
static let shared = AppContainer()
static var graph: AppGraph { shared.graph }
let graph: AppGraph
private init() {
Shared.AuthFlowFactoryProvider.shared.doInitialize()
let driverFactory = DriverFactory()
let storage = AppleMobileSyncStorageFactory.shared.create()
graph = SharedDependencyGraph.shared.doInit(
driverFactory: driverFactory,
storage: storage,
appEnvironment: AppEnvironment.prelive,
clientId: appClientId,
clientSecret: appClientSecret
)
}
}Advanced override for custom endpoints remains available:
import com.quran.shared.auth.model.AuthEnvironment
import com.quran.shared.auth.model.AuthConfig
import com.quran.shared.syncengine.SynchronizationEnvironment
val graph = SharedDependencyGraph.init(
driverFactory = DriverFactory(context = applicationContext),
storage = createMobileSyncStorage(applicationContext),
environment = SynchronizationEnvironment(
endPointURL = "https://custom-sync.example.com/auth"
),
authConfig = AuthConfig(
environment = AuthEnvironment.PRELIVE,
clientId = appClientId
)
)The shared Android storage uses stable DataStore file names so apps can exclude derived sync state and token-store data from backup or device transfer:
<exclude domain="file" path="datastore/quran_mobile_sync_settings.preferences_pb"/>
<exclude domain="file" path="datastore/org.publicvalue.multiplatform.oidc.tokenstore.preferences_pb"/>Add those entries to both full-backup-content and Android 12+ data-extraction-rules when app
backup is enabled.
Core API examples:
authState, bookmarks, collectionsWithBookmarks, notes
addBookmark, deleteBookmark, addCollection, deleteCollection, addNote, deleteNote
triggerSync()
SyncAuthService.login(), loginWithReauthentication(), logout(), and clearError()
Lifecycle note:
SyncService is app-scoped. Initialize once via SharedDependencyGraph.init(...).SyncAuthService.logout() for managed apps. SyncService.logout() remains as a compatibility delegate.The published artifact can target either environment at runtime:
AppEnvironment.PRELIVE / AppEnvironment.prelive
AppEnvironment.PRODUCTION / AppEnvironment.production
AppEnvironment keeps auth and sync aligned by default:
PRELIVE -> https://prelive-oauth2.quran.foundation and https://apis-prelive.quran.foundation/auth
PRODUCTION -> https://oauth2.quran.foundation and https://apis.quran.foundation/auth
buildkonfig.flavor now only controls the default fallback used when the app does not pass an explicit app environment.
Default fallback (gradle.properties):
buildkonfig.flavor=debugOverride for release-like builds:
./gradlew :auth:build -Pbuildkonfig.flavor=releasedemo/android app module.CLI build example:
./gradlew :demo:android:assembleDebugdemo/apple/QuranSyncDemo/QuranSyncDemo.xcodeproj
QuranSyncDemo scheme.The Xcode project includes a Run Script phase that calls:
./gradlew :umbrella:embedAndSignAppleFrameworkForXcodeCLI build example:
xcodebuild -project demo/apple/QuranSyncDemo/QuranSyncDemo.xcodeproj \
-scheme QuranSyncDemo \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
build CODE_SIGNING_ALLOWED=NOBuild all:
./gradlew buildRun all tests:
./gradlew allTests --stacktrace --continueBuild iOS XCFramework:
./gradlew :umbrella:assembleSharedXCFrameworkBuild Android demo compile target:
./gradlew :demo:android:compileDebugKotlinBuild iOS KMP target:
./gradlew :sync-pipelines:compileKotlinIosSimulatorArm64Kotlin Multiplatform sync/data stack for Quran mobile apps.
This repository contains shared modules used by Android and iOS apps for:
SyncService)auth + persistence + syncengine are composed in sync-pipelines, exposed through a managed DI graph (AppGraph / SharedDependencyGraph), and exported to iOS through umbrella.
flowchart LR
UI[Android/iOS UI] --> G[AppGraph]
G --> S[SyncService]
G --> F[SyncAuthService]
F --> A[Auth Module]
S --> A
S --> P[Persistence Module]
S --> E[SyncEngine Module]
U[Umbrella XCFramework] --> UI| Module | Purpose |
|---|---|
:auth |
OIDC login/logout, auth state, token handling |
:persistence |
SQLDelight DB, repositories for bookmarks/collections/notes/reading sessions |
:syncengine |
Core sync engine and scheduling |
:sync-pipelines |
DI graph and SyncService orchestration API |
:umbrella |
iOS XCFramework export (Shared.xcframework) |
:demo:android |
Android sample app (Compose) |
:demo:common |
Shared demo helpers/models |
:mutations-definitions |
Shared mutation/domain definitions |
Optional but recommended:
local.properties with the OAuth client ID for the Android demo app:OAUTH_CLIENT_ID=your_client_idPublished library artifacts do not embed credentials. Android apps are OAuth public clients using PKCE and must not embed OAuth client secrets.
git clone https://github.com/quran/mobile-sync.git
cd mobile-sync./gradlew help./gradlew allTests --stacktrace --continueChoose one integration mode per database.
Persistence-only mode uses the public :persistence repositories directly. Repositories preserve
local integrity and mutation tracking, but consumers must not mutate SQL tables directly. Use this
mode only when the database is not managed by :sync-pipelines.
Managed sync-capable mode uses :sync-pipelines through AppGraph. In this mode, all
user-visible data reads and writes go through SyncService, and authentication goes through
SyncAuthService. Do not mix direct persistence writes with the managed sync graph. If sync may be
enabled later for the same app data, use managed mode from day one, even while logged out.
Logged-out managed-service writes are local-first; sync triggers no-op until the user is
authenticated.
AppGraph exposes only managed facades: syncService: SyncService and
authService: SyncAuthService. Raw write-capable persistence repositories are no longer exposed
from :sync-pipelines AppGraph; they remain public in :persistence for persistence-only
consumers. The raw :auth AuthService is hidden from AppGraph and wrapped by
SyncAuthService.
This repository is still in active development. Schema changes are made directly in .sq files;
.sqm migrations are not currently maintained.
Configure the Android app manifest placeholders for the login and post-logout redirect URIs. The
:auth artifact contributes com.quran.shared.auth.android.SafeOidcRedirectActivity as the
exported browser callback entry point and keeps the upstream OIDC HandleRedirectActivity
non-exported.
android {
defaultConfig {
manifestPlaceholders["oidcRedirectScheme"] = "com.quran.oauth"
manifestPlaceholders["oidcRedirectHost"] = "callback"
manifestPlaceholders["oidcPostLogoutRedirectScheme"] = "com.quran.oauth"
manifestPlaceholders["oidcPostLogoutRedirectHost"] = "callback"
}
}Do not declare or export
org.publicvalue.multiplatform.oidc.appsupport.HandleRedirectActivity in the app manifest.
If you override AuthConfig.redirectUri or AuthConfig.postLogoutRedirectUri on Android, keep
the corresponding manifest placeholders aligned with those URIs.
import com.quran.shared.auth.di.AuthFlowFactoryProvider
import com.quran.shared.persistence.DriverFactory
import com.quran.shared.pipeline.AppEnvironment
import com.quran.shared.pipeline.di.SharedDependencyGraph
import com.quran.shared.pipeline.storage.createMobileSyncStorage
import org.publicvalue.multiplatform.oidc.appsupport.AndroidCodeAuthFlowFactory
val authFactory = AndroidCodeAuthFlowFactory(useWebView = false)
authFactory.registerActivity(activity)
AuthFlowFactoryProvider.initialize(authFactory)
val graph = SharedDependencyGraph.init(
driverFactory = DriverFactory(context = applicationContext),
storage = createMobileSyncStorage(applicationContext),
appEnvironment = AppEnvironment.PRELIVE,
clientId = appClientId
)
val authService = graph.authService
val syncService = graph.syncServiceimport Shared
final class AppContainer {
static let shared = AppContainer()
static var graph: AppGraph { shared.graph }
let graph: AppGraph
private init() {
Shared.AuthFlowFactoryProvider.shared.doInitialize()
let driverFactory = DriverFactory()
let storage = AppleMobileSyncStorageFactory.shared.create()
graph = SharedDependencyGraph.shared.doInit(
driverFactory: driverFactory,
storage: storage,
appEnvironment: AppEnvironment.prelive,
clientId: appClientId,
clientSecret: appClientSecret
)
}
}Advanced override for custom endpoints remains available:
import com.quran.shared.auth.model.AuthEnvironment
import com.quran.shared.auth.model.AuthConfig
import com.quran.shared.syncengine.SynchronizationEnvironment
val graph = SharedDependencyGraph.init(
driverFactory = DriverFactory(context = applicationContext),
storage = createMobileSyncStorage(applicationContext),
environment = SynchronizationEnvironment(
endPointURL = "https://custom-sync.example.com/auth"
),
authConfig = AuthConfig(
environment = AuthEnvironment.PRELIVE,
clientId = appClientId
)
)The shared Android storage uses stable DataStore file names so apps can exclude derived sync state and token-store data from backup or device transfer:
<exclude domain="file" path="datastore/quran_mobile_sync_settings.preferences_pb"/>
<exclude domain="file" path="datastore/org.publicvalue.multiplatform.oidc.tokenstore.preferences_pb"/>Add those entries to both full-backup-content and Android 12+ data-extraction-rules when app
backup is enabled.
Core API examples:
authState, bookmarks, collectionsWithBookmarks, notes
addBookmark, deleteBookmark, addCollection, deleteCollection, addNote, deleteNote
triggerSync()
SyncAuthService.login(), loginWithReauthentication(), logout(), and clearError()
Lifecycle note:
SyncService is app-scoped. Initialize once via SharedDependencyGraph.init(...).SyncAuthService.logout() for managed apps. SyncService.logout() remains as a compatibility delegate.The published artifact can target either environment at runtime:
AppEnvironment.PRELIVE / AppEnvironment.prelive
AppEnvironment.PRODUCTION / AppEnvironment.production
AppEnvironment keeps auth and sync aligned by default:
PRELIVE -> https://prelive-oauth2.quran.foundation and https://apis-prelive.quran.foundation/auth
PRODUCTION -> https://oauth2.quran.foundation and https://apis.quran.foundation/auth
buildkonfig.flavor now only controls the default fallback used when the app does not pass an explicit app environment.
Default fallback (gradle.properties):
buildkonfig.flavor=debugOverride for release-like builds:
./gradlew :auth:build -Pbuildkonfig.flavor=releasedemo/android app module.CLI build example:
./gradlew :demo:android:assembleDebugdemo/apple/QuranSyncDemo/QuranSyncDemo.xcodeproj
QuranSyncDemo scheme.The Xcode project includes a Run Script phase that calls:
./gradlew :umbrella:embedAndSignAppleFrameworkForXcodeCLI build example:
xcodebuild -project demo/apple/QuranSyncDemo/QuranSyncDemo.xcodeproj \
-scheme QuranSyncDemo \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
build CODE_SIGNING_ALLOWED=NOBuild all:
./gradlew buildRun all tests:
./gradlew allTests --stacktrace --continueBuild iOS XCFramework:
./gradlew :umbrella:assembleSharedXCFrameworkBuild Android demo compile target:
./gradlew :demo:android:compileDebugKotlinBuild iOS KMP target:
./gradlew :sync-pipelines:compileKotlinIosSimulatorArm64