Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Architecture

Aarsh edited this page May 26, 2026 · 4 revisions

Architecture

A walk-through of how the codebase is organised and how a typical user action flows from a tap to Firestore and back.


Module layout

This is a single-module Android application. The root project (Varisankya) contains exactly one module: :app.

varisankya-android/
├── settings.gradle.kts          # rootProject.name = "Varisankya"; include(":app")
├── build.gradle.kts             # Top-level plugin declarations (apply false)
├── gradle/libs.versions.toml    # Version catalogue
└── app/
    ├── build.gradle.kts         # Module config — android {}, plugins, deps, GPP block
    └── src/main/...

There is no library-module split. If the codebase grows, a future :core module for the data layer would be the natural first carve-out.


Runtime architecture

MVVM, with one ViewModel and view-XML layouts. No Compose, no Hilt/DI, no Navigation component.

Layer Lives in What it does
UI *.kt activities + bottom_sheet_* fragments + XML layouts findViewById + manual binding (no view binding generation enabled per class)
ViewModel viewmodel/MainViewModel Holds StateFlow<List<Subscription>> and StateFlow<HeroState>; subscribes to Firestore via a snapshot listener; orchestrates writes (markAsPaid, updateSubscriptionStatus, deleteSubscription)
Repository / helpers util/PaymentRepository, util/DateHelper, etc. Centralised Firestore paths, recurrence math, currency formatting
Data Subscription.kt, PaymentRecord.kt Firestore-mapped data classes
Background SubscriptionNotificationWorker (WorkManager) + receiver/NotificationActionReceiver (broadcast) Daily reminder notifications + the "Mark Paid" action button

Screen inventory

Screen Class Layout Notes
Home MainActivity activity_main.xml Hero card, subscription list, Extended FAB, swipe-to-mark-paid
All Payments / History UnifiedHistoryActivity activity_unified_history.xml Hero with avg-monthly (Tukey IQR), drill-down column chart, list
Search SearchActivity activity_search.xml Full-screen search with filter chips
Settings SettingsActivity activity_settings.xml Collapsing large-app-bar; cards for Currency / Security / Appearance / Typography / Notifications / Haptics / Legal
Login screen (overlay on Home) (inside MainActivity) activity_main.xml (the login_container) Shown when auth.currentUser == null

Bottom sheets (DialogFragment subclasses):

Sheet Class Layout Trigger
Add / Edit subscription AddSubscriptionBottomSheet bottom_sheet_add_subscription.xml FAB tap (Add); subscription tap (Edit)
Manage Payments PaymentBottomSheet bottom_sheet_payment.xml "Manage Payments" button in Edit sheet
Generic confirmation ConfirmationBottomSheet bottom_sheet_confirmation.xml Swipe-to-paid, delete sub, pause sub
Generic selection SelectionBottomSheet bottom_sheet_selection.xml Recurrence picker, category picker, currency picker
About AboutBottomSheet bottom_sheet_about.xml Settings → About
History cleanup CleanupBottomSheet bottom_sheet_cleanup.xml Auto-shown by All Payments when invalid records are detected

BaseActivity is the common parent that applies the theme + transition setup; every concrete activity extends it.


A request flow end-to-end

What happens when a user swipes a subscription right to mark it paid:

1. User swipes right on item_subscription in subscriptions_recycler_view
   └─ ItemTouchHelper → SwipeActionCallback.onSwipeRight (util/SwipeHelpers)
2. SubscriptionActionHelper.confirmMarkAsPaid()
   ├─ Analytics.paymentMarkPaidSwipe()             ← telemetry
   └─ Shows ConfirmationBottomSheet ("Mark Paid?")
3. User taps Confirm
   └─ viewModel.markAsPaid(subscription, onSuccess, onError)
4. MainViewModel.markAsPaid()
   ├─ Builds PaymentRecord(date=now, amount, currency, …)
   ├─ Computes nextDueDate via DateHelper.calculateNextDueDate()
   ├─ Firestore batch:
   │    set(users/{uid}/subscriptions/{sid}/payments/{pid}, payment)
   │    update(users/{uid}/subscriptions/{sid}, dueDate=next)
   ├─ On batch success:
   │    PaymentRepository.mirrorPaymentToFlat(...)  ← dual-write to flat
   │    SubscriptionNotificationWorker.cancelFor()  ← clear OS notification
   │    onSuccess()
5. Firestore snapshot listener in MainViewModel fires
   └─ _subscriptions emits new list
   └─ HomeScreen reactively updates (hero recalculates, list refreshes)

Notable invariants:

  • The dual-write to the flat collection is best-effort — if it fails (e.g. Firestore rules don't allow), the legacy nested write is still authoritative.
  • Notification cancel happens client-side after the Firestore write — both paths (in-app and notification-action) converge on the same SubscriptionNotificationWorker.cancelFor() helper so the notification-ID scheme stays in one place.

Notifications

SubscriptionNotificationWorker is a CoroutineWorker scheduled via a chained one-shot pattern (v3.8-beta.13+) — every run schedules the next one from the current wall-clock time, so the worker is self-correcting against timezone travel and DST:

SubscriptionNotificationWorker.scheduleNext(context, replacing)
  // enqueues a OneTimeWorkRequest under unique-work-name
  // "subscription_notifications". delayMs is computed in Calendar fields
  // (HOUR_OF_DAY / MINUTE) anchored to the device's CURRENT local zone, so
  // there is no absolute UTC instant baked in.

  // Call sites:
  //   MainActivity.setupNotifications  → replacing=false, KEEP policy
  //   SettingsActivity.rescheduleNotifications → replacing=true, REPLACE
  //   SubscriptionNotificationWorker.doWork end → replacing=true (self-chain)
  //   SystemEventReceiver (ACTION_TIMEZONE_CHANGED / ACTION_TIME_SET) → REPLACE

SystemEventReceiver listens for ACTION_TIMEZONE_CHANGED and ACTION_TIME_SET (both protected system broadcasts) and immediately re-anchors the worker schedule so the user doesn't even see one drifted notification at the moment of travel.

The worker:

  1. Reads all active subscriptions for the signed-in user.
  2. Computes days-left for each (both LocalDate.now() and the parsed due-date are computed in UTC — see the design invariant in AGENTS.md).
  3. If daysLeft in 0..NotificationDays (user-configurable in Settings), posts a notification with:
    • Title: Due Today / Due Tomorrow / Due in N days
    • Message: [Autopay • ]<subscription.name>: <currency> <cost>
    • Mark Paid action → NotificationActionReceiver.ACTION_MARK_PAID → marks paid + cancels notification + mirrors to flat collection
    • Body tap → opens MainActivity with EXTRA_FROM_NOTIFICATION=true (used to log notification_tap)
    • Swipe-away → NotificationActionReceiver.ACTION_NOTIFICATION_DISMISSED (used to log notification_dismiss)
  4. Logs notification_worker_run (with subscriptions_checked / notifications_posted counts) and notification_posted per actual posting — see Analytics & Audit.

Channel. Notifications use channel id subscription_reminders_v3 with IMPORTANCE_DEFAULT. The channel id has been bumped twice — Android caches per-channel importance, so changing the level requires recreating with a new id. Legacy subscription_reminders / _v1 / _v2 channels are deleted on first worker run.

Notification ID scheme. SubscriptionNotificationWorker.notificationIdFor(subscriptionId) = subscriptionId.hashCode(). Stable across worker runs so re-firing overwrites rather than stacks.

The worker runs daily at the user's chosen reminder time (Settings → Notifications → Daily Reminder). A user-triggered diagnostic — Settings → Notifications → Send Test Notification — calls SubscriptionNotificationWorker.postTestNotification(context) to post a sample notification immediately, bypassing the worker entirely.


UI patterns worth knowing

  • Grouped list shapes. SubscriptionAdapter applies different ShapeAppearanceModels based on position (first, middle, last, single). First and last items have large outer corners and small inner corners; middle items have small corners all around. Visually creates a single "grouped" surface from N cards.
  • Extended FAB shrink-on-scroll. MainActivity's NestedScrollView scroll listener calls fab.shrink() / fab.extend() based on scroll direction — standard Pixel pattern. The FAB's colour pair (colorPrimaryContainer / colorOnPrimaryContainer) is applied programmatically in MainActivity.applyFabColours() because M3's ExtendedFAB style silently swallows android:textColor set in XML — see the design invariant in AGENTS.md.
  • Sticky-resistance swipe-to-mark-paid. util/SwipeHelpers.applyStickyDamping() applies a non-linear damping curve to the visual translation: within the first 15 % of the view width the item only follows at ~45 % of finger movement (the "stick"), past that threshold it follows 1:1 carrying the accumulated offset. Two haptic events bracket the release point. Mirrors the Android notification-swipe feel.
  • CollapsingToolbarLayout in Settings. Title is Display-sized at rest, scales+pins on scroll. Owned by XML's app:title.
  • Bottom sheet drag handle haptics. BottomSheetDialog's drag handle gets performHaptics(CLOCK_TICK) on drag-state changes for tactile feedback.
  • Material 3 chips everywhere with the project-wide Widget.App.Chip style for consistent selected/unselected treatment.

What's intentionally absent

  • No Compose. All UI is view-XML + findViewById. View Binding generation is enabled in the build but most files don't use the generated class — adapters and activities use findViewById directly.
  • No DI framework (Hilt / Koin). The app is small enough that direct construction is fine.
  • No Navigation component. Activities navigate via plain Intent; bottom sheets are shown via DialogFragment.show().
  • No Room. All persistence is Firestore. Local cache is whatever the Firestore SDK does internally.
  • No analytics events with high cardinality — scalar params only; merchant strings, amounts, IDs are not logged. See Analytics & Audit.

See also

Clone this wiki locally