کلاینت اندروید (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ندارد. - تنها جریان داده، ساخت و فهرستِ تقویم محلی است:
CreateCalendarViewModel←CalendarRepository←@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)
سه قاعدهٔ بنیادی:
- آفلایناول. UI فقط به Room مینویسد و میخواند و هرگز مستقیم به شبکه نمیزند؛
SyncEngineدر پسزمینه داده را با سرور آشتی میدهد. - تنها سطح دسترسی به Calendar/Event/Task/Reminder همان
/api/syncاست — همان قاعدهای که اپ وب دارد. رابط جداگانه برای CRUD ننویسید. - شبکهسازی از روی کلاینت تولیدشدهٔ 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 موفق و پیش از هر نوشتن محلی، ترتیب این است:
- اولین pull بدون cursor ← سرور
cursorReset: trueمیفرستد و snapshot کامل میدهد: تقویمها (از جمله «تقویم اصلی»)، رویدادها، وظایف و یادآورها. nextCursorرا ذخیره کنید. از این به بعد همهٔ pullها دلتاییاند.- حالا اقلام local-only (اگر کاربر پیش از ورود چیزی ساخته) با
baseVersion = nullpush میشوند.
چرا 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 را به چهار آرایه گروهبندی و بهترتیبِ والد←فرزند میفرستد: calendars → events → tasks → reminders (سقف هر آرایه ۱۰۰۰ آیتم است). نتیجه 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 و حل تعارض در همگامسازی، ساختار کلاینت تولیدشده در کلاینت کاتلین و جریان کامل ورود در احراز هویت آمده است.