सामग्री पर जाएं
सभी पोस्ट

Mintly बनाना: जब Android प्रोसेस को मारना चाहता है तब भी फोकस टाइमर को सटीक रखना

चल रहे Pomodoro टाइमर की विश्वसनीयता की समस्या एक बार वाले रिमाइंडर से ज़्यादा कठिन होती है। जानिए कैसे Mintly एक फोरग्राउंड सर्विस और वॉल-क्लॉक एंड टाइम की मदद से Doze, प्रोसेस डेथ और स्क्रीन-ऑफ ड्रिफ्ट से बचता है।

MFKAPPS 6 मिनट पढ़ना

एक बार वाले रिमाइंडर को बस एक बार, लगभग सही समय पर ट्रिगर होना होता है। फोकस टाइमर एक कठिन समस्या है: इसे पूरे 25 मिनट तक दिखने लायक और लगातार सही रहना होता है — भले स्क्रीन बंद हो, भले Android सेशन के बीच में प्रोसेस को मारकर उसे फिर से शुरू करने का फैसला कर ले। अगर यह गलत हो जाए, तो नुकसान कोई छूटा हुआ नोटिफिकेशन नहीं होता — यह एक ऐसा टाइमर होता है जो पूरे एक मिनट तक “12:41 बचे हैं” दिखाता रहता है, या फोन पर एक नज़र डालने भर से 25:00 पर रीसेट हो जाता है।

Mintly बनाते समय मुझे दोनों समस्याएं झेलनी पड़ीं। यही वह आर्किटेक्चर है जिसने इन्हें ठीक किया: एक फोरग्राउंड सर्विस, घटते हुए काउंटर के बजाय वॉल-क्लॉक एंड टाइम, और एक ऐसा नोटिफिकेशन जो सर्विस पर भरोसा करके दोबारा बनवाने के बजाय अपनी खुद की काउंटडाउन खींचता है।

वह बग जिसने मुझे सबक सिखाया

मेरा पहला संस्करण साफ-साफ ज़ाहिर था: एक CoroutineWorker जैसा लूप जो हर सेकंड टिक करता, एक remainingSeconds फील्ड घटाता, और हर टिक पर अपडेट किया हुआ नोटिफिकेशन पोस्ट करता। यह एमुलेटर पर बिल्कुल सही चला और असली फोन पर एक दिन में ही बिखर गया। इसके दो फेल्योर मोड थे:

  • इकट्ठे हुए टिक्स। Doze मोड वेकअप्स को इकट्ठा कर देता है। स्क्रीन बंद होने के बाद “हर सेकंड एक टिक” वाला लूप वास्तव में हर सेकंड नहीं चल पाता — सिस्टम इसे टाल देता है, फिर एक साथ कई टिक्स एक झटके में डिलीवर करता है। दिखने वाली काउंटडाउन अटकती है या उछलती है।
  • खामोश प्रोसेस डेथ। अगर सिस्टम को मेमोरी की ज़रूरत पड़ी और उसने ऐप का प्रोसेस मार दिया, तो मेमोरी में मौजूद remainingSeconds फील्ड भी उसके साथ चला गया। रीस्टार्ट पर फिर से शुरू करने के लिए कुछ नहीं बचा, इसलिए टाइमर या तो गायब हो जाता या अपनी मूल अवधि से फिर शुरू हो जाता — यूज़र के नज़रिए से, फोकस के पांच मिनट अचानक ग़ायब हो जाते।

समाधान “ज़्यादा भरोसेमंद तरीके से टिक करना” नहीं था। समाधान यह था कि बचे हुए समय को ऐसे स्टेट की तरह मानना बंद किया जाए जिसे बचे रहना ज़रूरी है, और उसे ऐसी चीज़ की तरह माना जाए जिसे हमेशा फिर से गणना किया जा सकता है।

काउंटडाउन नहीं, एंड टाइम सहेजें

मूल बदलाव यह है: सेशन शुरू होते ही एक अकेला sessionEndAtMillis (वॉल-क्लॉक, System.currentTimeMillis() आधारित) सेव करें, और बाकी सब कुछ ज़रूरत पड़ने पर उसी से निकालें।

data class FocusSession(
    val id: Long,
    val ritual: RitualType,
    val startedAtMillis: Long,
    val endAtMillis: Long,
    val state: SessionState, // RUNNING, PAUSED, COMPLETED
)

fun FocusSession.remainingMillis(nowMillis: Long = System.currentTimeMillis()): Long =
    (endAtMillis - nowMillis).coerceAtLeast(0L)

यह एक बदलाव पूरे सिस्टम को खुद-ब-खुद ठीक होने वाला बना देता है। इससे फ़र्क़ नहीं पड़ता कि कितने टिक्स छूटे, या बीच में प्रोसेस मारा गया और फिर से शुरू हुआ या नहीं — remainingMillis() जिस पल बुलाया जाता है उसी पल सही होता है, क्योंकि यह किसी लूप से जोड़ा नहीं गया बल्कि दो टाइमस्टैम्प से गिना गया है। सेशन शुरू होते ही Room में सेव हो जाता है, इसलिए एक बिल्कुल नया प्रोसेस भी उसे दोबारा लोड करके फ़ौरन जान सकता है कि वह ठीक-ठीक कहां खड़ा है।

एक फोरग्राउंड सर्विस जो लगभग टिक नहीं करती

सर्विस का काम सिमट जाता है: प्रोसेस को इतना ज़िंदा रखना कि पूरा होना नोटिस हो सके, और नोटिफिकेशन को उचित रूप से ताज़ा रखना। सच्चाई का स्रोत यह नहीं है।

class FocusTimerService : Service() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val session = repository.activeSessionOrNull() ?: run { stopSelf(); return START_NOT_STICKY }

        startForeground(NOTIFICATION_ID, buildChronometerNotification(session))

        scope.launch {
            while (isActive) {
                val remaining = session.remainingMillis()
                if (remaining <= 0L) {
                    repository.markCompleted(session.id)
                    postCompletionNotification(session)
                    stopSelf()
                    break
                }
                delay(15_000) // refresh cadence, not the source of truth
            }
        }
        return START_REDELIVER_INTENT
    }
}

दो बातें यहां ज़्यादातर काम करती हैं:

  • नोटिफिकेशन पर setUsesChronometer(true), जिसे chronometerCountDown = true के साथ और endAtMillis से गणना किए गए बेस के साथ शुरू किया गया है। इसके बाद Android का अपना नोटिफिकेशन सिस्टम अपनी ही घड़ी का इस्तेमाल करके हर सेकंड काउंटडाउन दोबारा खींचता है — मेरा लूप नहीं। दिखने वाला नंबर कभी नहीं अटकता, भले मेरी सर्विस हर 15 सेकंड में ही रीफ्रेश होती हो।
  • START_STICKY नहीं, START_REDELIVER_INTENT अगर सिस्टम सर्विस को मार देता है, तो वह मूल इंटेंट के साथ फिर से शुरू होती है, और onStartCommand मेमोरी में रखी किसी भी चीज़ पर भरोसा करने के बजाय Room से activeSessionOrNull() को दोबारा पढ़ता है। एक मारी और फिर से शुरू की गई सर्विस बिल्कुल वैसा ही नोटिफिकेशन बनाती है जैसा कभी न मरी हो।

एग्ज़ैक्ट अलार्म का सेफ्टी नेट

एक फोरग्राउंड सर्विस के मारे जाने की संभावना बैकग्राउंड सर्विस से कहीं कम होती है, पर “कहीं कम संभावना” कोई गारंटी नहीं है — एक आक्रामक OEM बैटरी मैनेजर फिर भी इसे गिरा सकता है। इसलिए पूरा होने का एक दूसरा रास्ता है जो सर्विस के ज़िंदा रहने पर बिल्कुल निर्भर नहीं है: सेशन शुरू होते ही endAtMillis के लिए शेड्यूल किया गया एक एग्ज़ैक्ट अलार्म, जो उसी setExactAndAllowWhileIdle पैटर्न का इस्तेमाल करता है जिसके बारे में मैंने Hydrame के रिमाइंडर्स के लिए लिखा था। अगर अलार्म बजने पर सर्विस ज़िंदा है, तो यह एक हानिरहित दोहराव जांच भर है। अगर सर्विस मार दी गई थी, तो असल में समय पर “सेशन पूरा” पोस्ट करने वाला यही अलार्म होता है। बेल्ट और सस्पेंडर, पर सस्पेंडर की कीमत बस एक AlarmManager कॉल है।

ऐप फिर से शुरू होने पर सबसे पहले क्या जांचा जाता है

जब ऐप फोरग्राउंड में लौटती है — चाहे नए लॉन्च से हो, नोटिफिकेशन से हो, या मल्टीटास्किंग स्विचर से — फोकस स्क्रीन जो सबसे पहला काम करती है वह कुछ भी बनाने से पहले activeSessionOrNull() को पढ़ना और remainingMillis() को बुलाना है। “मेरे जाने से पहले टाइमर चल रहा था क्या?” जैसा कोई फ्लैग नहीं है जो पुराना पड़ सके। या तो Room में बचे हुए समय वाला एक एक्टिव सेशन मौजूद है, और UI बिल्कुल वही दिखाता है, या नहीं है, और वह रिचुअल पिकर दिखाता है। टाइमर खुद से विरोधाभास नहीं कर सकता क्योंकि जवाब की गणना करने की इजाज़त सिर्फ़ एक ही जगह को है।

सामान्य सबक

यह पैटर्न फोकस टाइमर से आगे भी लागू होता है: जब भी आपका मन किसी लाइव काउंटडाउन को बदलने वाले इन-मेमोरी स्टेट के रूप में रखने का करे, उसे एक तय एंड टाइमस्टैम्प और “अभी” के एक शुद्ध फंक्शन से बदल दें। टिक्स सच्चाई का स्रोत होने के बजाय बस एक रीफ्रेश कैडेंस बन जाते हैं, जिसका मतलब है कि छूटे हुए टिक्स, मारे गए प्रोसेस, और Doze मोड का इकट्ठा होना अब सटीकता की गड़बड़ियां नहीं रहतीं — ये बस वे पल हैं जब किसी ने सवाल नहीं पूछा, और अगली बार जब कोई पूछता है तो जवाब फिर भी सही होता है।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 5 मिनट पढ़ना

Subly बनाना: सब्सक्रिप्शन रिन्यूअल तारीखों के पीछे का कैलेंडर गणित

किसी सब्सक्रिप्शन की अगली चार्ज तारीख का अनुमान लगाना तब तक साधारण लगता है जब तक आप महीने के अंत की बिलिंग, लीप ईयर और ट्रायल कन्वर्शन से नहीं टकराते। यहाँ बताया गया है कि Subly इसे डिवाइस पर ही कैसे सही करता है।

#android #engineering #subscriptions
MFKAPPS 5 मिनट पढ़ना

Android नोटिफ़िकेशन एक्शन सही तरीके से: बिना ऐप खोले एक खुराक को ली गई के रूप में चिह्नित करना

Android नोटिफ़िकेशन एक्शन बटन के लिए एक व्यावहारिक गाइड — PendingIntent, activity-trampoline प्रतिबंध, goAsync(), और Room स्टेट को सुरक्षित रूप से अपडेट करना।

#android #engineering #notifications
MFKAPPS 8 मिनट पढ़ना

2026 में भरोसेमंद Android रिमाइंडर: WorkManager, सटीक अलार्म और नए बैटरी नियम

2026 में Android पर ऐसे रिमाइंडर कैसे शिप करें जो सचमुच फायर हों — WorkManager बनाम AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS, और वे OEM झंझटें जो अब भी काटती हैं।

#android #engineering #mobile