کلاینت اندروید (KMP)
کلاینت اندروید (Kotlin Multiplatform) چهار موجودیت تقویمم — تقویم، رویداد، کار و یادآور — را کاملاً آفلاین ویرایش میکند؛ ساخت زیروظیفه هم در رابط کاربری هست. این صفحه نقشهٔ کد و رفتارهای خاص اندروید را توضیح میدهد؛ قرارداد شبکه (پروتکل ۲، وضعیتها، reasonها و cursor) در همگامسازی است و اینجا تکرار نمیشود.
از کجا شروع کنم؟
همهٔ مسیرها زیر apps/mobile/ هستند. بستههای قدیمی com.fanoos هنوز در کد ماندهاند؛ این جدول نقطهٔ ورود هر بخش است:
| مسیر | نقطهٔ ورود |
|---|---|
android/src/main/…/ | CalendarApplication (Koin و راهاندازی sync)؛ CalendarActivity (Compose و SAF)؛ AndroidSyncScheduler |
feature/app/src/commonMain/…/ | CalendarApp: ناوبری ورود/خانه و بازیابی بازکلید |
feature/home/src/commonMain/…/sync/ | RecordsRoute → RecordsVM → RecordEditor؛ ConflictsRoute |
feature/account/src/commonMain/…/ | auth/ (ورود OTP)؛ AccountRoute/AccountVM (خروج) |
core/data/src/commonMain/…/ | sync/ (repository، موتور و recovery)؛ auth/ (نشست و خروج)؛ wiring در DataModule |
core/database/src/ | MyDatabase، SyncRows، dao/SyncDao؛ migrationها در androidMain |
core/network/ | SDK تولیدشده؛ دستنویس ممنوع |
تغییر آفلاین چطور به سرور میرسد؟
مسیر داده: RecordsVM → OfflineRepository.edit. این فراخوانی ردیف محلی و نیت متناظرش در outbox را در یک تراکنش Room ثبت میکند و بعد به scheduler خبر میدهد. اگر کاربر همان رکورد را حین ارسال دوباره ویرایش کند، یک mutation تازه ساخته میشود و درخواست جاری دستنخورده میماند.
چرخهٔ همگامسازی:
- حساب تازه اول یک pull کامل میگیرد؛ دور عادی بعد از آن
push → pull → refreshConflictsاست. - push آیتمها را به ترتیب وابستگی میفرستد: تقویم → رویداد → کار (والد قبل از فرزند) → یادآور؛ حذفها برعکس. پاسخی که به آیتمی نمیخورد، آن را از صف مصرف نمیکند؛ خطای شبکه همان شناسه و محتوا را در صف نگه میدارد تا تکرار امن باشد.
- pull صفحه و cursor را برای همان حساب اتمیک ذخیره میکند. اگر روی رکوردی پیشنویس تغییر باز دارید، پیشنویس حفظ و canonical جدا ذخیره میشود.
cursorResetهیچ دادهای پاک نمیکند. - triggerهای شروع دور: آغاز نشست، آمدن برنامه به foreground، بازگشت اتصال و ویرایش کاربر. دورها همزمان یکی اجرا میشوند.
- timeout درخواست sync و socket برابر ۱۲۰ ثانیه و timeout اتصال ۱۵ ثانیه است (
SyncHttpClientConfig).
محدودیت مهم: از WorkManager استفاده نمیشود؛ sync درون process برنامه اجرا میشود. اگر سیستم عملیات فرایند را بکشد، تا trigger بعدی چیزی ارسال نمیشود — دادهها پاک نمیشوند، فقط ارسال به تعویق میافتد.
ارتقا و هویت حساب
Room روی نسخهٔ v4 است و builder سه migration دارد؛ fallback مخرب (drop-all) وجود ندارد:
- v1→v2: شناسههای BLOB و فیلدهای تقویم، زیر مالکیت
importحفظ میشوند. - v2→v3: کش تعارض.
Migration3To4: ستون nullableproviderIdبرای تقویم/رویداد/یادآور.
دادههای importشدهٔ خودکار ارسال نمیشوند؛ با associateImport یا ویرایش زیر حساب فعال، مالکیتشان منتقل و به صف ارسال میروند.
ورود اندروید فعلاً به شکل قدیمی کار میکند و این محدودیتها را دارد:
- OTP واقعی همراه با یک
deviceIdتصادفیِ نصب فرستاده میشود که در DataStore میماند — حتی پس از خروج. - شیء
deviceفرستاده نمیشود؛ نشست در slot قدیمی User است، نه نشست دستگاهمحور. در فهرست دستگاهها دیده نمیشود و ورود قدیمیِ دستگاه دیگری میتواند refresh آن را جایگزین کند. - حساب از
user.idسرور شناسایی میشود. - refresh تکپروازی است: خطای شبکه/5xx داده را نگه میدارد؛ ابطال refresh (پاسخ 401/403) فقط «نیاز به ورود دوباره» را ثبت میکند، Room و outbox را پاک نمیکند.
تغییرم گیر کرده یا میخواهم خارج شوم
| وضعیت | اقدام |
|---|---|
| تعارض محلی (stale) | از منو «تعارضهای همگامسازی» را باز کنید: پذیرش نسخهٔ سرور، ارسال دوبارهٔ نسخهٔ خودتان با base/شناسهٔ تازه، یا اصلاح نام/عنوان. رابطههای والد-فرزند در ویرایشگر رکورد تغییر میکنند. |
| حذف در سرور | گزینهٔ «نسخهٔ من» یک restore نسخهدار است؛ زندهسازی خودکار وجود ندارد. |
| تعارض تاریخی | رد یا بازگردانی با شبکه انجام میشود؛ شکست موقت مورد را نگه میدارد. |
| تغییر uid تقویم پیشفرض | effectiveUid سرور مبناست. ConflictRecovery ارجاعها و outbox را بازکلید میکند و DataStore نیمهکاره را پس از restart ترمیم میکند. |
| خروج | ارسال باقیماندهها؛ خروجی JSON-lines با SAF و بعد خروج؛ یا دورریز با تأیید دوم. لغو یا شکست خروجی، چیزی پاک نمیکند. |
احتیاط: پیش از پاکسازی حساب، «همهٔ کار ذخیره شد» را فقط از صفر بودن
pendingوin_flightنفهمید — drain وضعیتfailedو تعارضهای باز را نمیسنجد. اول حلشان کنید یا خروجی بگیرید. خروج فقط دادهٔ همین حساب را پاک میکند (نه import و نه حساب دیگر)؛ fence و نسل نشست، پاسخ دیررس حساب قبلی را رد میکنند.
API محلی و debug
تنها build نوع debug این override را میخواند. API و DB هدف باید آزمایشی باشند؛ میزبان emulator برای API روی کامپیوتر توسعه، 10.0.2.2 است:
adb shell setprop debug.taghvimam.sync.host http://10.0.2.2:5000
adb shell getprop debug.taghvimam.sync.host
خروجی دستور دوم باید دقیقاً http://10.0.2.2:5000 باشد:
- پیش از هر instrumentation و پس از هر reboot دوباره چک کنید — boot سرد emulator این property را پاک میکند.
- خالی بودن override یعنی هدف
https://api.taghvimam.irتولید است؛ در آن حالت برنامه را اجرا نکنید. - بعد از تنظیم، فرایند debug را دوباره راهاندازی کنید.
ساخت، تست و rig امن journey در تستها توضیح داده شده است.
مرز قابلیتها
اینها را از وجود فیلد یا کد نتیجه نگیرید:
providerIdدر DTOهای تولیدشدهٔ Kotlin برای تقویم/رویداد/یادآور nullableLongاست؛ مقدار روی wire باید عدد صحیح امن JSON در بازهٔ-9007199254740991..9007199254740991باشد.- اتصال به Calendar Provider اندروید و اعلانهای سیستم پیا ده نشدهاند.
- وجود
ErrorLogsApiدر SDK به معنی ارسال خودکار خطاهای اندروید نیست. - هدف
web/داخل همین پروژهٔ KMP، خروجی آزمایشی Wasm و خارج از CI است؛ با کلاینت وب React اشتباه نگیرید.
خطِ UI ادغامشده (Fanoos dev)
شاخهٔ dev فانوس (۲۱ کامیت: ویرایشگر رویداد، رخداددهای ماهِ تنبل، مجوزهای تقویم،
رنگها، صفحههای احراز هویت) در ۲۰۲۶-10-10 روی موتورِ همین صفحه ادغام شد. اصل ادغام:
موتور main معتبر است؛ UI فانوس روی آن نشسته است. سیستم sync خودنویسِ فانوس
(SyncOrchestrator/PullChangesUC/SyncApi دستی + جدول موازی event) حذف شد و
EventRepo روی مخزن موتور بازنویسی شد: خواندن از SyncDao.eventRowsFor(account)
(displayJson = همان DTOهای wire) و نوشتن از OfflineRepository.edit — هر ذخیرهٔ
ویرایشگر، ردیف نمایش + intent ارسال را در یک تراکنش میگذارد. کلاسهای
CalendarUid/EventUid لایهٔ UI نگه داشته شدهاند؛ قرارداد موتور همان CalendarId
بایتی + متنِ UUID قاعدهمند روی wire است. گراف Koin با AuthTokensBus کامل شد
(KtorApiImpl به آن وابسته است؛ DataSource احراز هویت خودش آن را پیاده میکند).
شواهد و محدودیتها
سفرِ ۱۴ سناریویی روی emulator (API 35) و روی دستگاه فیزیکی واقعی (Android 14 از طریق adb reverse) در برابر API واقعی و DB آزمایشی سبز شده است: ورود واقعی، ساخت آفلاینِ زنجیرهٔ تقویم→رویداد→وظیفه تو در تو→یادآور، force-stop و ادامه، حذف و tombstone، گمشدن پاسخ پس از commit بدون نوشتهٔ دوباره، تعارض ویرایش/حذف با انتخاب کاربر، جداسازی حساب B، ارتقای DB از v1 و برخورد uid پیشفرض با بازکلید کامل. سناریوها موتور تولیدی را از طریق گراف Koin اجرا میکنند؛ اتوماسیون ضربهبهUI کامپوز و سفر مرورگر↔اندروید اجرا نشده است. یادآور و زیروظیفه از UI اندروید قابل ساختاند (انتخاب «کار والد») — این امکان در وب نیست. زمانبندی اعلان سیستم (یادآورها) پیاده نشده.
مرز Google Calendar / Calendar Provider
ورودِ تقویمهای دستگاه (فاز ۱، یکطرفه دستگاه→اپ) فعال است: از کشوی کناری
«تقویمهای دستگاه» تقویمها و رویدادهایشان انتخاب و وارد میشوند — هر ردیف
provider با uid قطعیِ مشتق از providerId از همان OfflineRepository.edit میگذرد
(source: ANDROID_PROVIDER روی wire)، پس outbox و LWW مثل هر ویرایش محلی اعمال
میشود و ورودِ دوباره بهجای تکرار، ادغام میشود. تست یکپارچهٔ
ProviderImportJourneyTest کل مسیر را با CalendarContract واقعی و push تا API
آزمون میکند (در rig اجرا میشود).
فاز ۲ (نوشتن دوطرفه به سمت دستگاه، از جمله پل sync-adapter حساب خود اپ) هنوز
پیاده نشده؛ پشتهٔ core/platform و DeviceCalendarSyncer خط فانوس برای آن
موجود است و باید روی همان مسیر engine سیمکشی شود.
لبههای پوششدادهشده
سناریوی «پیشفرضِ آفلاین موبایل در برابر پیشفرض وب»: تست یکپارچه
offlineDefaultCalendarMergesIntoServerDefaultRekeysChildrenAndPreference
(SyncEngineTest) — تقویم پیشفرض محلی + رویدادهایش آفلاین ساخته میشود؛ ادغام
پیشفرض سرور با uid وب پاس میدهد؛ ردیف بازکلید میشود، هر دو رویداد زنده میمانند
و ارجاع میگیرند، ترجیح پیشفرض کاربر دنبال میکند و چیزی از دست نمیرود.
بازکلیدِ نیمهکاره (کرش بین Room و DataStore) در ConflictRecoveryTest با replay
پوشش دارد.
هدف Wasm این پروژهٔ KMP در ۲ ۰۲۶-10-10 بهکلی حذف شد (تصمیم بهرهبردار): کلاینت وبِ محصول،
React (apps/calendar-web) است. یافتههای تاریخدارِ نسل پیش در ممیزی ۲۰۲۶-۰۹-۲۷ هستند.