|
| 1 | +# Architecture Overview: GlobalState and ApplicationController |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +This app uses a **non-standard but sensible** architecture that separates Android lifecycle concerns from business logic. This document explains how the two core classes interact. |
| 6 | + |
| 7 | +## The Two Core Classes |
| 8 | + |
| 9 | +### GlobalState - The Android Application Class |
| 10 | + |
| 11 | +**Location:** `android/app/src/main/java/com/github/quarck/calnotify/GlobalState.kt` |
| 12 | + |
| 13 | +**What it is:** The actual Android `Application` subclass - the entry point that Android creates when the app process starts. |
| 14 | + |
| 15 | +**Responsibilities (minimal by design):** |
| 16 | +- Holds only ephemeral/instance-specific state: |
| 17 | + - `lastNotificationRePost` - when notifications were last refreshed |
| 18 | + - `lastTimerBroadcastReceived` - when last timer broadcast arrived |
| 19 | +- Initializes React Native (this is a hybrid RN app) |
| 20 | +- Initializes SoLoader for native code |
| 21 | +- Applies theme preferences on startup |
| 22 | +- Creates notification channels (required for Android 8+) |
| 23 | + |
| 24 | +**Extension property for access:** |
| 25 | +```kotlin |
| 26 | +val Context.globalState: GlobalState? |
| 27 | + get() { |
| 28 | + val appCtx = applicationContext |
| 29 | + if (appCtx is GlobalState) |
| 30 | + return appCtx |
| 31 | + return null |
| 32 | + } |
| 33 | +``` |
| 34 | + |
| 35 | +This allows any code with a `Context` to access `context.globalState?.lastNotificationRePost`. |
| 36 | + |
| 37 | +### ApplicationController - The Business Logic Singleton |
| 38 | + |
| 39 | +**Location:** `android/app/src/main/java/com/github/quarck/calnotify/app/ApplicationController.kt` |
| 40 | + |
| 41 | +**What it is:** A Kotlin `object` (singleton) - **not** an Android component! It's just a regular Kotlin object that holds all the app's business logic. |
| 42 | + |
| 43 | +**Responsibilities (extensive):** |
| 44 | +- Event management (register, dismiss, snooze, restore) |
| 45 | +- Notification management |
| 46 | +- Alarm scheduling |
| 47 | +- Calendar monitoring |
| 48 | +- Quiet hours management |
| 49 | +- All coordination between subsystems |
| 50 | + |
| 51 | +**Key design pattern:** Receives `Context` as a parameter rather than holding one: |
| 52 | +```kotlin |
| 53 | +object ApplicationController : ApplicationControllerInterface { |
| 54 | + fun onBootComplete(context: Context) { ... } |
| 55 | + fun onCalendarChanged(context: Context) { ... } |
| 56 | + fun dismissEvent(context: Context, ...) { ... } |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +This avoids memory leaks and makes the code more testable. |
| 61 | + |
| 62 | +## How They Interact |
| 63 | + |
| 64 | +``` |
| 65 | +┌─────────────────────────────────────────────────────────────────┐ |
| 66 | +│ Android OS │ |
| 67 | +└────────────────────────────┬────────────────────────────────────┘ |
| 68 | + │ creates on app start |
| 69 | + ▼ |
| 70 | +┌─────────────────────────────────────────────────────────────────┐ |
| 71 | +│ GlobalState (Application) │ |
| 72 | +│ - React Native host │ |
| 73 | +│ - Ephemeral state (lastNotificationRePost, etc.) │ |
| 74 | +│ - Theme/Notification channel init │ |
| 75 | +└─────────────────────────────────────────────────────────────────┘ |
| 76 | +
|
| 77 | +┌─────────────────────┐ ┌─────────────────────┐ ┌───────────────┐ |
| 78 | +│ BroadcastReceivers │ │ Activities │ │ Services │ |
| 79 | +│ - Boot │ │ - MainActivity │ │ - Snooze │ |
| 80 | +│ - Calendar changed │ │ - ViewEventActivity│ │ - Dismiss │ |
| 81 | +│ - Alarms │ │ - Settings │ │ - etc. │ |
| 82 | +└─────────┬───────────┘ └──────────┬──────────┘ └───────┬───────┘ |
| 83 | + │ │ │ |
| 84 | + │ All call into │ │ |
| 85 | + └─────────────────────────┼──────────────────────┘ |
| 86 | + ▼ |
| 87 | +┌─────────────────────────────────────────────────────────────────┐ |
| 88 | +│ ApplicationController (object/singleton) │ |
| 89 | +│ - ALL business logic │ |
| 90 | +│ - Event management │ |
| 91 | +│ - Notification management │ |
| 92 | +│ - Calendar monitoring │ |
| 93 | +│ - Alarm scheduling │ |
| 94 | +└─────────────────────────────────────────────────────────────────┘ |
| 95 | +``` |
| 96 | + |
| 97 | +## Entry Points |
| 98 | + |
| 99 | +The key insight is that **Android components call ApplicationController**, not the other way around: |
| 100 | + |
| 101 | +### BroadcastReceivers |
| 102 | +```kotlin |
| 103 | +// BootCompleteBroadcastReceiver.kt |
| 104 | +class BootCompleteBroadcastReceiver : BroadcastReceiver() { |
| 105 | + override fun onReceive(context: Context?, intent: Intent?) { |
| 106 | + if (context != null) |
| 107 | + ApplicationController.onBootComplete(context) |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +// CalendarChangedBroadcastReceiver.kt |
| 112 | +class CalendarChangedBroadcastReceiver : BroadcastReceiver() { |
| 113 | + override fun onReceive(context: Context?, intent: Intent?) { |
| 114 | + if (context != null) |
| 115 | + ApplicationController.onCalendarChanged(context) |
| 116 | + } |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +### Activities |
| 121 | +```kotlin |
| 122 | +// MainActivity.kt |
| 123 | +override fun onCreate(savedInstanceState: Bundle?) { |
| 124 | + super.onCreate(savedInstanceState) |
| 125 | + ApplicationController.onMainActivityCreate(this) |
| 126 | +} |
| 127 | + |
| 128 | +override fun onResume() { |
| 129 | + super.onResume() |
| 130 | + ApplicationController.onMainActivityResumed(this, shouldForceRepost, monitorSettingsChanged) |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +### Services |
| 135 | +```kotlin |
| 136 | +// NotificationActionDismissService.kt |
| 137 | +ApplicationController.dismissEvent(context, dismissType, event) |
| 138 | +``` |
| 139 | + |
| 140 | +## Why This Pattern? |
| 141 | + |
| 142 | +### 1. Multiple Entry Points |
| 143 | +Calendar notification apps respond to many system events: |
| 144 | +- Phone boots → need to restore notifications |
| 145 | +- Calendar changes → need to check for new/moved events |
| 146 | +- Snooze alarm fires → need to re-show notification |
| 147 | +- User taps notification → need to handle action |
| 148 | +- Time zone changes → need to recalculate times |
| 149 | +- App updates → need to refresh state |
| 150 | + |
| 151 | +All these entry points need **consistent behavior**. Centralizing logic in one place ensures this. |
| 152 | + |
| 153 | +### 2. Separation of Concerns |
| 154 | +- **GlobalState:** Android lifecycle only (Application class boilerplate) |
| 155 | +- **ApplicationController:** All business logic (pure Kotlin, no Android lifecycle) |
| 156 | + |
| 157 | +### 3. Testability |
| 158 | +The ApplicationController pattern with injectable providers makes testing easier: |
| 159 | +```kotlin |
| 160 | +// In ApplicationController |
| 161 | +var eventsStorageProvider: ((Context) -> EventsStorageInterface)? = null |
| 162 | + |
| 163 | +private fun getEventsStorage(ctx: Context): EventsStorageInterface { |
| 164 | + return eventsStorageProvider?.invoke(ctx) ?: EventsStorage(ctx) |
| 165 | +} |
| 166 | + |
| 167 | +// In tests |
| 168 | +ApplicationController.eventsStorageProvider = { mockEventsStorage } |
| 169 | +``` |
| 170 | + |
| 171 | +### 4. Avoiding Memory Leaks |
| 172 | +By receiving `Context` as a parameter rather than storing it, there's no risk of holding onto an Activity context after it's destroyed. |
| 173 | + |
| 174 | +## Comparison to "Standard" Simple Apps |
| 175 | + |
| 176 | +A typical simple Android app might look like: |
| 177 | + |
| 178 | +```kotlin |
| 179 | +class MainActivity : AppCompatActivity() { |
| 180 | + override fun onCreate(savedInstanceState: Bundle?) { |
| 181 | + // UI setup |
| 182 | + val button = findViewById<Button>(R.id.myButton) |
| 183 | + |
| 184 | + // Business logic mixed right in |
| 185 | + button.setOnClickListener { |
| 186 | + val items = loadItemsFromDatabase() // DB logic here |
| 187 | + showNotification(items.size) // Notification logic here |
| 188 | + updateUI(items) // UI logic here |
| 189 | + } |
| 190 | + } |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +This works for simple apps with one entry point (user opens app). It breaks down when you have 10+ entry points that all need the same behavior. |
| 195 | + |
| 196 | +## Historical Context |
| 197 | + |
| 198 | +This codebase was started in 2016, before Google provided official architecture guidance (Architecture Components came in 2017). The original author created a sensible pattern that predates the official recommendations. |
| 199 | + |
| 200 | +The pattern is actually **ahead of its time** in some ways - the separation of concerns and injectable providers are exactly what modern architecture recommends, just implemented differently. |
| 201 | + |
| 202 | +## Modern Equivalent |
| 203 | + |
| 204 | +Today, a similar architecture would use: |
| 205 | +- **ViewModel** instead of singleton object (for lifecycle awareness) |
| 206 | +- **Repository pattern** for data access |
| 207 | +- **Hilt** for dependency injection (instead of manual providers) |
| 208 | +- **Coroutines** for async operations |
| 209 | + |
| 210 | +See `docs/dev_todo/android_modernization.md` for migration considerations. |
| 211 | + |
0 commit comments