feat(android): add better notifications and permanent notification for focus mode

This commit is contained in:
Johannes Millan 2025-12-19 15:52:11 +01:00
parent ffa7122aea
commit f7901ba47f
14 changed files with 973 additions and 47 deletions

View file

@ -87,5 +87,25 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="time_tracking" />
</service>
<service
android:name=".service.FocusModeForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="focus_timer" />
</service>
<receiver
android:name=".receiver.ReminderAlarmReceiver"
android:enabled="true"
android:exported="false" />
<receiver
android:name=".receiver.ReminderActionReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View file

@ -13,6 +13,7 @@ import androidx.activity.addCallback
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity
import com.superproductivity.superproductivity.plugins.SafBridgePlugin
import com.superproductivity.superproductivity.service.FocusModeForegroundService
import com.superproductivity.superproductivity.service.TrackingForegroundService
import com.superproductivity.superproductivity.util.printWebViewVersion
import com.superproductivity.superproductivity.webview.JavaScriptInterface
@ -158,6 +159,27 @@ class CapacitorMainActivity : BridgeActivity() {
callJSInterfaceFunctionIfExists("next", "onMarkTaskDone$")
return
}
// Handle focus mode notification actions
FocusModeForegroundService.ACTION_PAUSE -> {
Log.d("SP_FOCUS", "Pause action received from focus mode notification")
callJSInterfaceFunctionIfExists("next", "onFocusPause$")
return
}
FocusModeForegroundService.ACTION_RESUME -> {
Log.d("SP_FOCUS", "Resume action received from focus mode notification")
callJSInterfaceFunctionIfExists("next", "onFocusResume$")
return
}
FocusModeForegroundService.ACTION_SKIP -> {
Log.d("SP_FOCUS", "Skip action received from focus mode notification")
callJSInterfaceFunctionIfExists("next", "onFocusSkip$")
return
}
FocusModeForegroundService.ACTION_COMPLETE -> {
Log.d("SP_FOCUS", "Complete action received from focus mode notification")
callJSInterfaceFunctionIfExists("next", "onFocusComplete$")
return
}
}
// Handle share intent

View file

@ -0,0 +1,58 @@
package com.superproductivity.superproductivity.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
/**
* Handles reminder snooze action in the background by simply rescheduling the alarm.
* No app involvement needed - just dismiss notification and schedule new alarm.
*/
class ReminderActionReceiver : BroadcastReceiver() {
companion object {
const val TAG = "ReminderActionReceiver"
const val ACTION_SNOOZE = "com.superproductivity.REMINDER_SNOOZE"
const val EXTRA_NOTIFICATION_ID = "notification_id"
const val EXTRA_REMINDER_ID = "reminder_id"
const val EXTRA_RELATED_ID = "related_id"
const val EXTRA_TITLE = "title"
const val EXTRA_REMINDER_TYPE = "reminder_type"
const val SNOOZE_DURATION_MS = 10 * 60 * 1000L // 10 minutes
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_SNOOZE) return
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
val reminderId = intent.getStringExtra(EXTRA_REMINDER_ID) ?: return
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
Log.d(TAG, "Snooze: notificationId=$notificationId, title=$title")
// Dismiss the notification
if (notificationId != -1) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
// Reschedule alarm for 10 minutes from now
val newTriggerTime = System.currentTimeMillis() + SNOOZE_DURATION_MS
ReminderNotificationHelper.scheduleReminder(
context,
notificationId,
reminderId,
relatedId,
title,
reminderType,
newTriggerTime
)
Log.d(TAG, "Rescheduled reminder for ${SNOOZE_DURATION_MS / 60000} minutes from now")
}
}

View file

@ -0,0 +1,44 @@
package com.superproductivity.superproductivity.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
/**
* Receives alarm broadcasts and shows reminder notifications.
*/
class ReminderAlarmReceiver : BroadcastReceiver() {
companion object {
const val TAG = "ReminderAlarmReceiver"
const val ACTION_SHOW_REMINDER = "com.superproductivity.ACTION_SHOW_REMINDER"
const val EXTRA_NOTIFICATION_ID = "notification_id"
const val EXTRA_REMINDER_ID = "reminder_id"
const val EXTRA_RELATED_ID = "related_id"
const val EXTRA_TITLE = "title"
const val EXTRA_REMINDER_TYPE = "reminder_type"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_SHOW_REMINDER) return
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
val reminderId = intent.getStringExtra(EXTRA_REMINDER_ID) ?: return
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title")
ReminderNotificationHelper.showNotification(
context,
notificationId,
reminderId,
relatedId,
title,
reminderType
)
}
}

View file

@ -0,0 +1,179 @@
package com.superproductivity.superproductivity.service
import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationManagerCompat
class FocusModeForegroundService : Service() {
companion object {
const val TAG = "FocusModeService"
const val ACTION_START = "com.superproductivity.ACTION_START_FOCUS"
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_FOCUS"
const val ACTION_UPDATE = "com.superproductivity.ACTION_UPDATE_FOCUS"
const val ACTION_PAUSE = "com.superproductivity.ACTION_PAUSE_FOCUS"
const val ACTION_RESUME = "com.superproductivity.ACTION_RESUME_FOCUS"
const val ACTION_SKIP = "com.superproductivity.ACTION_SKIP_FOCUS"
const val ACTION_COMPLETE = "com.superproductivity.ACTION_COMPLETE_FOCUS"
const val EXTRA_TITLE = "title"
const val EXTRA_TASK_TITLE = "task_title"
const val EXTRA_DURATION_MS = "duration_ms"
const val EXTRA_REMAINING_MS = "remaining_ms"
const val EXTRA_IS_BREAK = "is_break"
const val EXTRA_IS_PAUSED = "is_paused"
@Volatile
var isRunning: Boolean = false
private set
}
private var title: String = ""
private var taskTitle: String? = null
private var durationMs: Long = 0
private var remainingMs: Long = 0
private var isBreak: Boolean = false
private var isPaused: Boolean = false
private var lastUpdateTimestamp: Long = 0
private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = object : Runnable {
override fun run() {
if (isRunning && !isPaused) {
// Update remaining time (countdown mode)
val now = System.currentTimeMillis()
val elapsed = now - lastUpdateTimestamp
lastUpdateTimestamp = now
if (durationMs > 0) {
// Countdown mode: decrease remaining time
remainingMs = (remainingMs - elapsed).coerceAtLeast(0)
} else {
// Flowtime mode: increase elapsed time (remainingMs is actually elapsed)
remainingMs += elapsed
}
updateNotification()
handler.postDelayed(this, 1000)
}
}
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
FocusModeNotificationHelper.createChannel(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand: action=${intent?.action}")
when (intent?.action) {
ACTION_START -> {
title = intent.getStringExtra(EXTRA_TITLE) ?: "Focus"
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE)
durationMs = intent.getLongExtra(EXTRA_DURATION_MS, 0L)
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, 0L)
isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, false)
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false)
startFocusMode()
}
ACTION_UPDATE -> {
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, remainingMs)
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, isPaused)
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: taskTitle
lastUpdateTimestamp = System.currentTimeMillis()
updateNotification()
}
ACTION_STOP -> {
stopFocusMode()
}
else -> {
// Service restarted by system - we have no state to restore
Log.d(TAG, "Service started without action, stopping")
stopSelf()
}
}
return START_NOT_STICKY
}
private fun startFocusMode() {
Log.d(TAG, "Starting focus mode: title=$title, durationMs=$durationMs, remainingMs=$remainingMs, isBreak=$isBreak, isPaused=$isPaused")
isRunning = true
lastUpdateTimestamp = System.currentTimeMillis()
// Start foreground immediately to avoid ANR
val notification = FocusModeNotificationHelper.buildNotification(
this,
title,
taskTitle,
remainingMs,
isPaused,
isBreak
)
startForeground(FocusModeNotificationHelper.NOTIFICATION_ID, notification)
// Start update loop if not paused
handler.removeCallbacks(updateRunnable)
if (!isPaused) {
handler.post(updateRunnable)
}
}
private fun stopFocusMode() {
Log.d(TAG, "Stopping focus mode")
isRunning = false
handler.removeCallbacks(updateRunnable)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun updateNotification() {
if (!isRunning) return
try {
val notification = FocusModeNotificationHelper.buildNotification(
this,
title,
taskTitle,
remainingMs,
isPaused,
isBreak
)
NotificationManagerCompat.from(this).notify(
FocusModeNotificationHelper.NOTIFICATION_ID,
notification
)
} catch (e: SecurityException) {
Log.w(TAG, "No permission to post notification", e)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
isRunning = false
handler.removeCallbacks(updateRunnable)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d(TAG, "Task removed, stopping service")
stopFocusMode()
}
}

View file

@ -0,0 +1,133 @@
package com.superproductivity.superproductivity.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.superproductivity.superproductivity.CapacitorMainActivity
import com.superproductivity.superproductivity.R
object FocusModeNotificationHelper {
const val CHANNEL_ID = "sp_focus_mode_channel"
const val NOTIFICATION_ID = 1002
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Focus Mode",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows focus mode timer status"
setShowBadge(false)
}
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
fun buildNotification(
context: Context,
title: String,
taskTitle: String?,
remainingMs: Long,
isPaused: Boolean,
isBreak: Boolean
): Notification {
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentPendingIntent = PendingIntent.getActivity(
context,
10,
contentIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_sp)
.setContentTitle(buildTitle(title, taskTitle))
.setContentText(formatDuration(remainingMs))
.setContentIntent(contentPendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
// Add Pause/Resume action
if (isPaused) {
val resumeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
action = FocusModeForegroundService.ACTION_RESUME
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val resumePendingIntent = PendingIntent.getActivity(
context,
11,
resumeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(0, "Resume", resumePendingIntent)
} else {
val pauseIntent = Intent(context, CapacitorMainActivity::class.java).apply {
action = FocusModeForegroundService.ACTION_PAUSE
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pausePendingIntent = PendingIntent.getActivity(
context,
12,
pauseIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(0, "Pause", pausePendingIntent)
}
// Add Skip (for breaks) or Complete (for work sessions) action
if (isBreak) {
val skipIntent = Intent(context, CapacitorMainActivity::class.java).apply {
action = FocusModeForegroundService.ACTION_SKIP
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val skipPendingIntent = PendingIntent.getActivity(
context,
13,
skipIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(0, "Skip", skipPendingIntent)
} else {
val completeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
action = FocusModeForegroundService.ACTION_COMPLETE
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val completePendingIntent = PendingIntent.getActivity(
context,
14,
completeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(0, "Complete", completePendingIntent)
}
return builder.build()
}
private fun buildTitle(focusTitle: String, taskTitle: String?): String {
return if (taskTitle.isNullOrBlank()) {
focusTitle
} else {
"$focusTitle: $taskTitle"
}
}
fun formatDuration(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%d:%02d", minutes, seconds)
}
}

View file

@ -0,0 +1,144 @@
package com.superproductivity.superproductivity.service
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.superproductivity.superproductivity.CapacitorMainActivity
import com.superproductivity.superproductivity.R
import com.superproductivity.superproductivity.receiver.ReminderActionReceiver
import com.superproductivity.superproductivity.receiver.ReminderAlarmReceiver
/**
* Simple helper for native reminder notifications.
* Snooze works entirely in background (just reschedules alarm).
* Tapping notification opens app.
*/
object ReminderNotificationHelper {
const val TAG = "ReminderNotifHelper"
const val CHANNEL_ID = "sp_reminders_channel"
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Reminders",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Task and note reminders"
setShowBadge(true)
enableVibration(true)
}
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
fun scheduleReminder(
context: Context,
notificationId: Int,
reminderId: String,
relatedId: String,
title: String,
reminderType: String,
triggerAtMs: Long
) {
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title")
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, ReminderAlarmReceiver::class.java).apply {
action = ReminderAlarmReceiver.ACTION_SHOW_REMINDER
putExtra(ReminderAlarmReceiver.EXTRA_NOTIFICATION_ID, notificationId)
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_ID, reminderId)
putExtra(ReminderAlarmReceiver.EXTRA_RELATED_ID, relatedId)
putExtra(ReminderAlarmReceiver.EXTRA_TITLE, title)
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_TYPE, reminderType)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pendingIntent)
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pendingIntent)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule reminder", e)
}
}
fun cancelReminder(context: Context, notificationId: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, ReminderAlarmReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, notificationId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
NotificationManagerCompat.from(context).cancel(notificationId)
}
fun showNotification(
context: Context,
notificationId: Int,
reminderId: String,
relatedId: String,
title: String,
reminderType: String
) {
createChannel(context)
// Tapping notification opens app
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentPendingIntent = PendingIntent.getActivity(
context, notificationId, contentIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Snooze - handled by BroadcastReceiver, no app needed
val snoozeIntent = Intent(context, ReminderActionReceiver::class.java).apply {
action = ReminderActionReceiver.ACTION_SNOOZE
putExtra(ReminderActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
putExtra(ReminderActionReceiver.EXTRA_REMINDER_ID, reminderId)
putExtra(ReminderActionReceiver.EXTRA_RELATED_ID, relatedId)
putExtra(ReminderActionReceiver.EXTRA_TITLE, title)
putExtra(ReminderActionReceiver.EXTRA_REMINDER_TYPE, reminderType)
}
val snoozePendingIntent = PendingIntent.getBroadcast(
context, notificationId * 10 + 1, snoozeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_sp)
.setContentTitle(title)
.setContentText(if (reminderType == "TASK") "Task reminder" else "Note reminder")
.setContentIntent(contentPendingIntent)
.setAutoCancel(true)
.addAction(0, "Snooze 10m", snoozePendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
try {
NotificationManagerCompat.from(context).notify(notificationId, notification)
} catch (e: SecurityException) {
Log.e(TAG, "No permission to show notification", e)
}
}
}

View file

@ -10,6 +10,8 @@ import com.superproductivity.superproductivity.App
import com.superproductivity.superproductivity.BuildConfig
import com.superproductivity.superproductivity.FullscreenActivity.Companion.WINDOW_INTERFACE_PROPERTY
import com.superproductivity.superproductivity.app.LaunchDecider
import com.superproductivity.superproductivity.service.FocusModeForegroundService
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
import com.superproductivity.superproductivity.service.TrackingForegroundService
@ -108,6 +110,75 @@ class JavaScriptInterface(
}
}
@Suppress("unused")
@JavascriptInterface
fun startFocusModeService(
title: String,
durationMs: Long,
remainingMs: Long,
isBreak: Boolean,
isPaused: Boolean,
taskTitle: String?
) {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_START
putExtra(FocusModeForegroundService.EXTRA_TITLE, title)
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
putExtra(FocusModeForegroundService.EXTRA_DURATION_MS, durationMs)
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
}
ContextCompat.startForegroundService(activity, intent)
}
@Suppress("unused")
@JavascriptInterface
fun stopFocusModeService() {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_STOP
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun updateFocusModeService(remainingMs: Long, isPaused: Boolean, taskTitle: String?) {
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
action = FocusModeForegroundService.ACTION_UPDATE
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
}
activity.startService(intent)
}
@Suppress("unused")
@JavascriptInterface
fun scheduleNativeReminder(
notificationId: Int,
reminderId: String,
relatedId: String,
title: String,
reminderType: String,
triggerAtMs: Long
) {
ReminderNotificationHelper.scheduleReminder(
activity,
notificationId,
reminderId,
relatedId,
title,
reminderType,
triggerAtMs
)
}
@Suppress("unused")
@JavascriptInterface
fun cancelNativeReminder(notificationId: Int) {
ReminderNotificationHelper.cancelReminder(activity, notificationId)
}
fun callJavaScriptFunction(script: String) {
webView.post { webView.evaluateJavascript(script) { } }

View file

@ -43,6 +43,33 @@ export interface AndroidInterface {
stopTrackingService?(): void;
getTrackingElapsed?(): string;
// Foreground service methods for focus mode timer
startFocusModeService?(
title: string,
durationMs: number,
remainingMs: number,
isBreak: boolean,
isPaused: boolean,
taskTitle: string | null,
): void;
stopFocusModeService?(): void;
updateFocusModeService?(
remainingMs: number,
isPaused: boolean,
taskTitle: string | null,
): void;
// Native reminder scheduling (snooze handled entirely in background)
scheduleNativeReminder?(
notificationId: number,
reminderId: string,
relatedId: string,
title: string,
reminderType: string,
triggerAtMs: number,
): void;
cancelNativeReminder?(notificationId: number): void;
// added here only
onResume$: Subject<void>;
onPause$: Subject<void>;
@ -58,6 +85,12 @@ export interface AndroidInterface {
// Notification action callbacks
onPauseTracking$: Subject<void>;
onMarkTaskDone$: Subject<void>;
// Focus mode notification action callbacks
onFocusPause$: Subject<void>;
onFocusResume$: Subject<void>;
onFocusSkip$: Subject<void>;
onFocusComplete$: Subject<void>;
}
// setInterval(() => {
@ -75,6 +108,10 @@ if (IS_ANDROID_WEB_VIEW) {
androidInterface.onPause$ = new Subject();
androidInterface.onPauseTracking$ = new Subject();
androidInterface.onMarkTaskDone$ = new Subject();
androidInterface.onFocusPause$ = new Subject();
androidInterface.onFocusResume$ = new Subject();
androidInterface.onFocusSkip$ = new Subject();
androidInterface.onFocusComplete$ = new Subject();
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);

View file

@ -0,0 +1,195 @@
import { inject, Injectable } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { map, pairwise, startWith, tap } from 'rxjs/operators';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { androidInterface } from '../android-interface';
import {
selectIsBreakActive,
selectIsLongBreak,
selectMode,
selectTimeRemaining,
selectTimer,
} from '../../focus-mode/store/focus-mode.selectors';
import * as focusModeActions from '../../focus-mode/store/focus-mode.actions';
import { selectCurrentTask } from '../../tasks/store/task.selectors';
import { combineLatest } from 'rxjs';
import { FocusModeMode, TimerState } from '../../focus-mode/focus-mode.model';
import { DroidLog } from '../../../core/log';
@Injectable()
export class AndroidFocusModeEffects {
private _store = inject(Store);
// Start/stop focus mode notification when timer state changes
syncFocusModeToNotification$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
combineLatest([
this._store.select(selectTimer),
this._store.select(selectMode),
this._store.select(selectCurrentTask),
this._store.select(selectIsBreakActive),
this._store.select(selectIsLongBreak),
this._store.select(selectTimeRemaining),
]).pipe(
map(
([timer, mode, currentTask, isBreakActive, isLongBreak, timeRemaining]) => ({
timer,
mode,
currentTask,
isBreakActive,
isLongBreak,
timeRemaining,
}),
),
startWith(null),
pairwise(),
tap(([prev, curr]) => {
if (!curr) return;
const {
timer,
mode,
currentTask,
isBreakActive,
isLongBreak,
timeRemaining,
} = curr;
const taskTitle = currentTask?.title || null;
// Check if focus mode is active (has a purpose)
const isFocusModeActive = timer.purpose !== null;
const wasFocusModeActive = prev?.timer?.purpose !== null;
if (isFocusModeActive) {
const title = this._getNotificationTitle(mode, isBreakActive, isLongBreak);
const remainingMs = timer.duration > 0 ? timeRemaining : timer.elapsed; // Flowtime shows elapsed
// Start service if just became active, otherwise update
if (!wasFocusModeActive) {
DroidLog.log('AndroidFocusModeEffects: Starting focus mode service', {
title,
duration: timer.duration,
remaining: remainingMs,
isBreak: isBreakActive,
isPaused: !timer.isRunning,
});
androidInterface.startFocusModeService?.(
title,
timer.duration,
remainingMs,
isBreakActive,
!timer.isRunning,
taskTitle,
);
} else if (this._hasStateChanged(prev?.timer, timer, taskTitle, curr)) {
// Only update if something significant changed
DroidLog.log('AndroidFocusModeEffects: Updating focus mode service', {
remaining: remainingMs,
isPaused: !timer.isRunning,
});
androidInterface.updateFocusModeService?.(
remainingMs,
!timer.isRunning,
taskTitle,
);
}
} else if (wasFocusModeActive && !isFocusModeActive) {
// Focus mode ended, stop the service
DroidLog.log('AndroidFocusModeEffects: Stopping focus mode service');
androidInterface.stopFocusModeService?.();
}
}),
),
{ dispatch: false },
);
// Handle notification action callbacks
handleFocusPause$ =
IS_ANDROID_WEB_VIEW &&
createEffect(() =>
androidInterface.onFocusPause$.pipe(
tap(() => DroidLog.log('AndroidFocusModeEffects: Pause action received')),
map(() => focusModeActions.pauseFocusSession()),
),
);
handleFocusResume$ =
IS_ANDROID_WEB_VIEW &&
createEffect(() =>
androidInterface.onFocusResume$.pipe(
tap(() => DroidLog.log('AndroidFocusModeEffects: Resume action received')),
map(() => focusModeActions.unPauseFocusSession()),
),
);
handleFocusSkip$ =
IS_ANDROID_WEB_VIEW &&
createEffect(() =>
androidInterface.onFocusSkip$.pipe(
tap(() => DroidLog.log('AndroidFocusModeEffects: Skip action received')),
map(() => focusModeActions.skipBreak()),
),
);
handleFocusComplete$ =
IS_ANDROID_WEB_VIEW &&
createEffect(() =>
androidInterface.onFocusComplete$.pipe(
tap(() => DroidLog.log('AndroidFocusModeEffects: Complete action received')),
map(() => focusModeActions.completeFocusSession({ isManual: true })),
),
);
private _getNotificationTitle(
mode: FocusModeMode,
isBreak: boolean,
isLongBreak: boolean,
): string {
if (isBreak) {
return isLongBreak ? 'Long Break' : 'Break';
}
switch (mode) {
case 'Pomodoro':
return 'Pomodoro';
case 'Flowtime':
return 'Flow';
case 'Countdown':
return 'Focus';
default:
return 'Focus';
}
}
private _hasStateChanged(
prevTimer: TimerState | undefined,
currTimer: TimerState,
taskTitle: string | null,
curr: {
timer: TimerState;
mode: FocusModeMode;
currentTask: { title: string } | null;
isBreakActive: boolean;
isLongBreak: boolean;
timeRemaining: number;
},
): boolean {
if (!prevTimer) return true;
// Check if pause state changed
if (prevTimer.isRunning !== currTimer.isRunning) return true;
// Check if purpose changed (work -> break or vice versa)
if (prevTimer.purpose !== currTimer.purpose) return true;
// Only update notification every 5 seconds to reduce overhead
// (native service already updates every second)
const elapsedDiff = Math.abs(currTimer.elapsed - prevTimer.elapsed);
if (elapsedDiff >= 5000) return true;
return false;
}
}

View file

@ -4,6 +4,7 @@ import { Store } from '@ngrx/store';
import {
distinctUntilChanged,
filter,
map,
pairwise,
startWith,
tap,
@ -16,6 +17,8 @@ import { selectCurrentTask } from '../../tasks/store/task.selectors';
import { DroidLog } from '../../../core/log';
import { DateService } from '../../../core/date/date.service';
import { Task } from '../../tasks/task.model';
import { selectTimer } from '../../focus-mode/store/focus-mode.selectors';
import { combineLatest } from 'rxjs';
@Injectable()
export class AndroidForegroundTrackingEffects {
@ -26,21 +29,50 @@ export class AndroidForegroundTrackingEffects {
/**
* Start/stop the native foreground service when the current task changes.
* Also handles syncing time when switching tasks directly.
* NOTE: When focus mode is active, we hide the tracking notification
* to avoid showing two notifications (focus mode notification takes priority).
*/
syncTrackingToService$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
this._store.select(selectCurrentTask).pipe(
distinctUntilChanged((a, b) => a?.id === b?.id),
startWith(null as Task | null),
combineLatest([
this._store.select(selectCurrentTask),
this._store.select(selectTimer),
]).pipe(
map(([currentTask, timer]) => ({
currentTask,
isFocusModeActive: timer.purpose !== null,
})),
distinctUntilChanged(
(a, b) =>
a.currentTask?.id === b.currentTask?.id &&
a.isFocusModeActive === b.isFocusModeActive,
),
startWith({ currentTask: null as Task | null, isFocusModeActive: false }),
pairwise(),
tap(([prevTask, currentTask]) => {
tap(([prev, curr]) => {
const { currentTask, isFocusModeActive } = curr;
const prevTask = prev.currentTask;
const wasFocusModeActive = prev.isFocusModeActive;
// If switching from one task to another (or stopping), sync the previous task's time first
if (prevTask) {
// Also sync when focus mode just started (to capture time tracked before focus mode)
const focusModeJustStarted = isFocusModeActive && !wasFocusModeActive;
if (prevTask && (!wasFocusModeActive || focusModeJustStarted)) {
this._syncElapsedTimeForTask(prevTask.id);
}
// Don't show tracking notification when focus mode is active
// (focus mode notification takes priority)
if (isFocusModeActive) {
DroidLog.log(
'Focus mode active, stopping tracking service to avoid duplicate notification',
);
androidInterface.stopTrackingService?.();
return;
}
if (currentTask) {
DroidLog.log('Starting tracking service', {
taskId: currentTask.id,

View file

@ -6,7 +6,6 @@ import { ReminderService } from '../../reminder/reminder.service';
import { LocalNotifications } from '@capacitor/local-notifications';
import { SnackService } from '../../../core/snack/snack.service';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { LocalNotificationSchema } from '@capacitor/local-notifications/dist/esm/definitions';
import { DroidLog } from '../../../core/log';
import { generateNotificationId } from '../android-notification-id.util';
import { androidInterface } from '../android-interface';
@ -55,6 +54,8 @@ export class AndroidEffects {
},
);
// Use native reminder scheduling with BroadcastReceiver for actions
// This allows snooze/done to work without opening the app
scheduleNotifications$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
@ -64,19 +65,17 @@ export class AndroidEffects {
tap(async (reminders) => {
try {
if (!reminders || reminders.length === 0) {
// Nothing to schedule yet, so avoid triggering the runtime permission dialog prematurely.
return;
}
DroidLog.log('AndroidEffects: scheduling reminders', {
DroidLog.log('AndroidEffects: scheduling reminders natively', {
reminderCount: reminders.length,
});
// Check permissions first
const checkResult = await LocalNotifications.checkPermissions();
DroidLog.log('AndroidEffects: pre-schedule permission check', checkResult);
let displayPermissionGranted = checkResult.display === 'granted';
if (!displayPermissionGranted) {
// Reminder scheduling only works after the runtime permission is accepted.
const requestResult = await LocalNotifications.requestPermissions();
DroidLog.log({ requestResult });
displayPermissionGranted = requestResult.display === 'granted';
if (!displayPermissionGranted) {
this._notifyPermissionIssue();
@ -84,42 +83,25 @@ export class AndroidEffects {
}
}
await this._ensureExactAlarmAccess();
const pendingNotifications = await LocalNotifications.getPending();
DroidLog.log({ pendingNotifications });
if (pendingNotifications.notifications.length > 0) {
await LocalNotifications.cancel({
notifications: pendingNotifications.notifications.map((n) => ({
id: n.id,
})),
});
// Schedule each reminder using native Android AlarmManager
for (const reminder of reminders) {
const id = generateNotificationId(reminder.relatedId);
const now = Date.now();
const scheduleAt =
reminder.remindAt <= now ? now + 1000 : reminder.remindAt;
androidInterface.scheduleNativeReminder?.(
id,
reminder.id,
reminder.relatedId,
reminder.title,
reminder.type,
scheduleAt,
);
}
// Re-schedule the full set so the native alarm manager is always in sync.
await LocalNotifications.schedule({
notifications: reminders.map((reminder) => {
// Use deterministic ID based on reminder's relatedId to prevent duplicate notifications
const id = generateNotificationId(reminder.relatedId);
const now = Date.now();
const scheduleAt =
reminder.remindAt <= now ? now + 1000 : reminder.remindAt; // push overdue reminders into the immediate future
const mapped: LocalNotificationSchema = {
id,
title: reminder.title,
body: '',
extra: {
reminder,
},
schedule: {
// eslint-disable-next-line no-mixed-operators
at: new Date(scheduleAt),
allowWhileIdle: true,
repeats: false,
every: undefined,
},
};
return mapped;
}),
});
DroidLog.log('AndroidEffects: scheduled local notifications', {
DroidLog.log('AndroidEffects: scheduled native reminders', {
reminderCount: reminders.length,
});
} catch (error) {

View file

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { ReminderService } from './reminder.service';
import { MatDialog } from '@angular/material/dialog';
import { IS_ELECTRON } from '../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import {
concatMap,
delay,
@ -121,6 +122,11 @@ export class ReminderModule {
@throttle(60000)
private _showNotification(reminders: Reminder[]): void {
// Skip on Android - we use native notifications with snooze button instead
if (IS_ANDROID_WEB_VIEW) {
return;
}
const isMultiple = reminders.length > 1;
const title = isMultiple
? '"' +

View file

@ -63,6 +63,7 @@ import { workContextReducer } from '../features/work-context/store/work-context.
import { WorkContextEffects } from '../features/work-context/store/work-context.effects';
import { IS_ANDROID_WEB_VIEW } from '../util/is-android-web-view';
import { AndroidEffects } from '../features/android/store/android.effects';
import { AndroidFocusModeEffects } from '../features/android/store/android-focus-mode.effects';
import { AndroidForegroundTrackingEffects } from '../features/android/store/android-foreground-tracking.effects';
import { CaldavIssueEffects } from '../features/issue/providers/caldav/caldav-issue.effects';
import { CalendarIntegrationEffects } from '../features/calendar-integration/store/calendar-integration.effects';
@ -153,7 +154,9 @@ import { PluginHooksEffects } from '../plugins/plugin-hooks.effects';
// EFFECTS ONLY
EffectsModule.forFeature([
...(IS_ANDROID_WEB_VIEW ? [AndroidEffects, AndroidForegroundTrackingEffects] : []),
...(IS_ANDROID_WEB_VIEW
? [AndroidEffects, AndroidFocusModeEffects, AndroidForegroundTrackingEffects]
: []),
]),
EffectsModule.forFeature([CaldavIssueEffects]),
EffectsModule.forFeature([CalendarIntegrationEffects]),