Skip to content

About

Quran Mobile Sync Engine

Resources

Stars

2 stars

Watchers

4 watching

Forks

Repository files navigation

mobile-sync

Kotlin Multiplatform sync/data stack for Quran mobile apps.

This repository contains shared modules used by Android and iOS apps for:

  • Authentication (OIDC)
  • Local persistence (SQLDelight)
  • Sync engine orchestration
  • A unified managed data API (QuranDataService)

Table of Contents

Architecture

auth + persistence + sync-v2 are composed in sync-pipelines, exposed through a managed DI graph (AppGraph / SharedDependencyGraph), and exported to iOS through umbrella. The repositories write plain domain tables (no sync columns); the sync engine diffs them against the last state both sides agreed on (sync_item) to find what to push.

flowchart LR
  UI[Android/iOS UI] --> G[AppGraph]
  G --> S[QuranDataService]
  G --> F[SyncAuthService]
  F --> A[Auth Module]
  S --> A
  S --> P[Persistence Module]
  S --> E[sync-v2 engine]
  P --> E
  U[Umbrella XCFramework] --> UI
Loading

Sync

  • Engine (:sync-v2). Each run fetches every change since the stored token, merges it three ways with the local changes (an edit beats a delete), and pushes what is left in batches of at most 100. An item the server keeps refusing is held, so the rest still syncs. SYNC_ENGINE.md maps how.
  • Persistence on plain tables. The repositories write plain domain tables, owned by :sync-v2's types, through one store per type, so the app and the importer follow the same rules. The tables carry no sync columns; the engine keeps its own state in sync_item and sync_meta.
  • Wiring (:sync-pipelines). SyncClient runs the engine for the signed-in account and reports each run; SyncScheduler decides when runs happen.
  • When it syncs. A run starts 30 seconds after app start, 5 seconds after a local change, and at once for triggerSync(immediately = true). After every run, success or not, the next one is due in 30 minutes, so sync never stops. Runs never overlap. A failed run is retried with exponential backoff (200 ms, ×2.5, up to 5 retries). A 403 that may be the token's scope pauses pushes for a while; automatic runs keep pulling.
  • Auth. The sync account is the ID token's sub. Signing in to a different account wipes the previous account's local data before anything is pushed. A 401 forces one token refresh and runs again; a refused refresh ends the session, while a network error, a timeout or a server error during a refresh keeps it.
  • Status. QuranDataService.syncStatus shows Idle, Syncing, or how the latest run ended: Succeeded with its time, or Failed with its time and a SyncError.

Modules

Module Purpose
:auth OIDC login/logout, auth state, token handling
:persistence The app database (MobileSyncDatabase) and the repositories for bookmarks/collections/notes/reading sessions, over :sync-v2's plain tables
:sync-v2 The generic sync engine (fetch, three-way merge, push) and the plain domain tables of its types; see SYNC_ENGINE.md
:sync-pipelines DI graph, the sync client and scheduler, and the QuranDataService managed data API
:umbrella iOS XCFramework export (Shared.xcframework)
:demo:android Android sample app (Compose)
:demo:common Shared demo helpers/models
:mutations-definitions Shared helpers: the server's text rules (ServerText) and the DI scope (AppScope)
:logging Routes logs, including HTTP calls, to a host app's logger (MobileSyncLogging)

Requirements

  • JDK 17
  • Android Studio (for Android demo)
  • Xcode (for iOS demo)
  • Cocoa toolchain for iOS simulator/device builds

Optional but recommended:

  • local.properties with the OAuth client ID for the Android demo app:
OAUTH_CLIENT_ID=your_client_id

Published library artifacts do not embed credentials. Android apps are OAuth public clients using PKCE and must not embed OAuth client secrets.

Quick Start

  1. Clone and enter the repo:
git clone https://github.com/quran/mobile-sync.git
cd mobile-sync
  1. Ensure Gradle can resolve dependencies:
./gradlew help
  1. Run tests:
./gradlew allTests --stacktrace --continue

Usage

Integration modes

Choose 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 QuranDataService, 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: quranDataService: QuranDataService 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.

1. Initialize managed app graph (Android/Kotlin)

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.

Graph construction does not require an Android Activity. Build the graph from application context and runtime configuration. Optionally route Mobile Sync's logs to the app's logger with MobileSyncLogging.configure(sink, minSeverity); it must come before SharedDependencyGraph.init, because HTTP clients read the severity when they are created. At DEBUG, HTTP calls also log their headers and bodies, which hold user content such as note text. configure replaces Kermit's process-wide logger configuration, so if the app or another library also logs with Kermit, those logs reach the sink too. Without configure, logs go to the platform log, including a summary line for each HTTP request and response.

import com.quran.shared.logging.MobileSyncLogSeverity
import com.quran.shared.logging.MobileSyncLogging
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

// Optional.
MobileSyncLogging.configure(
    sink = { severity, tag, message -> appLog(severity, tag, message) },
    minSeverity = MobileSyncLogSeverity.INFO
)

val graph = SharedDependencyGraph.init(
    driverFactory = DriverFactory(context = applicationContext),
    storage = createMobileSyncStorage(applicationContext),
    appEnvironment = AppEnvironment.PRELIVE,
    clientId = appClientId
)

val authService = graph.authService
val quranDataService = graph.quranDataService

If clientId is blank, the same initializer creates a managed local-only graph. QuranDataService continues to read and write local data, while SyncAuthService.isAuthenticationConfigured is false and sign-in fails with an authentication-not-configured error. This lets open-source builds run without bundled OAuth credentials.

Register the Android browser auth flow only before interactive sign-in. The registered Activity handles browser launch and redirect delivery; it is not needed for graph construction or local data operations.

import com.quran.shared.auth.di.AuthFlowFactoryProvider
import org.publicvalue.multiplatform.oidc.appsupport.AndroidCodeAuthFlowFactory

if (graph.authService.isAuthenticationConfigured) {
    val authFactory = AndroidCodeAuthFlowFactory(useWebView = false)
    authFactory.registerActivity(activity)
    AuthFlowFactoryProvider.initialize(authFactory)
}

// Later, from the login UI:
graph.authService.login()

2. Initialize managed app graph (iOS/Swift)

As on Android, the optional MobileSyncLogging.configure(sink:minSeverity:) call must come before SharedDependencyGraph.doInit, because HTTP clients read the severity when they are created.

Kotlin default arguments are not exported to Swift, so pass every argument, such as triggerSync(immediately: false).

import Shared

final class AppContainer {
    static let shared = AppContainer()
    static var graph: AppGraph { shared.graph }

    let graph: AppGraph

    private init() {
        // Optional; `AppLogSink` conforms to `MobileSyncLogSink`.
        MobileSyncLogging.shared.configure(sink: AppLogSink(), minSeverity: .info)
        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.pipeline.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
    )
)

Android backup exclusions

Sync and session lifecycle metadata is stored in the shared quran.db database next to the data it describes (the sync token, account and outbox in sync_meta, per-item sync state in sync_item). Auth metadata and OAuth tokens stay outside the database, so a lost or recreated database does not sign the user out. Both use stable DataStore file names so apps can exclude them from backup or device transfer:

<exclude domain="file" path="datastore/quran_mobile_sync_auth.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.

3. Use managed services

Core API examples:

  • Observe: authState, syncStatus, bookmarks, collectionsWithBookmarks, notes
  • Mutations: addBookmark, deleteBookmark, addCollection, deleteCollection, addNote, deleteNote
  • Trigger sync: triggerSync() after a burst of edits, or triggerSync(immediately = true) for a user's "Sync now"
  • Auth: use SyncAuthService.login(), loginWithReauthentication(), logout(), and clearError()

Import local data through QuranDataService.importData(PersistenceImportData(...)). Each ImportCollectionAyahBookmark.collectionName must name an ImportCollection in the same batch. Names match exactly, except that any case or surrounding-whitespace variant of Favorites resolves to the default collection; entries sharing a name describe one destination. Use readingBookmarks with ImportReadingBookmark.Ayah or .Page, each specifying a ReadingBookmarkSlot (TEAL, ORANGE, or RED), lastUpdated, and an optional name. Each slot may appear once; page numbers must be in 1..604. An imported slot replaces its location, name (including clearing it when null), and modification time. Merge imports leave omitted slots unchanged; deleteExisting = true clears their locations while preserving slot identities and names for sync. Imports are atomic and report the number of supplied reading bookmarks in PersistenceImportResult.readingBookmarksImported. An import writes through the repositories' rules: text is trimmed like the server, each note gets a creation time of its own (nudged by a millisecond on a clash), a verse keeps one highlight color, and only the newest 20 reading sessions are kept. The next sync pushes it like any other local change. With trackHistory = true, an import records a fingerprint of each item it handled, and later tracked imports skip those items, even after the user edits or deletes what they brought in. Logout keeps this history, so re-running a legacy import afterwards can't bring back deleted items or add the legacy data to another account.

Lifecycle note:

  • QuranDataService is app-scoped. Initialize once via SharedDependencyGraph.init(...).
  • Prefer SyncAuthService.logout() for managed apps. QuranDataService.logout() remains available for callers that route auth and data lifecycle through one facade.
  • Do not clear app-scoped services from UI/view-model teardown.

4. App environment selection

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=debug

Override for release-like builds:

./gradlew :auth:build -Pbuildkonfig.flavor=release

Run the Demos

Android demo

  1. Open project in Android Studio.
  2. Run demo/android app module.

CLI build example:

./gradlew :demo:android:assembleDebug

iOS demo

  1. Open: demo/apple/QuranSyncDemo/QuranSyncDemo.xcodeproj
  2. Select QuranSyncDemo scheme.
  3. Run on an iOS Simulator.

The Xcode project includes a Run Script phase that calls:

./gradlew :umbrella:embedAndSignAppleFrameworkForXcode

CLI build example:

xcodebuild -project demo/apple/QuranSyncDemo/QuranSyncDemo.xcodeproj \
  -scheme QuranSyncDemo \
  -configuration Debug \
  -destination 'generic/platform=iOS Simulator' \
  build CODE_SIGNING_ALLOWED=NO

Build, Test, and Release Commands

Build all:

./gradlew build

Run all tests:

./gradlew allTests --stacktrace --continue

Run the end-to-end sync integration tests (devices against a fake sync server; see AGENTS.md):

./gradlew :sync-pipelines:jvmTest --tests 'com.quran.shared.pipeline.integration.*'

Build iOS XCFramework:

./gradlew :umbrella:assembleSharedXCFramework

Build Android demo compile target:

./gradlew :demo:android:compileDebugKotlin

Build iOS KMP target:

./gradlew :sync-pipelines:compileKotlinIosSimulatorArm64

Releases

The manual Release workflow bumps the version and tags it, attaches the iOS XCFramework to a GitHub Release, and publishes the Android/JVM artifacts to the GitHub Pages Maven repository on the gh-pages branch. Android apps resolve it with:

maven("https://quran.github.io/mobile-sync/maven") {
    content { includeGroup("com.quran") }
}

Maven Central releases stay manual and infrequent. Publish them from a release tag so both repositories serve the same build for a version.

Publish the GitHub Pages artifacts to a local folder to inspect them:

./gradlew publishToGitHubPages -PgithubPagesMavenDir=/tmp/mobile-sync-maven

Related Repositories

About

Quran Mobile Sync Engine

Resources

Stars

2 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages