رفتن به محتوای اصلی

کلاینت اندروید (KMP)

این صفحه دو نیمه دارد: نیمهٔ اول می‌گوید اپ اندروید امروز واقعاً چه می‌کند؛ نیمهٔ دوم قرارداد و طراحیِ موتور همگام‌سازی را برای پیاده‌سازیِ آینده مشخص می‌کند. مرز این دو در سراسر صفحه با برچسب «طراحی» در عنوان بخش‌ها و بلوک‌های «وضعیت فعلی» مشخص است.

وضعیت فعلی

موتور همگام‌سازی در کلاینت اندروید (KMP) هنوز پیاده‌سازی نشده است. بخش‌های «طراحی» این صفحه قرارداد و طراحیِ موردنیاز برای ساخت را توضیح می‌دهند، نه چیزی که الان وجود دارد. مشخصات کامل در .agents/superpowers/specs/2026-08-23-kmp-sync-engine-design.md (از ریشهٔ مخزن) آمده است. مدل کامل نسخه‌ها، cursor و حل تعارض در همگام‌سازی توضیح داده شده و اپ وب (apps/calendar-web) همین طراحی را پیاده کرده و مرجع عملی است.

امروزِ اپ چه می‌کند؟

  • پایگاه‌دادهٔ Room (نسخهٔ ۱، MyDatabase) فقط یک جدول دارد: CalendarEntity با کلید uid (UUIDv7 به‌صورت ByteArray)، ستون‌های created/updated و soft-delete با ستون deleted. ستون version ندارد.
  • تنها جریان داده، ساخت و فهرستِ تقویم محلی است: CreateCalendarViewModelCalendarRepository@Upsert. نوشتن دیگری وجود ندارد.
  • ماژول تولیدشدهٔ :core/network (پکیج ir.taghvimam.api، Ktor + kotlinx-serialization) کامل است — همهٔ endpointها و مدل‌ها را دارد — اما هیچ فراخوانی از آن در اپ وجود ندارد.
  • AuthPreferencesDataSource (DataStore برای توکن‌های access/refresh) نوشته شده ولی به هیچ جریانی وصل نیست.
  • AndroidManifest.xml حتی مجوز INTERNET را اعلام نکرده است.
  • مدیریت connectivity، WorkManager، FCM و CalendarContract: هیچ‌کدام در پروژه نیستند.
  • تارگت wasm: DatabaseManager هنوز TODO است.

معماری هدف (طراحی)

UI (Compose)
│ نوشتن محلی

Room ── جداول موجودیت (uid، version، deleted) + جدول outbox
│ یک تراکنش: تغییر ردیف + درج outbox

SyncEngine ── push ← pull ← retry ← conflict


سرور /api/sync (از طریق SyncApi تولیدشده در :core/network)

سه قاعدهٔ بنیادی:

  1. آفلاین‌اول. UI فقط به Room می‌نویسد و می‌خواند و هرگز مستقیم به شبکه نمی‌زند؛ SyncEngine در پس‌زمینه داده را با سرور آشتی می‌دهد.
  2. تنها سطح دسترسی به Calendar/Event/Task/Reminder همان /api/sync است — همان قاعده‌ای که اپ وب دارد. رابط جداگانه برای CRUD ننویسید.
  3. شبکه‌سازی از روی کلاینت تولیدشدهٔ Ktor در :core/network می‌آید (پکیج ir.taghvimam.api که از OpenAPI تولید و در مخزن نگهداری می‌شود). رابط Retrofit یا URL دستی ننویسید؛ اگر قرارداد عوض شود، باید در زمان build خطا بگیرید، نه در runtime.

همهٔ محرک‌های همگام‌سازی (بخش «محرک‌ها») به یک تابع syncNow() ختم می‌شوند که با یک Mutex از هم‌پوشانی دو اجرا جلوگیری می‌کند (single-flight).

ورود و bootstrap (طراحی)

ورود با شناسهٔ دستگاه

متدمسیرنقش
POST/api/auth/send-otpارسال کد ۶ رقمی با SMS
POST/api/auth/verify-otpبررسی کد ← توکن‌ها + پروفایل (ورود و ثبت‌نام یک‌جا)
POST/api/auth/refreshگرفتن access token تازه با refresh token

در verify-otp دستگاه به‌صورت یک آبجکت تودرتو معرفی می‌شود — نه فیلدهای تختِ deviceId/fcmToken:

{
"phone": "09121234567",
"otp": "123456",
"device": {
"uid": "8f2b1c4e-6a7d-4e2b-9c1f-3d5a7b9e0f12",
"platform": "ANDROID",
"name": "Pixel 8",
"fcmToken": "eQ…"
}
}
  • device.uid شناسهٔ پایدار همین نصب است و یک‌بار تولید و ذخیره می‌شود؛ platform همیشه "ANDROID" است.
  • پاسخ، کنارِ توکن‌ها، device: DeviceDto اختیاری دارد (برای کاربر تازه، سرور تقویم پیش‌فرض «تقویم اصلی» را هم می‌سازد).
  • access token به همین دستگاه محدود است (device-scoped)؛ درخواست refresh با deviceUid همراه می‌شود و استفادهٔ دوباره از یک refresh token، کل خانوادهٔ توکن‌ها را باطل می‌کند. پس هنگام 401 فقط یک refresh و یک تلاش مجدد لازم است — نه حلقهٔ بی‌نهایت.
  • توکن‌ها در همان AuthPreferences (DataStore) ذخیره می‌شوند که امروز موجود است.

جزئیات جریان ورود در احراز هویت آمده است.

bootstrap: اول pull، بعد push

بعد از verify-otp موفق و پیش از هر نوشتن محلی، ترتیب این است:

  1. اولین pull بدون cursor ← سرور cursorReset: true می‌فرستد و snapshot کامل می‌دهد: تقویم‌ها (از جمله «تقویم اصلی»)، رویدادها، وظایف و یادآورها.
  2. nextCursor را ذخیره کنید. از این به بعد همهٔ pullها دلتایی‌اند.
  3. حالا اقلام local-only (اگر کاربر پیش از ورود چیزی ساخته) با baseVersion = null push می‌شوند.

چرا pull اول؟ چون uidهای والد و versionهای سرور فقط بعد از pull معلوم می‌شوند: بدون pull نمی‌دانید تقویمِ پیش‌فرض چه uidی دارد و بدون پاسخ سرور هیچ baseVersionی برای مقایسه ندارید. اپ وب هم با همین ترتیب بوت می‌شود؛ در حالت پایدار (steady state) ترتیب برعکس می‌شود: اول push بعد pull.

مدل دادهٔ محلی (طراحی)

هر چهار موجودیت ستون‌های مشترک دارند: uid (کلید، UUIDv7، تولیدشدهٔ کلاینت)، version (رشته، پیش‌فرض null تا اولین پاسخ سرور) و deleted (tombstone). روابط همیشه با uid بیان می‌شوند، نه کلید عددی: calendarUid، eventUid، taskUid، parentTaskUid.

@Entity(tableName = "events")
data class EventEntity(
@PrimaryKey val uid: String, // UUIDv7 — سمت کلاینت تولید می‌شود
val version: String? = null, // تا اولین پاسخ سرور null است
val deleted: Long? = null, // null = زنده؛ غیر null = tombstone
val calendarUid: String, // FK به تقویم، به‌صورت uid
val title: String,
val startTime: String?,
val endTime: String?,
)

@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey val uid: String,
val version: String? = null,
val deleted: Long? = null,
val calendarUid: String?,
val parentTaskUid: String?, // FK اختیاری به وظیفهٔ والد
val title: String,
val isCompleted: Boolean = false,
val dueDate: String?,
)

@Entity(tableName = "reminders")
data class ReminderEntity(
@PrimaryKey val uid: String,
val version: String? = null,
val deleted: Long? = null,
val eventUid: String?, // دقیقاً یکی از eventUid / taskUid پر است
val taskUid: String?,
val value: Int,
val type: String, // MINUTE | HOUR | DAY
)
  • version روی wire یک رشتهٔ دهدهی است، چون دنبالهٔ سراسری سرور از 2^53 عبور می‌کند؛ در Room هم همان رشته نگه داشته می‌شود، نه عدد.
  • سرور مالک version است؛ کلاینت هرگز آن را نمی‌نویسد، فقط مقدار برگشتی از push/pull را ذخیره می‌کند و دفعهٔ بعد همان را baseVersion می‌فرستد.
  • tombstoneها نگه داشته می‌شوند (حذفِ محلی یعنی deleted != null) و UI با WHERE deleted IS NULL فیلتر می‌کند.
  • جدول CalendarEntity موجود ماندنی است؛ ستون version و جداول سه‌گانهٔ دیگر با مهاجرتِ Room اضافه می‌شوند.

outbox (طراحی)

هر نوشتن محلی، هم‌زمان یک ردیف در جدول pending_mutations می‌گذارد:

@Entity(tableName = "pending_mutations")
data class PendingMutation(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val entityType: String, // "calendar" | "event" | "task" | "reminder"
val entityUid: String,
val op: String, // "upsert" | "delete"
val baseVersion: String?, // null = هیچ نسخه‌ای از سرور ندیده‌ام (create)
val payloadJson: String, // فیلدهای قابل ویرایش، سریالایزشده
val status: String = "pending",
val attempts: Int = 0,
val lastError: String? = null,
val createdAt: Long,
val updatedAt: Long,
)

قاعدهٔ طلایی: تغییر ردیفِ موجودیت و درج ردیف outbox باید در «یک» تراکنش Room انجام شوند — یا هر دو، یا هیچ‌کدام. این همان چیزی است که نوشتن را در برابر crash مصون می‌کند:

@Dao
interface EventDao {
@Transaction
suspend fun createOffline(e: EventEntity) {
upsert(e)
insertMutation(PendingMutation(
entityType = "event", entityUid = e.uid, op = "upsert",
baseVersion = null, // create
payloadJson = json.encodeToString(e.toPushFields()),
createdAt = now(), updatedAt = now(),
))
}
}

فشرده‌سازی (coalescing): چند mutation ارسال‌نشده برای یک uid را می‌توان به آخرین وضعیت فشرده کرد — با این شرط که baseVersion همان نسخهٔ «اولین» مشاهده‌شده بماند. دلیل روشن است: baseVersion گواهِ «چه دیده بودم» است، نه گزارش وضعیت فعلی؛ سرور با همان نسخهٔ اول تصمیم می‌گیرد که نوشتن شما clean است یا stale. اگر کاربر سه بار پشت‌سرهم عنوان را عوض کند، یک upsert با آخرین عنوان و baseVersion آن نسخه‌ای که آخرین pull دیده بود، کافی است.

حلقهٔ sync (طراحی)

syncNow() دو گام دارد: اول push، بعد pull. اگر outbox خالی باشد (مثل bootstrap)، عملاً فقط pull اجرا می‌شود.

push

آیتم‌های outbox را به چهار آرایه گروه‌بندی و به‌ترتیبِ والد←فرزند می‌فرستد: calendarseventstasksreminders (سقف هر آرایه ۱۰۰۰ آیتم است). نتیجه per-item است؛ یک آیتمِ خطادار بقیهٔ batch را نمی‌اندازد:

private suspend fun push() {
val pending = db.mutations().pendingSorted() // به ترتیب id = ترتیب رخداد
if (pending.isEmpty()) return
val byEntity = pending.groupBy { it.entityType }
val res = api.syncPush(SyncPushInput(
calendars = byEntity["calendar"]?.toItems(),
events = byEntity["event"]?.toItems(),
tasks = byEntity["task"]?.toItems(),
reminders = byEntity["reminder"]?.toItems(),
)).data

for ((entity, results) in res.byEntity()) {
for (r in results) when (r.status) {
// انجام شد — version تازه را نگه دارید و mutation را حذف کنید
CREATED, UPDATED, DELETED, CONFLICT -> {
db.mutations().deleteFor(entity, r.uid)
r.version?.let { db.setVersion(entity, r.uid, it) }
// در CONFLICT نسخهٔ سرور برنده است؛ نسخهٔ محلی با pull بعدی
// بازنویسی می‌شود. conflictId را نگه دارید (بخش «تعارض‌ها»).
if (r.status == CONFLICT) db.markConflict(entity, r.uid, r.conflictId)
}
ERROR -> db.mutations().bumpAttempts(entity, r.uid, r.error)
}
}
}
  • created/updated/deleted/conflict: version برگشتی ذخیره و ردیف outbox حذف می‌شود.
  • conflict: مثل بقیه version می‌گیرد و mutation مصرف می‌شود؛ فقط conflictId برای نمایش به کاربر نگه داشته می‌شود. معنای کامل baseVersion/stale/clean در همگام‌سازی آمده است.
  • error: همان‌جا می‌ماند و در چرخهٔ بعدی — بعد از یک pull — دوباره تلاش می‌شود؛ شاید والدِ گم‌شده (unknown calendarUid و مانند آن) با pull رسیده باشد. بعد از سقف تلاش (مثلاً ۵) با status = "failed" کنار گذاشته و به کاربر گزارش می‌شود تا outbox مسموم نشود. خطاهای per-item و تفکیک «تلاش‌مجددپذیر» از «اصلاح‌کن» در همگام‌سازی فهرست شده‌اند.

push روی { uid, baseVersion } idempotent است؛ تلاش مجدد پس از crash بی‌خطر است. کد 429 (سقف نرخ) یعنی صرف‌نظر از backoff و تلاش در چرخهٔ بعدی.

pull

دلتای سرور را صفحه‌به‌صفحه تا hasMore = false می‌خواند:

private suspend fun pull() {
var cursor = cursorStore.get() // null = pull کامل (bootstrap)
do {
val delta = api.syncChanges(cursor = cursor, limit = 500).data
db.withTransaction {
delta.calendars.forEach { db.calendars().upsert(it) }
delta.events.forEach { db.events().upsert(it) }
delta.tasks.forEach { db.tasks().upsert(it) }
delta.reminders.forEach { db.reminders().upsert(it) }
}
cursor = delta.nextCursor
delta.nextCursor?.let { cursorStore.save(it) } // فقط بعد از اعمال موفق صفحه
// cursorReset = true یعنی cursor قبلی نامعتبر بود و سرور snapshot کامل
// داده — همین رفتار عادی را ادامه دهید.
} while (delta.hasMore)
}
  • هر صفحه در یک تراکنش Room اعمال می‌شود؛ cursor فقط بعد از اعمال موفقِ همان صفحه ذخیره می‌شود تا بعد از crash صفحه‌ای از دست نرود.
  • cursor یک رشتهٔ امضاشده (HMAC) و برای کلاینت مبهم است؛ دستکاری‌اش نکنید.
  • cursorReset = true یعنی جایگزینی کامل snapshot: کل دادهٔ محلی با پاسخ سرور بازنویسی می‌شود (رفتارِ طبیعی bootstrap یا cursor نامعتبر).

محرک‌ها (طراحی)

چهار محرک، همه به همان syncNow():

// ۱) تایمر دوره‌ای — WorkManager
fun schedulePeriodicSync(context: Context) {
val req = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"taghvimam_sync", ExistingPeriodicWorkPolicy.KEEP, req)
}

class SyncWorker(ctx: Context, params: WorkerParameters) :
CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result = try {
ServiceLocator.syncEngine.syncNow()
Result.success()
} catch (e: UnauthorizedException) { Result.failure() // نیاز به ورود دوباره
} catch (e: Exception) { Result.retry() }
}
// ۲) برگشت شبکه — در Application
registerNetworkCallback(
NetworkRequest.Builder().addCapability(NET_CAPABILITY_INTERNET).build(),
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
scope.launch { syncEngine.syncNow() } }
})

// ۳) آمدن اپ به foreground
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
scope.launch { syncEngine.syncNow() } }
})

// ۴) پیام دادهٔ FCM با type=sync
class SyncFirebaseService : FirebaseMessagingService() {
override fun onMessageReceived(msg: RemoteMessage) {
if (msg.data["type"] == "sync") WorkManager.getInstance(this)
.enqueueUniqueWork("sync_kick", ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<SyncWorker>().build())
}
}

ضمناً بعد از هر نوشتن محلی یک kick() با debounce کوتاه (حدود ۵۰۰ms) بزنید تا تغییری که همین حالا کرده‌اید سریع برود — چند نوشتنِ پیاپی در یک اجرای واحد ادغام می‌شوند. Mutex داخل syncNow() از هم‌پوشانی محرک‌ها جلوگیری می‌کند.

تعارض‌ها (طراحی)

اگر push برای آیتمی status: "conflict" برگرداند، یعنی دستگاه دیگری همان رکورد را زودتر تغییر داده و نوشتن این دستگاه باخته است. نسخهٔ بازنده در SyncConflict سرور ذخیره می‌شود و تصمیم با کاربر است:

class ConflictRepository(private val apiProvider: ApiProvider) {

suspend fun open() =
apiProvider.api().syncConflicts().data.conflicts
.filter { it.resolvedAt == null } // فقط حل‌نشده‌ها

/** «نسخهٔ من درست بود» — snapshot بازنده دوباره اعمال می‌شود. */
suspend fun restore(conflictId: Long) =
apiProvider.api().syncConflictsResolve(
conflictId, SyncConflictResolveInput(action = "restore"))

/** «نسخهٔ سرور بماند» — تعارض بسته می‌شود و برنده دست نمی‌خورد. */
suspend fun dismiss(conflictId: Long) =
apiProvider.api().syncConflictsResolve(
conflictId, SyncConflictResolveInput(action = "dismiss"))
}

پس از restore یا dismiss یک syncNow() بزنید تا دلتای نتیجه برسد. معنای دقیق restore (اعمال دوبارهٔ snapshot بازنده و گرفتن version تازه) و dismiss در همگام‌سازی آمده است.

خروج (طراحی)

خروج روی دستگاه اشتراکی یک الزام امنیتی است؛ هر چهار گام را با هم انجام دهید:

suspend fun logout() {
runCatching { apiProvider.api().logout() } // سمت سرور: ابطال توکن‌ها
tokens.clear() // ۱) توکن‌ها (AuthPreferences)
db.clearAllTables() // ۲) دادهٔ محلی Room (همهٔ موجودیت‌ها + outbox)
cursorStore.clear() // ۳) cursor همگام‌سازی
}

اگر فقط توکن‌ها پاک شوند، کاربر بعدی روی همان گوشی دادهٔ کاربر قبلی را می‌بیند؛ اگر cursor پاک نشود، کاربر بعدی با cursor متعلق به کاربر قبلی pull می‌زند و سرور با snapshot کامل پاسخ می‌دهد — درست، اما فقط بعد از یک رفت‌وبرگشت اضافه. پاک‌سازی هر سه، مسیرِ تمیز است.

تقویم سیستم اندروید

درون‌ریزی رویدادها از CalendarProvider (تقویم سیستم اندروید) پیاده‌سازی نشده و فعلاً فقط یک قلمِ نقشهٔ راه است. تا آن زمان، تنها منبع دادهٔ اپ همان /api/sync است و هیچ نگاشتی به تقویم سیستم ساخته نمی‌شود.


مدل کامل نسخه‌ها، cursor و حل تعارض در همگام‌سازی، ساختار کلاینت تولیدشده در کلاینت کاتلین و جریان کامل ورود در احراز هویت آمده است.